【C++ Primer Plus】C++11 深入理解右值、右值引用和完美转发
1. 右值引用和移动语义
1.1 左值和右值
- 左值 local value:存储在内存中、有明确存储地址(可寻址)的数据(x、y、z)
- 右值 read value:不一定可以寻址,例如存储于寄存器中的数据;通常字面量都是右值,除了字符串常量(1、3)
int x = 1; int y = 3; int z = x + y;
对于x++和++x虽然都是自增操作,但x++编译器首先生成一份临时值,然后对x自增,最后返回临时内容,所以x++是右值;++x是对x递增后返回自身,所以++x是左值
x++; // 右值引用 ++x; // 左值引用 int *p1 = &x++; // 右值引用所以编译失败 int *p2 = &++x; // 左值引用可以编译成功
1.2 左值引用和右值引用
- 左值引用:必须引用一个左值。
- 常量左值引用:可以引用左值或右值。可以引用右值的原理是延长右值的生命周期,但这种引用存在一个问题,常量左值引用导致无法修改对象内容。
int &x1 = 7; // 非常量左值引用 编译错误 const int &x = 11; // 语句结束后,11的生命周期被延长了 const int x = 11; // 语句结束后,11立刻被销毁
- 右值引用:引用右值且只能引用右值的方法
int &&k = 11; // 右值引用(延长右值的生命周期)
对于数字的表示可能不太清晰,因为数字本身就有些虚无缥缈,下面用一个类的例子来更好解释右值引用的优势,可以减少复制构造来优化性能(但实际上编译器会帮我们优化)
#include <iostream> using namespace std; class MyClass { public: char* pc; MyClass(); MyClass(const MyClass& myclass); ~MyClass() { cout << "dtor" << endl; delete pc; } void show() { cout << "Show: " << pc << endl; } }; MyClass::MyClass() { cout << "ctor" << endl; pc = new char[10]; for (int i = 0; i < 10; i++) pc[i] = 'c'; } MyClass::MyClass(const MyClass& myclass) { cout << "copy ctor" << endl; pc = myclass.pc; // **浅复制**, 编译器不优化则返回值调用赋值构造函数, 接下来析构原对象的时候这里内存会指向空内存!!! for (int i = 0; i < 10; i++) pc[i] = myclass.pc[i]; } MyClass make_myclass() { MyClass mc; // 1.构造函数 3.析构函数, 析构这里的mc对象(此时如果是浅复制,新的对象指向的内存也将为空, 引发指针异常) return mc; // 2.拷贝函数, 返回拷贝的对象 } int main() { MyClass&& mc = make_myclass(); // 返回值的生命周期被延长 mc.show(); cout << endl; MyClass mc2 = make_myclass(); // 再次调用拷贝构造函数 mc2.show(); }
- 优化前:MyClass&& mc = make_myclass(); 调用后
- ctor: 调用mc的构造函数
- copy ctor:返回值时调用mc的拷贝函数(因为没有重写移动构造函数,所以返回值时才会调用拷贝构造函数)
- dtor: 返回值后将mc销毁调用析构函数(注意这里的复制构造函数会存在问题:如果是浅复制,此时销毁的对象会把堆区内存销毁导致新的对象空引用,所以还是强调必须重写复制构造函数)
- 由于右值引用,延长了右值的生命周期
- dtor: main函数结束再调用一次析构函数
- 编译器优化后:MyClass&& mc = make_myclass(); 注意其实编译器优化会解决一些潜在的问题,当然我们只要有堆内存操作都必须重写拷贝构造函数,就解决了这个问题,详情看代码注释
- ctor: 调用构造函数
- dtor: 调用析构函数
- MyClass mc = make_myclass(); 该函数调用后
- ctor: 调用mc的构造函数
- copy ctor: 返回值 调用mc的拷贝函数
- dtor: 返回值后销毁mc 调用析构函数
- copy ctor: 为了构造mc2 调用构造函数
- dtor: 销毁返回值临时变量 调用析构函数
- dtor: main函数结束后再调用一次析构函数
1.3 移动语义
上面其实已经用到了移动语义,移动语义主要就是解决C++复制构造对性能的影响。但也存在问题,例如移动构造函数运行过程中发生了异常,这会造成源对象和目标对象都不完整。这里再用一个例子说明,该Useless类内有一个元素个数为 n 的 char 数组,静态变量 ct 记录了对象个数。(下面的流程基于未优化-fno-elide-constructors编译,否则编译器会自己优化掉移动构造函数的部分)
- Useless one(20, 'o'); 调用 int char 构造函数
- Useless one(20, 'c'); 调用 int char 构造函数
- Useless three(one + two);
- one + two 调用 operator+ 运算符重载,在内部调用 int 构造函数构造了对象 temp
- 返回值时调用移动语义构造函数(未优化的情况下,实际上因为重写了移动构造函数这里才会调用),夺走 temp 里指针指向的内容并把它的指针设置为空,这样它在销毁时不会把堆区内存清空
- 临时对象 temp 被销毁
class Useless { public: int n; // 元素个数 char* pc; // 数据指针 static int ct; // 对象个数 void ShowObject()const; Useless(int k); Useless(int k, char ch); Useless(Useless&& f); // 移动构造 ~Useless(); Useless operator+(const Useless& f)const; void ShowData() const; }; int Useless::ct = 0; Useless::Useless(int k) :n(k) { printf("int 参数的构造函数; 对象个数为: %d\n", ++ct); pc = new char[n]; ShowObject(); } Useless::Useless(int k, char ch) :n(k) { printf("int char参数的构造函数; 对象个数为: %d\n", ++ct); pc = new char[n]; for (int i = 0; i < n; i++){ pc[i] = ch; } ShowObject(); } Useless::Useless(Useless&& f) :n(f.n) { printf("移动构造函数; 对象个数为: %d\n", ++ct); pc = f.pc; f.pc = nullptr; f.n = 0; ShowObject(); } Useless::~Useless() { printf("析构函数调用; 元素个数为: %d\n", --ct); ShowObject(); delete[] pc; } Useless Useless::operator+(const Useless& f)const { printf("进入 operator+\n"); Useless temp = Useless(n + f.n); for (int i = 0; i < n; i++) temp.pc[i] = pc[i]; for (int i = n; i < temp.n; i++) temp.pc[i] = f.pc[i - n]; printf("离开 operator+\n"); return temp; } void Useless::ShowObject() const { printf("元素个数: %d, 数据地址: %x\n", n, (void*)pc); } void Useless::ShowData()const { if (n == 0) printf("元素个数为空\n"); else for (int i = 0; i < n; i++) printf("%c ", pc[i]); printf("\n"); } int main() { Useless one(20, 'o'); // int char 构造函数 对象个数1 printf("\n"); Useless two(20, 'c'); // int char 构造函数 对象个数2 printf("\n"); Useless three(one + two); // 1. operator+ 调用 int 构造函数 对象个数3; 2. operator+ 返回右值, 调用移动构造函数(减少了复制的次数) 对象个数4; 3.临时对象被销毁 对象个数3 printf("\n"); printf("object one: \n"); one.ShowData(); printf("object two: \n"); two.ShowData(); printf("object three: \n"); three.ShowData(); printf("\n"); }
1.4 强制移动
移动构造函数和移动赋值运算符都必须使用右值,但如果让他们使用左值就需要一些特殊处理
Useless choices[10]; Useless best; int pick = 5; best = chioces[pick]; // 由于这里是左值,所以会调用普通的复制赋值运算符
可以使用C++11头文件utility中提供的move函数来实现将左值转换为右值,但是注意右值的字段会被夺走,并且必须定义了移动赋值运算符或移动构造函数
#include <iostream> #include <utility> #include <vector> #include <string> int main() { std::string str = "Hello"; std::vector<std::string> v; //调用常规的拷贝构造函数,新建字符数组,拷贝数据 v.push_back(str); std::cout << "After copy, str is \"" << str << "\"\n"; //调用移动构造函数,掏空str,掏空后,最好不要使用str v.push_back(std::move(str)); std::cout << "After move, str is \"" << str << "\"\n"; std::cout << v[0] << ", " << v[1] << "\n"; }
2. 万能引用
很多时候我们希望传递的是一个引用而非通过拷贝构造传递,这可以提高程序效率;但仅仅通过fn(className& c)来传递引用会导致不能传递右值,fn(const className& c)又会导致传递进来的参数不能被修改,所以提出了万能引用的概念
2.1 引用折叠
万能引用实际上就是发生了类型推导,如果源对象是一个左值,则推导出左值引用;如果源对象是一个右值,则推导出右值引用。
void foo(int &&i) {} // i为右值引用 template<class T> void bar(T &&t) {} // t为万能引用 template<class T> void bar(vector<T> &&t) {} // 非万能引用,必须是直接的T int get_val() {return 5;} int &&x = get_val(); // x 为右值引用 auto &&y = get_val(); // y 为万能引用
C++11 通过一套引用叠加推导的规则来实现万能引用——引用折叠,可以注意到实际类型是左值引用,则最终类型一定是左值引用;只有引用类型是一个非引用类型或者右值引用,最后推导出来的才是一个右值引用
通过下面几行代码理解引用折叠,首先是C++11规定的展开时的定义
- 实参类型为T的左值, 则模板 T 展开为 T&
- 此时Test形参的类型为 T& &&,经过折叠后为 T& 左值引用
- 此时static_cast<T&>(t) 将t转为左值引用,所以调用左值引用的函数
实参类型为T的右值, 则模板 T 展开为 T
- 此时Test形参的类型为 T &&,所以为右值引用
- 此时static_cast<T&&>(t) 将t转为右值引用,所以调用右值引用的函数
#include <iostream> void process(int& i) { std::cout << "左值引用" << std::endl; } void process(int&& i) { std::cout << "右值引用" << std::endl; } template<class T> void Test(T&& t) { process(static_cast<T&&>(t)); } int main() { int a = 1; Test(a); // C++11规定 实参类型为T的左值, 则模板T展开为int& Test(1); // C++11规定 实参类型为T的右值, 则模板T展开为int }
2.2 完美转发
通过 std::forward<T>() 可以实现完美转发,不论左值还是右值都可以通过引用的方式传参,提高程序运行的效率。下面给出了一个完美转发的例子,打印了 T 的实际类型,并通过修改 t 的值实现了修改 a 的值(传入左值即左值引用),同样如果传入类的右值一样是右值引用。
#include <iostream> template<class T> void show_Type(T&& t) { std::cout << "is int&: " << std::is_same_v<T, int&> << std::endl; std::cout << "is int : " << std::is_same_v<T, int> << std::endl; t = 10; } template<class T> void perfect_forwarding(T&& t) { show_Type(std::forward<T>(t)); } int main() { int a = 5; perfect_forwarding(5); // 该参数在不同函数间始终以右值引用方式传递 perfect_forwarding(a); // 该参数在不同函数间始终以左值引用方式传递 std::cout << a; // 由于以引用的方式传递, 这里内存中的数值也一样会修改 }
3. move 和 forward
- std::move接受一个对象,并允许您将其视为临时对象(右值)。尽管这不是语义要求,但是通常,接受对右值的引用的函数会使它无效。当看到时std::move,表明该对象的值以后不应再使用,但是仍然可以分配一个新值并继续使用它
- std::forward有一个用例:将模板化的函数参数(在函数内部)转换为用于传递它的调用方的值类别(左值或右值)。这允许将右值参数作为右值传递,并将左值作为左值传递,这是“完美转发”的方案