当前位置: 首页 > news >正文

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

文章目录

    • 一、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++在处理对象时更加灵活和高效,尤其是在涉及到泛型编程和资源管理时。

相关文章:

  • Kotlin 嵌套类和内部类
  • 链表:struct node *next;为什么用指针,为什么要用自身结构体类型?(通俗易懂)
  • 以太坊基金会换帅,资本市场砸盘
  • 【Java 后端】Restful API 接口
  • dify基础之prompts
  • 【计算机网络】常见tcp/udp对应的应用层协议,端口
  • IO与NIO的区别
  • set 和 map 的左右护卫 【刷题反思】
  • android::hardware::configureRpcThreadpool使用介绍
  • OpenCV计算摄影学(3)CUDA 图像去噪函数fastNlMeansDenoising()
  • Kubernetes (K8S) 高效使用技巧与实践指南
  • PyTorch 的 nn.NLLLoss:负对数似然损失全解析
  • 在 ASP.NET Core 中压缩并减少图像的文件大小
  • lqb官方题单-速成刷题清单(上) - python版
  • AI 实战2 - face -detect
  • Open3D解决SceneWidget加入布局中消失的问题
  • composer 错误汇总
  • 排序算法(3):
  • Dify Workflows MCP Server (TypeScript)设计与实战
  • 人工智能之数学基础:线性代数中矩阵的运算
  • 湖南省人民政府网站/semantics
  • 注册公司需要注意什么事项/站长工具seo源码
  • 网站设计的英文/网络营销与网站推广的
  • 做网站的客户资料交换qq群/百度提交入口网址
  • 一个做二维码问卷调查的网站/平台运营推广方案
  • mstsc做网站/seo挂机赚钱