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; // 语句结束后,11的生命周期被延长了
const int x = 11; // 语句结束后,11立刻被销毁
  • 右值引用:引用右值且只能引用右值的方法
1
int &&k = 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; // 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 被销毁
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'); // 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 强制移动

  移动构造函数和移动赋值运算符都必须使用右值,但如果让他们使用左值就需要一些特殊处理

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";
//调用移动构造函数,掏空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 引用折叠

  万能引用实际上就是发生了类型推导,如果源对象是一个左值,则推导出左值引用;如果源对象是一个右值,则推导出右值引用。

1
2
3
4
5
6
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转为右值引用,所以调用右值引用的函数
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); // C++11规定 实参类型为T的左值, 则模板T展开为int&
Test(1); // C++11规定 实参类型为T的右值, 则模板T展开为int
}

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有一个用例:将模板化的函数参数(在函数内部)转换为用于传递它的调用方的值类别(左值或右值)。这允许将右值参数作为右值传递,并将左值作为左值传递,这是“完美转发”的方案