不再困惑!一文教你读懂C 右值引用和std::move

原创腾讯技术工程2020-12-15 18:18:00

作者:rickonji 冀铭哲

C 11引入了右值引用,有一定的理解成本,工作中发现不少同事对右值引用理解不深,认为右值引用性能更高等等。本文从实用角度出发,用尽量通俗易懂的语言讲清左右值引用的原理,性能分析及其应用场景,帮助大家在日常编程中用好右值引用和std::move。

1. 什么是左值、右值

首先不考虑引用以减少干扰,可以从2个角度判断:左值可以取地址、位于等号左边;而右值没法取地址,位于等号右边

int a = 5;
  • a可以通过 & 取地址,位于等号左边,所以a是左值。

  • 5位于等号右边,5没法通过 & 取地址,所以5是个右值。

再举个例子:

struct A {A(int a = 0) {        a_ = a;    } int a_;}; A a = A();
  • 同样的,a可以通过 & 取地址,位于等号左边,所以a是左值。

  • A()是个临时值,没法通过 & 取地址,位于等号右边,所以A()是个右值。

可见左右值的概念很清晰,有地址的变量就是左值,没有地址的字面值、临时值就是右值。

2. 什么是左值引用、右值引用

引用本质是别名,可以通过引用修改变量的值,传参时传引用可以避免拷贝,其实现原理和指针类似。 个人认为,引用出现的本意是为了降低C语言指针的使用难度,但现在指针 左右值引用共同存在,反而大大增加了学习和理解成本。

2.1 左值引用

左值引用大家都很熟悉,能指向左值,不能指向右值的就是左值引用

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

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

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

const int &ref_a = 5;  // 编译通过

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

void push_back (const value_type& val);

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

2.2 右值引用

再看下右值引用,右值引用的标志是&&,顾名思义,右值引用专门为右值而生,可以指向右值,不能指向左值

int &&ref_a_right = 5; // ok int a = 5;int &&ref_a_left = a; // 编译不过,右值引用不可以指向左值 ref_a_right = 6; // 右值引用的用途:可以修改右值

2.3 对左右值引用本质的讨论

下边的论述比较复杂,也是本文的核心,对理解这些概念非常重要。

2.3.1 右值引用有办法指向左值吗?

有办法,std::move:

int a = 5; // a是个左值int &ref_a_left = a; // 左值引用指向左值int &&ref_a_right = std::move(a); // 通过std::move将左值转化为右值,可以被右值引用指向 cout << a; // 打印结果:5

在上边的代码里,看上去是左值a通过std::move移动到了右值ref_a_right中,那是不是a里边就没有值了?并不是,打印出a的值仍然是5。

std::move是一个非常有迷惑性的函数,不理解左右值概念的人们往往以为它能把一个变量里的内容移动到另一个变量,但事实上std::move移动不了什么,唯一的功能是把左值强制转化为右值,让右值引用可以指向左值。其实现等同于一个类型转换:static_cast<T&&>(lvalue)。 所以,单纯的std::move(xxx)不会有性能提升,std::move的使用场景在第三章会讲。

同样的,右值引用能指向右值,本质上也是把右值提升为一个左值,并定义一个右值引用通过std::move指向该左值:

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

2.3.2 左值引用、右值引用本身是左值还是右值?

被声明出来的左、右值引用都是左值。 因为被声明出的左右值引用是有地址的,也位于等号左边。仔细看下边代码:

// 形参是个右值引用void change(int&& right_value) { right_value = 8;} int main() {int a = 5; // a是个左值int &ref_a_left = a; // ref_a_left是个左值引用int &&ref_a_right = std::move(a); // ref_a_right是个右值引用 change(a); // 编译不过,a是左值,change参数要求右值change(ref_a_left); // 编译不过,左值引用ref_a_left本身也是个左值change(ref_a_right); // 编译不过,右值引用ref_a_right本身也是个左值 change(std::move(a)); // 编译通过change(std::move(ref_a_right)); // 编译通过change(std::move(ref_a_left)); // 编译通过 change(5); // 当然可以直接接右值,编译通过 cout << &a << ' ';cout << &ref_a_left << ' ';cout << &ref_a_right;// 打印这三个左值的地址,都是一样的}

看完后你可能有个问题,std::move会返回一个右值引用int &&,它是左值还是右值呢? 从表达式int &&ref = std::move(a)来看,右值引用ref指向的必须是右值,所以move返回的int &&是个右值。所以右值引用既可能是左值,又可能是右值吗? 确实如此:右值引用既可以是左值也可以是右值,如果有名称则为左值,否则是右值

或者说:作为函数返回值的 && 是右值,直接声明出来的 && 是左值。 这同样也符合第一章对左值,右值的判定方式:其实引用和普通变量是一样的,int &&ref = std::move(a)和 int a = 5没有什么区别,等号左边就是左值,右边就是右值。

最后,从上述分析中我们得到如下结论:

  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);}

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

按上文分析,std::move只是类型转换工具,不会对性能有好处;右值引用在作为函数形参时更具灵活性,看上去还是挺鸡肋的。他们有什么实际应用场景吗?

3.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_back和emplace_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));}

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

// 例2:std::vector和std::string的实际例子int main() {std::string str1 = 'aacasxs';std::vector<std::string> vec; vec.push_back(str1); // 传统方法,copyvec.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类大都支持移动语义函数,即可移动的。 另外,编译器会默认在用户自定义的class和struct中生成移动语义函数,但前提是用户没有主动定义该类的拷贝构造等函数(具体规则自行百度哈)。 因此,可移动对象在<需要拷贝且被拷贝者之后不再被需要>的场景,建议使用std::move触发移动语义,提升性能。

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

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

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

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

4. 完美转发 std::forward

和std::move一样,它的兄弟std::forward也充满了迷惑性,虽然名字含义是转发,但他并不会做转发,同样也是做类型转换.

与move相比,forward更强大,move只能转出来右值,forward都可以。

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

举个例子,有main,A,B三个函数,调用关系为:main->A->B,建议先看懂2.3节对左右值引用本身是左值还是右值的讨论再看这里:

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<int>(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<int &&>(ref_r)); // ok,std::forward的T是右值引用类型(int &&),符合条件b,因此u(ref_r)会被转换为右值,编译通过 change3(ref_r); // ok,change3的入参是左值引用,需要接左值,ref_r是左值,编译通过change3(std::forward<int &>(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:& && → ?)等知识,本文就不详细介绍这些了。

如有错误,请指正!

(0)

相关推荐

  • 一文让你彻底明白C 中的const

    在抽象的最高层次上,const做两件事: 一种保护你自己的方式(类似于private) 对编译器的一种指示,表明标记为const的对象适合于程序的数据段.换句话说,属于只读数据(ROM-able). ...

  • “高钙奶”真的更补钙?乳酸菌饮料就是酸奶?此文教你读懂标签

    无论您购买的是哪种食品,食品的包装上都会印有"食品标签",标签上的信息有很多,例如您最为关注的生产日期.保质期,再比如配料表.营养成分表.名称类别.生产者信息等内容.其实,包装袋背 ...

  • 不再束手无策,一文读懂新生儿疼痛如何管理

    你是否曾面对新生儿的疼痛状态束手无策?  既往曾有观点认为,新生儿神经系统发育未成熟,髓鞘发育不全,大脑皮层发育不成熟,感觉通路形成不全,因此新生儿并不能有效的感知疼痛,在治疗过程中一般不必给予特别镇 ...

  • 类风湿关节炎的MRI诊断不简单,一文教你读懂(内附干货)

    病史 女,47岁,多关节晨僵. 图像提供了包括冠状位T1WI(1A),冠状位脂肪抑制T1WI增强序列(1B),腕管水平的轴位脂肪抑制T1WI增强序列(1C)和一个近端桡腕关节水平的轴位脂肪抑制T2WI ...

  • 干货 | 一文教你读懂流式散点图和等高线图

    作者:解螺旋.罗小黑 解螺旋原创 转载请注明来源:解螺旋,医生科研助手 光信号是流式细胞仪的灵魂.流式细胞仪分析细胞的各种参数都是通过光信号来实现的.光信号包括了散射光信号和荧光信号.散射光信号可以用 ...

  • 氨氯地平与左旋氨氯地平有啥区别?一文教你读懂

    *仅供医学专业人士阅读参考 左旋氨氯地平与氨氯地平到底有什么不同吗?剂量该如何换算? 氨氯地平是临床上常用的降压药,属于长效的二氢吡啶类钙离子通道阻滞剂(CCB),其起效平缓.作用平稳,副作用发生率较 ...

  • 可转债是什么?一文教你读懂可转债

    关于可转债我们需要知道哪些专业知识呢? 一.什么是可转债? 可转债的全称是"可转换公司债券",其本身是债券,每张债券的票面价是100元,可以到期还本付息,但与普通债券不同的是,可转 ...

  • 保健品、保健食品和药品,傻傻分不清?一文教你读懂

    日常生活中,总有人将保健品.保健食品和药品分不清,而有些保健品甚至打着可以治愈疾病以及更为夸张的神效吸引大众. 那么,保健品.保健食品和药品到底是什么?如何区分保健品和药品?选购保健品又有哪些应该注意 ...

  • 小米11 四款机子谁最值得买?一文教你读懂 不选错

    原本预计至少4个小时的小米春季新品发布会,因为雷军感冒被拆成了两天,下半场将在今晚继续.这样的变动可能会影响不少媒体早就定好的行程,但也由此可见,澎湃芯片.小米MIX.小米未来十年的规划,在雷军看来还 ...

  • 什么是变频器?一文教你读懂变频器(内含福利!)

    什么是变频器? 变频器是通过应用电力电子技术,改变电机工作电源频率,以控制电机不同转速的电力控制设备.变频器主要有整流电路.缓冲电路.滤波电路.逆变电路等组成(附图1). 附图1 整流电路:主要由整流 ...