C++ 【右值引用】极致的内存管理

news/2025/2/26 14:59:13

文章目录

    • 一、C++中的左值与右值引用
        • 左值与右值的区别
        • 左值
        • 右值
        • 右值引用语法
    • 二、左值引用与右值引用的使用
        • 左值引用能引用右值吗?
        • 右值引用能引用左值吗?
    • 三、右值引用的底层原理
        • 右值引用常量
        • 右值引用绑定 move 后的左值
        • 为什么我们需要右值引用?
        • 左值引用解决的拷贝问题
        • 右值引用解决的拷贝问题
      • 四、移动语义
        • 移动构造与移动赋值
        • 为什么会调用移动构造?
        • 移动赋值
      • 右值引用的本质
        • STL容器的移动构造与移动赋值
      • 引用折叠
      • 完美转发
      • 完美转发

一、C++中的左值与右值引用

在传统的C++语法中,我们使用的是左值引用,而C++11引入了右值引用的概念。为便于理解,接下来我们会将之前学习的引用称为左值引用。无论是左值引用还是右值引用,它们本质上都是为对象取别名。

左值与右值的区别

在讲解右值引用之前,我们需要首先了解左值与右值的区别。

左值

左值是表示数据的表达式,我们可以对其取地址并且赋值。左值能够出现在赋值操作符(=)的左边,而右值则不能。

例如,下面的代码片段中:

int i = 0;
int* p = &i;
double d = 3.14;

变量 ipd 都是左值。首先,它们出现在赋值操作符 = 的左边;其次,我们可以获取它们的地址,并修改它们的值。

对于常量变量来说:

const int ci = 0;
int const* cp = &ci;
const double cd = 3.14;

cicpcd 也是左值,尽管它们具有 const 属性,使得它们的值不可修改,但它们仍然出现在 = 的左边并且可以取地址。

因此,左值最显著的特征是可以取地址,但不一定能被修改。

右值引用是C++11中引入的一种新语法,它使得程序能够更高效地处理临时对象。为了更好地理解右值引用,我们需要先了解什么是右值。

右值

右值也是一种表示数据的表达式,例如字面常量表达式的返回值函数的返回值等。右值可以出现在赋值操作符(=)的右边,但不能出现在左边。与左值不同,右值不能取地址。

以下是一个例子:

double func() {
    return 3.14;
}

int x = 10;
int y = 20;
int z = x + y;
double d = func();

在这段代码中,1020x + yfunc() 都是右值。具体来说,1020 是字面常量,x + y 是表达式的返回值,而 func() 是函数的返回值。右值的显著特点是它们不能取地址。

通过对比,左值和右值的最大区别就是:左值可以取地址,而右值不能。

右值引用语法

首先,我们来回顾一下左值引用的语法:

int i = 0;
int* p = nullptr;

int& ri = i;
int*& rp = p;

在左值引用的语法中,我们只需在原变量类型后加一个 &,便能创建一个左值引用。这时,新变量相当于原变量的别名,可以通过引用传递参数、返回值等,从而减少不必要的拷贝。

然而,左值引用不能引用右值。例如:

int& ri = 0;       // 错误,右值不能绑定到左值引用
int*& rp = nullptr; // 错误
double& rd = 3.14;  // 错误

以上代码尝试将右值绑定到左值引用,但编译时会报错。左值引用语法是 type&,而右值引用的语法是 type&&

接下来,我们尝试使用右值引用来引用右值:

int&& ri = 0;
int*&& rp = nullptr;
double&& rd = 3.14;

在这种情况下,右值引用成功地引用了右值。

二、左值引用与右值引用的使用

现在我们已经了解了右值引用的语法,但接下来我们需要考虑以下两个问题:

  1. 左值引用能引用右值吗?
  2. 右值引用能引用左值吗?

虽然之前我们已经展示过左值引用不能直接引用右值,但这里我们要进一步深入讨论和澄清一些特殊情况。

左值引用能引用右值吗?

回顾之前的测试,我们知道左值引用不能直接引用右值。例如:

int& i = 5;  // 错误

这段代码是非法的,因为右值(5)不能绑定到左值引用。这正是引入右值引用语法的原因之一。

但是,const 左值引用可以引用右值:

const int& i = 5;  // 合法

为什么会出现这种情况呢?因为常量引用具有常性,无法修改引用的对象。当我们使用 const 引用时,C++允许我们引用右值。这样,我们就能将一个右值绑定到一个常量左值引用上,避免了修改常量的风险。

右值引用能引用左值吗?

接下来讨论右值引用能否引用左值。右值引用通常用于绑定到右值,但它并不直接支持左值。然而,在某些特殊情况下,右值引用也能绑定到左值:

int x = 10;
int&& rx = std::move(x);  // 合法

这里,我们使用 std::move(x)x 转换为右值,从而允许将它绑定到右值引用。注意,std::move 并不真的是移动数据,它只是将对象转换为右值引用类型。因此,rx 实际上是绑定到左值 x 上的,但通过 std::move 强制它成为右值引用。

  • 左值引用不能直接引用右值,但const 左值引用可以引用右值。
  • 右值引用不能直接引用左值,但通过 std::move 可以将左值转换为右值,从而绑定到右值引用。

三、右值引用的底层原理

右值引用的引入为C++带来了更高效的资源管理和转移操作。接下来,我们将深入了解右值引用的底层工作原理,主要分为两种情况:右值引用常量和右值引用经过 move 转换后的左值。

右值引用常量

当右值引用绑定到一个常量时,引用并不会直接指向常量区的数据,而是将数据拷贝到栈区,然后让引用指向栈区中的数据。为什么这样做呢?因为如果右值引用直接指向常量区中的数据,修改该数据将会导致程序出现未定义行为。

看看这段代码:

int&& r = 5;  // 右值引用常量
r = 10;        // 修改数据

在这个过程中,我们使用右值引用 r 绑定到了常量 5,然后通过右值引用将其值修改为 10。但如果 r 直接指向常量区中的 5,修改它就会导致常量区中的数据被不合法地改变,这是不允许的。

因此,右值引用常量时的真实操作是:将常量区中的数据拷贝到栈区,并且引用指向栈区的数据,这样就避免了对常量区数据的非法修改。通过这个方式,右值引用常量的值可以被修改,而不会影响原始常量区的数据。

同样,const 左值引用引用常量时也是类似的,数据会先被拷贝到栈区,然后引用指向栈区的数据,但无法修改这个数据。

总结:

  • 右值引用常量:会把常量区的数据拷贝到栈区,然后引用指向栈区中的数据,且该数据可以修改。
  • const 左值引用常量:会把常量区的数据拷贝到栈区,然后引用指向栈区中的数据,但该数据是常量,不能修改。
右值引用绑定 move 后的左值

当右值引用绑定到经过 move 处理的左值时,它不会复制数据,而是直接指向原始的左值。这种情况下,右值引用实际上就成了左值的别名。

举个例子:

int i = 5;
int&& rri = std::move(i); // move 转换左值为右值

rri = 10;

std::cout << i << std::endl;  // 输出 10
std::cout << rri << std::endl; // 输出 10

在这个例子中,rrii 共享同一块内存,因此修改 rri 也会影响 i 的值。这里,rri 就相当于是 i 的别名。使用 std::move 将左值转换为右值,使得右值引用可以引用并操作该左值。

这是否与左值引用非常相似呢?的确如此。当右值引用绑定到经过 move 处理的左值时,它与直接使用左值引用没有任何区别。

为什么我们需要右值引用?

左值引用在C++中引入后,解决了许多拷贝的问题,比如传递参数时,参数的不必要拷贝。

来看以下代码:

string add_string(string& s1, string& s2)
{
    string s = s1 + s2;
    return s;
}

int main()
{
    string str;
    string hello = "Hello";
    string world = "world";
    
    str = add_string(hello, world);

    return 0;
}

在这个例子中,add_string 函数使用引用传递两个 string 参数,从而避免了两个 string 对象的拷贝,提升了效率。然而,这并没有解决一些额外的性能问题,比如当返回值需要被拷贝时,仍然会消耗不必要的资源

左值引用解决的拷贝问题

左值引用在传参和返回值中解决了一部分拷贝构造的问题。比如,当函数返回一个局部变量时,如果使用左值引用来返回,避免了不必要的拷贝构造。

考虑以下代码:

string& say_hello()
{
    static string s = "hello world";
    return s;
}

int main()
{
    string str1;
    str1 = say_hello();
    return 0;
}

在这个例子中,函数 say_hello 返回一个 string 类型的引用,指向静态变量 s因为 s 是静态变量,它在函数调用结束后仍然存在,所以我们可以直接返回它的引用。这样,当 str1 接收返回值时,我们不需要创建临时变量进行拷贝构造,而是直接通过引用赋值,从而节省了拷贝构造的开销。

右值引用解决的拷贝问题

然而,当我们返回一个局部变量时,即使使用左值引用,也无法避免拷贝构造的问题。因为局部变量在函数结束时会被销毁,必须要返回一个临时值。来看以下代码:

string say_hello()
{
    string s = "hello world";
    return s;
}

int main()
{
    string str;
    str = say_hello();
    return 0;
}

在这段代码中,say_hello 返回的是局部变量 s。由于 s 是局部变量,它在函数结束时会被销毁,因此返回 s 时会先拷贝构造一个临时对象,然后临时对象再被拷贝构造到 str。这导致了两次拷贝构造。

为了避免这种情况,C++引入了右值引用。右值引用允许我们将局部变量的资源"转移"到外部,从而避免不必要的拷贝。

我们可以使用 std::moves 转换为右值引用,这样在返回时就不会进行拷贝构造,而是直接将资源转移给外部对象。看看这个改进的版本:

string say_hello()
{
    string s = "hello world";
    return std::move(s);  // 转移资源
}

int main()
{
    string str;
    str = say_hello();  // 只进行一次资源转移
    return 0;
}

这里,std::move(s) 会将 s 转换为右值引用,从而实现资源的转移,而不需要进行拷贝构造。通过右值引用,我们避免了拷贝的开销,并且提高了程序的性能。

右值引用不仅仅是一种语法,它本质上是一个“标记”,标识一个对象的资源可以被移动。它允许我们在返回值时,避免不必要的资源拷贝,直接转移对象的所有权。

这就是右值引用存在的意义。当我们需要返回一个对象时,使用右值引用可以避免不必要的拷贝,将对象的所有权直接转移到目标位置,从而提升性能。

  • 右值引用常量:拷贝常量数据到栈区,引用指向栈区数据,且该数据可以修改。
  • const 左值引用常量:拷贝常量数据到栈区,引用指向栈区数据,但数据不可修改。
  • 右值引用move后的左值:右值引用直接指向原左值,修改右值引用也会影响原左值。
  • 右值引用的意义:避免不必要的拷贝,提高程序效率,特别是在处理临时对象和返回值时。

我们先来探讨一下什么情况下会产生可以被右值引用的左值。

  1. 左值被move
    当一个左值被move后,它就可以被右值引用。这表明程序员明确表示该左值的资源可以被迁移,从而赋予了它右值的特性。

  2. 将亡值
    C++会把即将离开作用域的非引用类型的返回值视为右值,这种类型的右值也被称为将亡值

回顾一个典型场景:函数内部的局部变量s已经创建好了字符串"hello world",但s马上就要离开函数作用域并被销毁。为了避免资源浪费,C++允许将s的资源直接迁移给外部变量,而不是进行不必要的拷贝。

这种情况下,变量s即将作用离开域并被销毁,但它内部的"hello world"是我们需要的。

因此,C++将即将离开作用域的非引用类型返回值视为右值,这种右值的核心含义是:这个变量的资源可以被迁移走。这句话非常重要!

C++引入move属性的原因是,有些变量的生命周期还很长,C++不敢擅自迁移它们的资源。但当程序员调用move时,就明确表示了可以迁移该变量的资源。这相当于程序员亲自许可,将左值的资源迁移走。

四、移动语义

那么,右值是如何迁移资源的呢?这就涉及到右值引用的移动语义

为了更好地理解移动语义,我们需要首先了解右值引用在实现资源转移时的重要性。右值引用不仅仅是为了优化性能,它还引入了一种新的语义,使得程序能够高效地管理资源,尤其是在避免不必要的拷贝构造时。

我们先定义一个简单的 mystring 类,模拟字符串的管理,并引入构造、拷贝构造、赋值操作等功能。

class mystring
{
public:
    // 构造函数
    mystring(const char* str = "")
    {
        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }

    // 析构函数
    ~mystring()
    {
        delete[] _str;
    }

    // 赋值重载
    mystring& operator=(const mystring& s)
    {
        cout << "赋值重载" << endl;
        return *this;
    }

    // 拷贝构造
    mystring(const mystring& s)
    {
        cout << "拷贝构造" << endl;
    }

private:
    char* _str = nullptr;
};

在这个类中,_str 是一个指向字符数组的指针,负责存储字符串。当我们通过拷贝构造和赋值操作创建新对象时,资源会被复制到新对象中。

移动构造与移动赋值

在没有移动构造的情况下,返回一个 mystring 对象会触发多次拷贝构造。在下面的例子中,我们演示了这种情况:

mystring get_string()
{
    mystring str("hello");
    return str; // 发生拷贝构造
}

int main()
{
    mystring s2 = get_string();  // 发生两次拷贝构造
    return 0;
}

在这个过程中,str 是局部变量,当 get_string 函数返回时,str 会先被拷贝构造到一个临时变量,然后再拷贝构造到 s2。如果字符串有大量字符,这种做法会非常低效,因为每次都需要进行拷贝。

为了避免这种效率问题,我们可以通过移动构造来实现资源的转移,避免不必要的拷贝:

class mystring
{
public:
    // 移动构造
    mystring(mystring&& s)
    {
        cout << "移动构造" << endl;
        std::swap(_str, s._str);  // 交换指针
    }

    // 拷贝构造
    mystring(const mystring& s)
    {
        cout << "拷贝构造" << endl;
    }
};

在这个新的移动构造函数中,我们使用 std::swap 交换 s 和当前对象 _str 的指针,实际上就是转移 s 所拥有的资源,而不是进行数据拷贝。这样,返回值 str 的资源就可以直接转移给临时对象。

为什么会调用移动构造?

get_string 函数返回时,str 是一个将亡值(临时变量),它具有右值属性。由于右值属性的存在,str 会调用移动构造,而不是拷贝构造。随后,s2 会通过移动构造直接获得 str 的资源,而不会再进行拷贝。

流程如下:

  1. get_string 返回时,str 是一个右值,触发移动构造,返回临时变量。
  2. 临时变量通过移动构造转移资源给 s2

通过这种方式,我们避免了两次拷贝构造的开销,只有一次资源转移。

移动赋值

除了移动构造,还有移动赋值操作,它允许我们在对象已经存在的情况下,将另一个对象的资源转移到当前对象中:

// 移动赋值重载
mystring& operator=(mystring&& s)
{
    std::swap(_str, s._str);
    return *this;
}

右值引用的本质

右值引用不仅仅是语法上的变化,它的引入实现了移动语义移动指的是资源的转移,而语义则表示这是有意进行资源转移的行为。

  • 移动构造:通过交换指针而不是复制数据,实现资源的转移。
  • 移动赋值:通过交换指针而不是复制数据,将一个对象的资源转移给另一个对象。

右值引用和移动构造的出现,解决了对象返回时的拷贝问题。C++11之后,类的成员函数数量从6个增加到了8个,新增了移动构造移动赋值重载,它们为C++带来了极大的性能提升,尤其是在处理大量数据或复杂对象时。

STL容器的移动构造与移动赋值

在C++11中,STL容器也更新了移动构造和移动赋值操作。这是为了能够利用移动语义提高性能,尤其是在处理临时对象时,避免不必要的深拷贝。

C++11的vector构造函数

vector(vector&& v);  // 移动构造

C++11的vector赋值操作符

vector& operator=(vector&& v);  // 移动赋值重载

这些移动构造和赋值操作使得STL容器能够直接转移数据的所有权,而不是进行昂贵的拷贝操作,从而显著提高了性能。

引用折叠

引用折叠是C++11引入的一个非常重要的概念,它与万能引用T&&)密切相关,能够使得代码更加简洁和高效。

在下面的代码中,我们定义了两个重载的func函数,一个接受右值引用,另一个接受常量左值引用:

template <class T>
void func(T&& t)
{
    cout << "T&& 右值引用" << endl;
}

template <class T>
void func(const T& t)
{
    cout << "const T& const左值引用" << endl;
}

int main()
{
    int a = 5;
    func(a);        // 左值
    func(move(a));  // 右值

    return 0;
}

程序输出:

T&& 右值引用
T&& 右值引用

为什么左值也会调用右值引用的版本?

这正是因为C++的引用折叠规则。T&&在模板推导时会根据传入的参数类型推导为适当的类型,即便传入的是左值引用。C++11中的引用折叠规则为:

T& && 会推导为 T&
T&& && 会推导为 T&&

因此,T&&可以“折叠”为左值或右值引用,而不需要写多个模板函数重载。

C++11引入的引用折叠特性,使得我们可以编写更简洁、统一的模板函数来处理左值引用和右值引用。通过一个模板函数,我们可以同时处理左值和右值,而无需显式编写多个重载版本。

以下是使用引用折叠的一种方式:

template <class T>
void func(T&& t)
{
    // 处理t
}

int a = 5;
func(a);        // 左值
func(move(a));  // 右值

当调用func(a)时,a是一个左值,而当调用func(move(a))时,move(a)是一个右值。在这两种情况下,C++通过引用折叠规则来决定如何处理T&&参数。

  • 第一次调用 func(a)
    T 被推导为 int&,因此 T&& 会折叠为 int& &&,最终类型为 int&,表示 t 是左值引用。

  • 第二次调用 func(move(a))
    T 被推导为 int&&,因此 T&& 会折叠为 int&& &&,最终类型为 int&&,表示 t 是右值引用。

这种引用折叠机制能够统一处理左值和右值,从而避免我们需要显式为每个情况写出不同的函数重载。实际上,如果我们使用一个模板,就能生成原本需要四个重载的情况:

void func(int&);         // 左值引用
void func(const int&);   // 常量左值引用
void func(int&&);        // 右值引用
void func(const int&&);  // 常量右值引用

通过引用折叠规则,我们只需要一个模板函数就能统一处理这四种情况。这样大大减少了代码的冗余,并使得代码更加简洁和灵活。

完美转发

在调用其他函数时,我们希望传递的参数保持其原本的左值或右值属性。完美转发是通过std::forward实现的,它确保了参数在传递时保持其原始的值类别(左值或右值)。std::forward只有在需要转发一个万能引用时才会派上用场。

考虑以下代码:

void func2(int& x)
{
    cout << "func2 左值引用" << endl;
}

void func2(int&& x)
{
    cout << "func2 右值引用" << endl;
}

template <class T>
void fuc1(T&& t)
{
    func2(t);  // 这里会调用哪个函数呢?
}

int main()
{
    int i = 5;
    fuc1(i);        // 传递左值
    fuc1(move(i));  // 传递右值

    return 0;
}

输出结果:

func2 左值引用
func2 右值引用

为什么第一次调用 fuc1(i) 时,调用了左值版本,而 fuc1(move(i)) 调用时,调用了右值版本呢?

这是因为在调用fuc1时,T&&会根据传入的参数类型推导出对应的类型:

  • fuc1(i) 时,Tint&,所以 T&& 会折叠成 int& &,根据折叠规则变为 int&,即左值引用类型。
  • fuc1(move(i)) 时,Tint&&,所以 T&& 会折叠成 int&& &&,根据折叠规则变为 int&&,即右值引用类型。

这就是引用折叠的规则。

完美转发

为了确保在转发参数时保留原始的左值或右值属性,我们需要使用std::forward,如下所示:

template <class T>
void fuc1(T&& t)
{
    func2(std::forward<T>(t));  // 保留t的原始值属性
}

通过使用std::forward<T>(t)t会保持其原始的值类别,确保func2接受正确的左值或右值。

  1. 引用折叠:C++11中的引用折叠使得模板函数能够统一处理左值引用和右值引用,简化了代码。通过折叠规则,T&&可以变成不同类型的引用(左值引用或右值引用)。

  2. 完美转发:通过std::forward,我们可以将传入的参数保持其原始的值类别,避免不必要的拷贝或错误的类型转化。

  3. 移动语义与STL容器:STL容器也支持移动构造和移动赋值操作,从而使得容器能够高效地处理临时对象,避免不必要的拷贝,提高性能。

这两项功能(引用折叠和完美转发)使得C++在处理对象时更加灵活和高效,尤其是在涉及到泛型编程和资源管理时。


http://www.niftyadmin.cn/n/5868850.html

相关文章

非结构化数据管理平台如何解决企业数据孤岛问题?

在数字化转型的进程中&#xff0c;企业积累了大量的非结构化数据&#xff0c;如文档、图片、视频等。然而&#xff0c;这些数据往往分散存储在不同的系统和部门中&#xff0c;形成了所谓的 “数据孤岛”。数据孤岛不仅导致数据难以共享和利用&#xff0c;还增加了企业的管理成本…

AI知识架构之数据采集

数据采集 数据格式: 结构化数据:以固定格式和结构存储,如数据库中的表以及 Excel 表格,易于查询和分析。半结构化数据:有一定结构但不如结构化数据严格,XML 常用于数据交换,JSON 在 Web 应用中广泛用于数据传输和存储。非结构化数据:无预定义结构,文本、图像、音频和视…

leetcode_动态规划和递归 509. 斐波那契数

509. 斐波那契数 斐波那契数 &#xff08;通常用 F(n) 表示&#xff09;形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始&#xff0c;后面的每一项数字都是前面两项数字的和。也就是&#xff1a; F(0) 0&#xff0c;F(1) 1F(n) F(n - 1) F(n - 2)&#xff0c;其中 n …

C++ | 高级教程 | 文件和流

&#x1f47b; 概念 文件流输出使用标准库 fstream&#xff0c;定义三个新的数据类型&#xff1a; 数据类型描述ofstream输出文件流&#xff0c;用于创建文件并向文件写入信息。ifstream输入文件流&#xff0c;用于从文件读取信息。fstream文件流&#xff0c;且同时具有 ofst…

【2025信息安全软考重点考点归纳】实时更新

重点页:第14章 恶意代码防范技术原理 页码&#xff1a;271 病毒载体及其对应案例 病毒隐秘载体病毒案例Word文档Melissa照片库尔尼科娃电子邮件“求职信”病毒网页NIMDA病毒 重点页&#xff1a;第6章 认证技术原理与应用 页码&#xff1a;125 Kerberos 认证技术 Kerberos是…

【UML】统一建模语言 UML 基础

【UML】统一建模语言UML 基础 文章目录 一、概述1.1 - 什么是建模1.2 建模的原则1.3 软件建模的实现过程 二、 UML2.1 UML中10种图 三、用例图3.1 用例之间的关系 —— 泛化关系3.2 用例之间的关系 —— 包含关系3.3 用例之间的关系 —— 扩展关系 四、类图4.1 类的表示方法4.2…

【docker】docker swarm lock和unlock的区别,以及旧节点重启的隐患

docker swarm lock/unlock 的作用 Docker Swarm 提供了**加密集群状态&#xff08;Encrypted Raft logs&#xff09;**的功能&#xff0c;可以防止 Swarm 集群的管理数据&#xff08;如任务分配、集群配置等&#xff09;在磁盘上被未授权访问。 docker swarm lock&#xff1a…

Dockerfile 中的 COPY 语句:作用与使用详解

在 Docker 的构建过程中&#xff0c;Dockerfile 是一个核心文件&#xff0c;它定义了镜像的构建步骤和内容。其中&#xff0c;COPY 语句是一个非常重要的指令&#xff0c;用于将文件或目录从构建上下文&#xff08;通常是 Dockerfile 所在的目录及其子目录&#xff09;复制到容…