右值引用详解

1 前言

右值引用是 C++11 引入的与 Lambda 表达式齐名的重要特性之一。它的引入解决了 C++ 中大量的历史遗留问题, 消除了诸如 std::vectorstd::string 之类的额外开销。

先说一下一个看到的结论,引入右值引用,就是为了移动语义。移动语义就是为了减少拷贝。std::move就是将左值转为右值引用。这样就可以重载到移动构造函数了,移动构造函数将指针赋值一下就好了,不用深拷贝了,提高性能

2 什么是左值、右值、纯右值、将亡值

左值,lvalue,就是赋值符号左边的值,如a=5,a就是左值,但是准确的来说,左值是表达式(不一定是赋值表达式)后仍然存在的持久对象。

右值,rvalue,右边的值,是指表达式结束就不存在的临时对象。

纯右值,prvalue,用于计算的或者用于初始化对象的的右值。

( 在介绍将亡值之前对左右和右值做个总结,有地址的变量就是左值,没有地址的字面值、临时值就是右值。)

将亡值,xvalue,是C++11为了引入右值引用而提出的概念,与纯右值的不同点在于,将亡值是即将被销毁、却能够被移动的值。可能比较难理解,如下代码:

std::vector foo(){
    std::vector temp = {1,2,3,4,5};
    return temp;
}
std::vector v = foo();

上段代码,函数foo的返回值temp在内部创建然后赋值给v,然而v或者这个对象时会将整个temp拷贝一份,然后把temp销毁,如果这个temp非常大,将会造成大量额外的开销。最后一行中v是左值,foo()返回的值是纯右值。但是v可以被别的变量捕获到,而foo()产生的那个返回值作为一个临时值,一旦被v复制后,将立即被销毁也不能修改,将亡值就定义了这样一种行为:临时的值能够被识别、同时又能够被移动。

在 C++11 之后,编译器为我们做了一些工作,此处的左值 temp 会被进行此隐式右值转换, 等价于 static_cast &&>(temp),进而此处的 v 会将 foo 局部返回的值进行移动。 也就是后面我们将会提到的移动语义。

3 什么是左值引用、右值引用

引用本质是别名,可以通过引用修改变量的值,传参时传引用可以避免拷贝,其实现原理和指针类似。

3.1 左值引用

只能指向左值,不能指向右值的就是左值引用

int a = 5;
int &t = a; //左值引用指向左值,编译通过
int &t = 5; //左值引用指向右值,编译失败

引用是对变量起别名,由于右值没有地址无法被修改,所以左值引用无法指向右值。

但是 const 左值引用 可以指向右值的:

const int &t = 5 //编译通过

为什么?因为const 左值引用不会修改指向值,因此可以指向右值,这也是为什么要使用const &作为函数参数的原因之一,如std::vectorpush_back

void push_back (const value_type& val);

如果没有constvec.push_back(5)这样的代码就无法编译通过了。

3.2 右值引用

右值引用和左值引用的区别就是,右值引用只能指向右值,不能指向左值,符号是&&,比左值引用多了一个&。

int &&t = 5;//编译通过
int a = 5;
int &&t = a;//编译失败

t = 6; //右值引用的用于:可以修改右值

左值引用默认只能指向左值,但是加了const的情况下可以指向右值,那么右值引用有没有类似的机制来指向左值呢?有的,就是std::move。

int a = 5;
int &l = a;  //正确,左值引用只能指向左值
//int &&r = 5; //正确,右值引用只能指向右值
//int &&r = a; //编译错误
int &&r = std::move(a); //正确,通过std::move将左值转化为右值,可以被右值引用指向左值。
cout<< a;  打印结果5

std::move 唯一的功能是把左值强制转换为右值,可以让右值引用指向左值,等于一个强制类型转换。

int &&ref_a = 5;
ref_a = 6; 
 
等同于以下代码:
 
int temp = 5;
int &&ref_a = std::move(temp);
ref_a = 6;

注意,被声明出来的左、右值引用都是左值,因为被声明出的左右值引用是有地址的,也位于等号左边。即int &t,int &&t本身都是左值。但是,i&&t也可以是右值,作为函数返回值的 && 是右值,直接声明出来的 && 是左值

总结:

  1. 从性能上讲,左右值引用没有区别,传参使用左右值引用都可以避免拷贝。
  2. 右值引用可以直接指向右值,也可以通过std::move指向左值;而左值引用只能指向左值(const左值引用也能指向右值)。
  3. 作为函数形参时,右值引用更灵活。虽然const左值引用也可以做到左右值都接受,但它无法修改,有一定局限性
void f(const int& n) {
    n += 1; // 编译失败,const左值引用不能修改指向变量
}

void f2(int && n) {
    n += 1; // ok
}

int main() {
    f(5);
    f2(5);
}

4 右值引用和std::move的应用场景

4.1 实现移动语义

在实际场景中,右值引用和std::move被广泛应用于在STL和自定义类中实现移动语义,避免拷贝,从而提升程序性能。在没有右值引用之前,一个简单的数组类通常实现如下,有构造函数、拷贝构造函数、赋值运算符重载、析构函数等。

class Array {
public:
    Array(int size) : size_(size) {
        data = new int[size_];
    }
     
    // 深拷贝构造
    Array(const Array& temp_array) {
        size_ = temp_array.size_;
        data_ = new int[size_];
        for (int i = 0; i < size_; i ++) {
            data_[i] = temp_array.data_[i];
        }
    }
     
    // 深拷贝赋值
    Array& operator=(const Array& temp_array) {
        delete[] data_;
 
        size_ = temp_array.size_;
        data_ = new int[size_];
        for (int i = 0; i < size_; i ++) {
            data_[i] = temp_array.data_[i];
        }
    }
 
    ~Array() {
        delete[] data_;
    }
 
public:
    int *data_;
    int size_;
};

上面代码的深拷贝构造、深拷贝复制在传参数的时候已经通过左值引用避免了一次多余的拷贝了,但是内部还是需要实现深拷贝,即重新开辟空间、赋值,要需要解决这个问题, 这时,有人提出一个想法:是不是可以提供一个移动构造函数,把被拷贝者的数据移动过来,被拷贝者后边就不要了,这样就可以避免深拷贝了,但是问题比较多,比如值:

class Array {
public:
    Array(int size) : size_(size) {
        data = new int[size_];
    }
     
    // 深拷贝构造
    Array(const Array& temp_array) {
        ...
    }
     
    // 深拷贝赋值
    Array& operator=(const Array& temp_array) {
        ...
    }
 
    // 移动构造函数,可以浅拷贝
    Array(const Array& temp_array, bool move) {
        data_ = temp_array.data_;
        size_ = temp_array.size_;
        // 为防止temp_array析构时delete data,提前置空其data_      
        temp_array.data_ = nullptr;
    }
     
 
    ~Array() {
        delete [] data_;
    }
 
public:
    int *data_;
    int size_;
};

这么做有2个问题:

  • 不优雅,表示移动语义还需要一个额外的参数(或者其他方式)。
  • 无法实现!temp_array是个const左值引用,无法被修改,所以temp_array.data_ = nullptr;这行会编译不过。当然函数参数可以改成非const:Array(Array& temp_array, bool move){...},这样也有问题,由于左值引用不能接右值,Array a = Array(Array(), true);这种调用方式就没法用了。

可以发现左值引用真是用的很不爽,右值引用的出现解决了这个问题,在STL的很多容器中,都实现了以右值引用为参数移动构造函数移动赋值重载函数,或者其他函数,最常见的如std::vector的push_backemplace_back。参数为左值引用意味着拷贝,为右值引用意味着移动。

class Array {
public:
    ......
 
    // 优雅
    Array(Array&& temp_array) {
        data_ = temp_array.data_;
        size_ = temp_array.size_;
        // 为防止temp_array析构时delete data,提前置空其data_      
        temp_array.data_ = nullptr;
    }
     
 
public:
    int *data_;
    int size_;
};

如何使用:

// 例1:Array用法
int main(){
    Array a;
 
    // 做一些操作
    .....
     
    // 左值a,用std::move转化为右值
    Array b(std::move(a));
}

4.2 实例:vector::push_back使用std::move提高性能

// 例2:std::vector和std::string的实际例子
int main() {
    std::string str1 = "aacasxs";
    std::vector vec;
     
    vec.push_back(str1); // 传统方法,copy
    vec.push_back(std::move(str1)); // 调用移动语义的push_back方法,避免拷贝,str1会失去原有值,变成空字符串
    vec.emplace_back(std::move(str1)); // emplace_back效果相同,str1会失去原有值
    vec.emplace_back("axcsddcas"); // 当然可以直接接右值
}
 
// std::vector方法定义
void push_back (const value_type& val);
void push_back (value_type&& val);
 
void emplace_back (Args&&... args);

在vector和string这个场景,加个std::move会调用到移动语义函数,避免了深拷贝。

除非设计不允许移动,STL类大都支持移动语义函数,即可移动的。 另外,编译器会默认在用户自定义的classstruct中生成移动语义函数,但前提是用户没有主动定义该类的拷贝构造等函数(具体规则自行百度哈)。 因此,可移动对象在<需要拷贝且被拷贝者之后不再被需要>的场景,建议使用std::move触发移动语义,提升性能。

moveable_objecta = moveable_objectb; 
改为: 
moveable_objecta = std::move(moveable_objectb);

还有些STL类是move-only的,比如unique_ptr,这种类只有移动构造函数,因此只能移动(转移内部对象所有权,或者叫浅拷贝),不能拷贝(深拷贝):

std::unique_ptr ptr_a = std::make_unique();

std::unique_ptr ptr_b = std::move(ptr_a); // unique_ptr只有‘移动赋值重载函数‘,参数是&& ,只能接右值,因此必须用std::move转换类型

std::unique_ptr ptr_b = ptr_a; // 编译不通过

std::move本身只做类型转换,对性能无影响。 我们可以在自己的类中实现移动语义,避免深拷贝,充分利用右值引用和std::move的语言特性。

5 完美转发std::forward

和std::move一样,std::forward本质上是进行类型转换,即左值右值之间的转换,与move相比,forward更强大,move只能转出来右值,forward都可以。

std::forward(u)有两个参数:T与 u。 a. 当T为左值引用类型时,u将被转换为T类型的左值; b. 否则u将被转换为T类型右值。

举个例子,有main,A,B三个函数,调用关系为:main->A->B。

void B(int&& ref_r) {
    ref_r = 1;
}
 
// A、B的入参是右值引用
// 有名字的右值引用是左值,因此ref_r是左值
void A(int&& ref_r) {
    B(ref_r);  // 错误,B的入参是右值引用,需要接右值,ref_r是左值,编译失败
     
    B(std::move(ref_r)); // ok,std::move把左值转为右值,编译通过
    B(std::forward(ref_r));  // ok,std::forward的T是int类型,属于条件b,因此会把ref_r转为右值
}
 
int main() {
    int a = 5;
    A(std::move(a));
}

例2

void change2(int&& ref_r) {
    ref_r = 1;
}
 
void change3(int& ref_l) {
    ref_l = 1;
}
 
// change的入参是右值引用
// 有名字的右值引用是 左值,因此ref_r是左值
void change(int&& ref_r) {
    change2(ref_r);  // 错误,change2的入参是右值引用,需要接右值,ref_r是左值,编译失败
     
    change2(std::move(ref_r)); // ok,std::move把左值转为右值,编译通过
    change2(std::forward(ref_r));  // ok,std::forward的T是右值引用类型(int &&),符合条件b,因此u(ref_r)会被转换为右值,编译通过
     
    change3(ref_r); // ok,change3的入参是左值引用,需要接左值,ref_r是左值,编译通过
    change3(std::forward(ref_r)); // ok,std::forward的T是左值引用类型(int &),符合条件a,因此u(ref_r)会被转换为左值,编译通过
    // 可见,forward可以把值转换为左值或者右值
}
 
int main() {
    int a = 5;
    change(std::move(a));
}

上边的示例在日常编程中基本不会用到,std::forward最主要运于模版编程的参数转发中,想深入了解需要学习万能引用(T &&)引用折叠(eg:& && → ?)等知识,本文就不详细介绍这些了。

原文链接:一文读懂C++右值引用和std::move - 知乎

你可能感兴趣的:(c++,c++,开发语言)