1. 右值引用和移动语义
1.1 左值和右值
- 左值 local value:存储在内存中、有明确存储地址(可寻址)的数据(x、y、z)
- 右值 read value:不一定可以寻址,例如存储于寄存器中的数据;通常字面量都是右值,除了字符串常量(1、3)
1 2 3
| int x = 1; int y = 3; int z = x + y;
|
对于x++和++x虽然都是自增操作,但x++编译器首先生成一份临时值,然后对x自增,最后返回临时内容,所以x++是右值;++x是对x递增后返回自身,所以++x是左值
1 2 3 4
| x++; ++x; int *p1 = &x++; int *p2 = &++x;
|
1.2 左值引用和右值引用
- 左值引用:必须引用一个左值。
- 常量左值引用:可以引用左值或右值。可以引用右值的原理是延长右值的生命周期,但这种引用存在一个问题,常量左值引用导致无法修改对象内容。
1 2 3
| int &x1 = 7; const int &x = 11; const int x = 11;
|
对于数字的表示可能不太清晰,因为数字本身就有些虚无缥缈,下面用一个类的例子来更好解释右值引用的优势,可以减少复制构造来优化性能(但实际上编译器会帮我们优化)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| #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; return mc; }
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 被销毁
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
| 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'); printf("\n"); Useless two(20, 'c'); printf("\n"); Useless three(one + two); printf("\n"); printf("object one: \n"); one.ShowData(); printf("object two: \n"); two.ShowData(); printf("object three: \n"); three.ShowData(); printf("\n"); }
|
1.4 强制移动
移动构造函数和移动赋值运算符都必须使用右值,但如果让他们使用左值就需要一些特殊处理
1 2 3 4
| Useless choices[10]; Useless best; int pick = 5; best = chioces[pick];
|
可以使用C++11头文件utility中提供的move函数来实现将左值转换为右值,但是注意右值的字段会被夺走,并且必须定义了移动赋值运算符或移动构造函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| #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"; 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 引用折叠
万能引用实际上就是发生了类型推导,如果源对象是一个左值,则推导出左值引用;如果源对象是一个右值,则推导出右值引用。
1 2 3 4 5 6
| void foo(int &&i) {} template<class T> void bar(T &&t) {} template<class T> void bar(vector<T> &&t) {} int get_val() {return 5;} int &&x = get_val(); auto &&y = get_val();
|
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转为右值引用,所以调用右值引用的函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| #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); Test(1); }
|
2.2 完美转发
通过 std::forward() 可以实现完美转发,不论左值还是右值都可以通过引用的方式传参,提高程序运行的效率。下面给出了一个完美转发的例子,打印了 T 的实际类型,并通过修改 t 的值实现了修改 a 的值(传入左值即左值引用),同样如果传入类的右值一样是右值引用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| #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有一个用例:将模板化的函数参数(在函数内部)转换为用于传递它的调用方的值类别(左值或右值)。这允许将右值参数作为右值传递,并将左值作为左值传递,这是“完美转发”的方案
Author:
mxwu
Permalink:
https://mingxuanwu.com/2023/12/02/202312021427/
License:
Copyright (c) 2023 CC-BY-NC-4.0 LICENSE