Cpp 进阶笔记——1. 右值引用与移动语义
我们关注现代C++中比较难以理解的概念:右值引用,移动语义,完美转发等, 这些概念都是C++11之后才提出的,目的是进一步压榨程序的运行期效率,避免某些非必要的临时变量的拷贝构造和析构过程。这些语法是完全针对底层实现的,并不是针对于上层的语义优化,不是为了让程序变得更易读的语法糖。
左值和右值 (1)
在C++中,表达式由一个或多个运算对象通过运算符组成,对表达式求值得到一个结果。 字面量和变量是最简单的表达式,它们的结果就是字面量和变量的值。 一个表达式至少具有如下两个属性:
- 类型:描述计算产生的值的静态类型
- 值类别:描述值是如何产生的,以及表达式的行为如何被影响
从语法上检查一个表达式能否给另一个表达式赋值,既需要判断类型之间能否进行转换,还需要判断值类别是否满足要求。在本文中我们不讨论类型问题,重点关注表达式的值类别。
在C语言和C++的早期语法中,值类别被简单分为左值和右值。
简单地说,在一个合法的赋值语句中,等号左边的就是左值表达式,等号右边的就是右值表达式。
在赋值过程中,右值表达式不会被改变,而左值表达式会因为赋值而改变。
1
lvalue = rvalue;
左值和右值的区别本质上在于它们发挥了不同的角色:
- 对于左值,我们需要使用它的身份(内存地址);
- 对于右值,我们需要使用它的值(内容)。
表达式能否被赋值等价于表达式能不能作为左值,显然只有表达式在内存中有对应的存储地址时,赋值才是有意义的,当然这个条件是不充分的,它还需要允许被修改。 在这个原始的定义下:
- 字面量常量表达式只能作为右值,因为字面量常量仅仅有值,并没有存储或内存地址的概念,它实际上可能直接被直接写入指令中,或者存放在不可修改的数据区。
- 对于变量直接构成的表达式,通常既可以作为左值也可以作为右值,但是只读变量构成的表达式只能作为右值,原因很简单:它是只读而不可修改的。
- 如果一个表达式可以作为左值,通常它也可以作为右值,反之不成立。
例如下面的赋值语句是不合法的,虽然两侧类型一致,但是出现在左侧的表达式并不能作为左值。
1
7 = 5 + 2;
引用 (1)
引用——指针的语法糖
C语言中的指针是非常核心但是非常困难的概念,C++提供了引用作为指针常量的语法糖,在使用时省去了繁琐的指针操作和指针判空操作。
例如下面使用指针实现的函数 1
2
3
4
5
6
7
8
9void add(int *p, int n) {
if (p != nullptr) { *p = *p + n; }
}
void test1() {
int s = 10;
add(&s, 5);
std::cout << "s = " << s; // s = 15
}
改成引用就会简洁很多 1
2
3
4
5
6
7void add(int &m, int n) { m = m + n; }
void test1() {
int s = 10;
add(s, 5);
std::cout << "s = " << s; // s = 15
}
直接省去了取地址、解引用以及指针判空的操作。
当然引用说到底只是C++提供的语法糖,实质上还是通过指针常量进行实现的,并且这里的指针常量必须使用变量的地址进行初始化。
上面的代码等价于 1
2
3
4
5
6
7
8void add(int *const pm, int n) { *pm = *pm + n; }
void test1() {
int s = 10;
int *const pm = &s; // pm != nullptr
add(pm, 5);
std::cout << "s = " << s; // s = 15
}
如果引用的对象是不可变的(或者即使它本身可变,我们也可以加上不可变限制:不允许通过引用修改它,只允许通过引用读取它),
那么对指针的类型又加上了一个const约束,变成了指向只读对象的指针常量,例如下面的代码
1
2
3
4
5
6
7
8void show(const int &m) {
std::cout << "m = " << m << '\n';
}
void test1() {
int s = 10;
show(s);
}
等价于 1
2
3
4
5
6
7
8
9void show(const int *const p) {
std::cout << "m = " << *p << '\n';
}
void test1() {
int s = 10;
const int *const p = &s;
show(p);
}
引用类型和绑定的变量类型必须保持一致,下面的语句是不合法的
1
2
3double val = 3.14;
int &m = val; // compile error
std::cout << m;
在C++11之后,在语法上又提出了右值引用等更多的概念,与之相对,我们把最初的这些引用称为左值引用。
将亡值
我们需要引入一个特殊的概念——将亡值。
函数的调用过程
C++程序的主要工作就是一次次的函数调用,在函数调用的开始涉及到:
- 冻结并记录函数执行中的寄存器状态
- 创建函数栈帧
- 传递参数(赋值语义,可能涉及到某些参数的创建)
- 立即创建局部变量(或者预留内存空间,在后续的语句中创建局部变量)
在函数调用的结束涉及到:
- 保存返回值(临时保存到寄存器,或者某个临时内存空间,反正是在当前的函数栈帧之外)
- 销毁当前的函数栈帧(包括对所有的局部对象执行析构)
- 恢复上一层函数执行中的寄存器状态
- 上一层函数从寄存器或临时内存空间中获取返回值
这两个过程中由于栈帧的创建和销毁,可能会产生很多临时对象,造成很多不必要的浪费,这个问题对于C语言并不明显,因为无非是多进行了一两次的内存复制。
但是对C++来说,这里的问题就可大可小了:C++面向对象的语法保证,对自定义类型的对象在生命周期的开始和结束时自动调用构造和析构函数,如果这两个过程的消耗非常大(例如在堆内存中申请和释放空间),那么对临时对象的构造和析构,就会对计算效率产生很大的影响。 C++恰恰又是一个对性能有极致要求的底层语言,当然不能被这个问题绑架,于是C++11在语法上定义了将亡值的概念,允许程序利用这些将亡值的“遗体”。
将亡值通常会在函数传参和函数返回的过程中产生,下面以一个缓冲区类的使用为例,分别进行分析。
接下来的实验,我们将忽略编译器进行的所有优化,包括在-O0条件下编译器也可能自动实施的某些优化,我们采用如下的编译命令
1
clang++ -O0 -fno-elide-constructors main.cpp
示例——Buffer缓存类
考虑下面的例子 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
class Buffer {
public:
explicit Buffer(size_t size) {
PRINT_INFO("call constructor\n");
allocate(size);
}
Buffer(const Buffer &ob) {
PRINT_INFO("call copy constructor\n");
allocate(ob.m_buf_size);
memcpy(m_buf, ob.m_buf, sizeof(int) * ob.m_buf_size);
}
Buffer &operator=(const Buffer &ob) {
PRINT_INFO("call copy operator\n");
if (this == &ob) { return *this; }
deallocate();
allocate(ob.m_buf_size);
memcpy(m_buf, ob.m_buf, sizeof(int) * ob.m_buf_size);
return *this;
}
// Buffer(Buffer &&ob) noexcept;
// Buffer &operator=(Buffer &&ob) noexcept;
~Buffer() {
PRINT_INFO("call destructor\n");
deallocate();
}
int &at(size_t index) { return m_buf[index]; }
int at(size_t index) const { return m_buf[index]; }
size_t size() const { return m_buf_size; }
private:
size_t m_buf_size;
int *m_buf;
void allocate(size_t size) {
m_buf_size = size;
m_buf = static_cast<int *>(malloc(sizeof(int) * size));
}
void deallocate() {
if (m_buf != nullptr) {
free(m_buf);
m_buf = nullptr;
}
}
};
这段代码定义了一个简单的int类型的缓冲区类,并且提供了对元素的访问的获取长度的方法。
函数传参时的将亡值
我们考虑下面的函数调用 1
2
3
4
5
6
7
8
9
10void ProcessBuf(Buffer buf) {
for (int i = 0; i < buf.size(); i++) { buf.at(i) = 2 * i; }
for (int i = 0; i < buf.size(); i++) { std::cout << buf.at(i) << " "; }
std::cout << "\n";
}
void test2() {
ProcessBuf(Buffer{5});
}
ProcessBuf函数对Buffer对象进行了修改和输出,test2函数调用了ProcessBuf函数,并给它传递了一个临时对象Buffer{5}。
程序执行结果如下(编译时必须关掉返回值优化,否则结果不一样)
1
2
3
4
5call constructor // 在test2函数中,构造临时对象Buffer{5}
call copy constructor // 给ProcessBuf函数传参的过程,拷贝构造参数对象buf
0 2 4 6 8
call destructor // ProcessBuf函数调用结束,参数对象buf自动析构
call destructor // 在test2函数中,析构临时对象Buffer{5}
但是实际上我们只需要一个就够了,我们在调用侧构造了一个临时对象,能不能将其直接作为函数参数进行废物利用呢?
如果我们将ProcessBuf函数的参数类型改为Buffer &
1
2
3
4
5
6void ProcessBuf(Buffer buf) { // compile error
for (int i = 0; i < buf.size(); i++) { buf.at(i) = 2 * i; }
for (int i = 0; i < buf.size(); i++) { std::cout << buf.at(i) << " "; }
std::cout << "\n";
}
这会直接导致编译报错 1
2error C2664: 'void ProcessBuf(Buffer &)': cannot convert argument 1 from 'Buffer' to 'Buffer &'
note: A non-const reference may only be bound to an lvalue
这是因为函数的参数也是当前函数的局部变量,而传参过程是具有赋值语义的,实际上就是说下面的语句是不合法的
1
Buffer &buf = Buffer{5}; // compile error
原因是语法上只允许引用绑定在一个左值上,而临时对象是不能取地址的,不能作为左值。
为什么临时对象不能被取地址?这还是出于运行效率的考虑:
- 对于某些特别小的临时对象(例如基本数据类型),程序会选择直接将数据存储在寄存器中,而不会写入内存再读取;
- 只有对于那些在寄存器中放不下的大对象,程序才不得不把临时对象存储在临时内存中,用于临时的中转。(临时内存空间是在当前函数栈之外的)
函数返回时的将亡值
我们考虑下面的函数调用 1
2
3
4
5
6
7
8
9
10
11
12
13
14Buffer GetBuf(size_t n, int value) {
Buffer buf{n};
for (int i = 0; i < buf.size(); i++) { buf.at(i) = value; }
return buf;
}
void test3() {
Buffer ret = GetBuf(4, 1);
for (int i = 0; i < ret.size(); i++) { std::cout << ret.at(i) << " "; }
std::cout << "\n";
}
GetBuf函数创建并初始化了一个Buffer对象,然后将其返回。
test3函数调用了GetBuf函数获取了一个Buffer对象,然后输出其中的内容。
程序执行结果如下(编译时必须关掉返回值优化,否则结果不一样)
1
2
3
4
5
6
7call constructor // 构造局部变量buf
call copy constructor // 基于buf,拷贝构造一个临时对象作为返回值
call destructor // GetBuf函数调用结束,局部变量buf自动析构
call copy constructor // 基于返回值,拷贝构造局部变量ret
call destructor // 临时对象自动析构(在构造ret之后,临时对象立刻消亡)
1 1 1 1
call destructor // test3函数调用结束,局部变量ret自动析构
我们发现这里居然发生了三次构造和析构,产生了很多的额外计算开销。
考虑到只用于返回的临时对象的存在,程序大致相对于下面的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19void GetBuf(Buffer *tmp_p, size_t n, int value) {
Buffer buf{n};
for (int i = 0; i < buf.size(); i++) { buf.at(i) = value; }
tmp_p->Buffer::Buffer(buf);
return;
}
void test3() {
Buffer *tmp_p = (Buffer *)malloc(sizeof(Buffer));
GetBuf(tmp_p, 4, 1);
Buffer ret = *tmp_p;
tmp_p->Buffer::~Buffer();
free(tmp_p);
for (int i = 0; i < ret.size(); i++) { std::cout << ret.at(i) << " "; }
std::cout << "\n";
}
程序执行结果同上(这次不需要关闭返回值优化,结果不会受到影响)
1
2
3
4
5
6
7call constructor // 构造局部变量buf
call copy constructor // 基于buf,在tmp_p指向的内存,手动拷贝构造一个临时对象
call destructor // GetBuf函数调用结束,局部变量buf自动析构
call copy constructor // 基于tmp_p指向的临时对象,拷贝构造局部变量ret
call destructor // 手动析构tmp_p指向的临时对象
1 1 1 1
call destructor // test3函数调用结束,局部变量ret自动析构
这样的做法显然过于繁琐了。
回到原来的函数,我们显然不能把GetBuf函数的返回值类型改为Buffer &,因为这样做其实是返回了一个局部变量的引用或指针,而这个局部变量在使用时已经被销毁了。那如果我们将test1函数中的接收部分改为引用呢?
1
2
3
4
5
6void test2() {
Buffer &ret = GetBuf(4, 1); // compile error
for (int i = 0; i < ret.size(); i++) { std::cout << ret.at(i) << " "; }
std::cout << "\n";
}
还是会导致编译报错 1
2error C2440: 'initializing': cannot convert from 'Buffer' to 'Buffer &'
note: A non-const reference may only be bound to an lvalue
编译器给出的理由和上面一样,非const的左值引用不能绑定在一个仅仅作为返回值的临时对象上,后者不能被取地址,不能作为左值。
即使我们尝试进行强制转换,在语法上仍然是不允许的 1
Buffer &ret = static_cast<Buffer &>(GetBuf(4, 1)); // compile error
引用 (2)
右值引用
C++11之后引入了右值引用的语法,这是完全针对底层实现的,为了进一步压榨运行期的效率,避免某些非必要的临时变量的拷贝和析构。这些语法不是针对于上层的语义优化,并不是为了让程序变得更易读的指针语法糖。
右值引用包括如下两个情况:
- 右值引用绑定一个字面值常量
- 右值引用绑定一个将亡值对象
这里字面值常量和将亡值对象都是通常意义下的右值。
右值引用绑定字面值常量的情况比较简单,编译器其实就是定义了一个普通变量来接收字面值常量
1
2
3int &&r = 5; // 右值引用绑定字面值常量
// 等效于
int tmp_r = 5; // tmp_r就是个普通的int变量而已,并不存在什么引用
加上const修饰也是一样的 1
2
3const int &&r = 5;
// 等效于
const int tmp_r = 5;
这表明:右值引用绑定一个字面值常量时,相当于给这个字面值常量提供了生命周期,此时压根没有什么引用或指针,就是在实现时定义了一个变量来接收字面值常量而已。
右值引用并不要求左右两侧的类型严格一致,只要右侧类型可以隐式转换为左侧类型即可
1
2
3const int &&r = 3.14;
// 等效于
const int tmp_r = 3.14;
右值引用并不能像普通的引用一样绑定一个正常的变量,语法上直接禁止了这种行为
1
2
3int a = 2;
int &l = a; // ok
int &&r = a; // compile error
右值引用只能用于绑定特殊的将亡值对象,用于延续它们的生命周期,并完全接管这些临时对象, 我们先讨论如何利用那些函数调用过程中的将亡值,在后文中我们还会介绍如何手动创建将亡值(让变量提前衰亡,使用右值引用进行“夺舍”)
我们可以将函数参数的类型改为右值引用(这种做法其实并不好,见下文中的移动语义部分)
1
2
3
4
5
6
7
8void ProcessBuf(Buffer &&buf) {
for (int i = 0; i < buf.size(); i++) { buf.at(i) = 2 * i; }
for (int i = 0; i < buf.size(); i++) { std::cout << buf.at(i) << " "; }
std::cout << "\n";
}
void test2() { ProcessBuf(Buffer{5}); }
此时程序运行结果如下(我们仍然关闭了返回值优化) 1
2
3call constructor // 在test2函数中,构造临时对象Buffer{5},并被右值引用buf接管
0 2 4 6 8
call destructor // ProcessBuf函数调用结束,buf接管的对象被自动析构
这里省略了一次构造析构,因为我们在传参过程中用右值引用接收并接管了临时对象
1
Buffer &&buf = Buffer{5};
我们也可以将函数的返回值使用右值引用接收 1
2
3
4
5
6
7
8
9
10
11
12
13
14Buffer GetBuf(size_t n, int value) {
Buffer buf{n};
for (int i = 0; i < buf.size(); i++) { buf.at(i) = value; }
return buf;
}
void test3() {
Buffer &&ret = GetBuf(4, 1);
for (int i = 0; i < ret.size(); i++) { std::cout << ret.at(i) << " "; }
std::cout << "\n";
}
此时程序运行结果如下(我们仍然关闭了返回值优化) 1
2
3
4
5call constructor // 构造局部变量buf
call copy constructor // 基于buf,拷贝构造一个临时对象作为返回值
call destructor // GetBuf函数调用结束,局部变量buf自动析构
1 1 1 1
call destructor // ret接管了返回的临时对象,直到test3函数调用结束后,自动析构
这里也省略了一个对象的构造析构,因为我们用右值引用接收并接管了函数的返回值。
小结一下,右值引用绑定将亡值对象的主要作用就是延长将亡值对象的生命周期:
- 在正常情况下,将亡值对象的生命周期非常短暂,短暂到创建和销毁只是一瞬间,我们无法在语法层面对其进行任何操作。它的使命就是传递参数或返回值,然后就地销毁。对它无法进行任何操作,所以才将其归结为右值。
- 我们使用右值引用绑定这将亡值对象,手动延续了它的生命周期(称为续命引用更加合适),它的生命周期从“将亡”变成了与右值引用共存亡;
- 原本这个将亡值对象是匿名的,我们使用右值引用完全接管了它,此后可以对其进行正常的读写等,从这个角度来说,右值引用本身是可以作为左值的(如果没有
const修饰)。
const引用
C++为了贯彻“引用就是别名”的设计目标,除了让引用绑定通常意义下的左值(例如变量)之外,
还允许const引用绑定通常意义下的右值(例如字面值常量),反正const引用不会被修改,只能作为右值。
const引用只能作为右值,右值引用却可以作为左值,这样的命名实在是滑稽。
我们显然无法对字面值常量取地址,为了达成上述目的,const引用在底层实现上必须要分成两种情况进行讨论:
- 绑定变量,作为只读变量的引用,在底层实现中基于指针常量实现;
- 绑定字面值常量,将亡值对象等右值,同样延续了将亡值的生命周期,在底层实现中可以理解为基于右值引用实现。需要说明的是,
const引用绑定右值的实现很可能不是基于右值引用的,这主要是历史原因:const引用出现的时间比右值引用早很多,但是这不影响我们的理解。
绑定变量例如 1
2
3int a = 5; // a是一个变量
const int &r2 = a; // r2是变量a的只读引用
std::cout << r2;
实际相当于 1
2
3int a = 5;
const int *const ptr_r2 = &a; // ptr_r2是指向变量a的只读的指针常量
std::cout << *ptr_r2;
绑定字面值常量例如 1
2const int &r1 = 8; // r1是字面值常量的只读引用
std::cout << r1;
实际相当于 1
2const int &&tmp_r1 = 8; // tmp_r1是使用字面值常量初始化的只读变量
std::cout << tmp_r1;
对于通常的引用,我们要求引用类型和绑定的变量类型必须保持一致,但是对于const引用可以放松这个要求,
只要右侧表达式的类型可以隐式转换为左侧类型即可(当然编译器可能会给出警告)
1
2
3double val = 3.14;
const int &m = val;
std::cout << m; // 3
编译器的做法和使用const引用直接绑定一个字面量的处理类似
1
2
3double val = 3.14;
const int &&m = val;
std::cout << m; // 3
左值和右值 (2)
前面我们已经给出了从C语言继承来的原始的左值和右值的定义,但是在现代C++标准中(C++11、C++14、C++17), 为了适配C++11才提出的右值引用和移动语义等,以及与C++17规定的返回值优化等兼容,左值和右值的相关概念一直在调整。(C++是真的离谱,这么基础的概念在语法上还是一直扯不清楚)
最新的分类标准如下:
- 基本概念
- 纯右值(prvalue)
- 将亡值(xvalue)
- 左值(lvalue)
- 组合概念
- 右值(rvalue):纯右值和将亡值统称为右值;
- 泛左值(glvalue):左值和将亡值统称为泛左值。
想把上面几个基本概念说清楚是一项非常繁琐复杂的工作,具体的讨论可以参考这篇博客:C++17 的值类别。 我并没有兴趣去做C++语言律师,下面只是给出最典型的例子:
- 纯右值:例如绝大多数的字面值常量表达式
- 将亡值:
std::move()得到的表达式是将亡值,还有上文中的那些仅仅用于返回值或传参的临时对象 - 左值:例如一个变量组成的表达式
补充:字面值常量在绝大部分情况下都是纯右值,但有一个例外——字符串字面值是左值,可以取地址的,下面的语句是合法的
1
2std::cout << &("abc") << '\n';
const char *p_char = "abc";

手动创建将亡值
我们继续前面的函数传参的例子,但是做一些修改:调用方提供的不再是一个临时对象,而是一个普通的局部对象
1
2
3
4
5
6
7
8
9
10
11
12
13void ProcessBuf(Buffer buf) {
for (int i = 0; i < buf.size(); i++) { buf.at(i) = 2 * i; }
for (int i = 0; i < buf.size(); i++) { std::cout << buf.at(i) << " "; }
std::cout << "\n";
}
void test2() {
Buffer a{5};
a.at(0) = 100;
ProcessBuf(a);
}
程序运行结果如下(这里无所谓是否关闭优化,因为编译器并不敢进行优化)
1
2
3
4
5call constructor // 在test2函数中,构造局部变量a
call copy constructor // 给ProcessBuf函数传参的过程,基于a拷贝构造参数对象buf
0 2 4 6 8
call destructor // ProcessBuf函数调用结束,参数对象buf自动析构
call destructor // test2函数调用结束,局部变量a自动析构
如果我们将ProcessBuf函数的参数类型改为右值引用,程序会直接编译报错
1
2
3
4
5
6
7
8
9
10
11
12
13void ProcessBuf(Buffer &&buf) {
for (int i = 0; i < buf.size(); i++) { buf.at(i) = 2 * i; }
for (int i = 0; i < buf.size(); i++) { std::cout << buf.at(i) << " "; }
std::cout << "\n";
}
void test2() {
Buffer a{5};
a.at(0) = 100;
ProcessBuf(a); // compile error
}
原因很简单,我们在函数传参中相对于执行了如下语句 1
Buffer &&buf = a;
但是正如前文所说,右值引用不能绑定普通变量,只能绑定将亡值。
这在语法上其实是非常合理的:我们不能让右值引用buf“夺舍”一个普通的变量,万一这个变量在函数调用之后还会使用呢?
但是如果我确实有这样的特殊需求:
- 明确保证普通变量在调用方这一侧已经完全不打算用了,主动将其视作将亡值;
- 我希望把
a的内存控制权直接移交给ProcessBuf函数中的buf参数,从而节约传参过程的成本。
简而言之,我希望手动让一个变量强行变为将亡值,在语法上可以做到吗?答案是既可以也不可以。
我们先说可以的部分,直接将变量强制类型转换为右值引用就可以让编译通过了,而且不会发生额外的拷贝构造
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16void ProcessBuf(Buffer &&buf) {
for (int i = 0; i < buf.size(); i++) { buf.at(i) = 2 * i; }
for (int i = 0; i < buf.size(); i++) { std::cout << buf.at(i) << " "; }
std::cout << "\n";
}
void test2() {
Buffer a{5};
a.at(0) = 100;
ProcessBuf(static_cast<Buffer &&>(a));
// a已经被我们用右值引用“夺舍”了,我们不应该再次使用它,否则是未定义行为
std::cout << "a should not be used again\n";
}
此时我们的程序可以正常编译执行,运行结果如下 1
2
3
4call constructor // 在test2函数中,构造局部变量a
0 2 4 6 8
a should not be used again
call destructor // test2函数调用结束,局部变量a自动析构
我们再说不可以的部分,上述操作只是欺骗编译器的一种文字游戏而已:
- 局部变量
a看起来被右值引用“夺舍”了,但是我们实际上仍然可以使用它,只是不应该使用它,否则行为未定义; - 局部变量
a的析构时机没有被影响,仍然是在test2函数调用后才被自动析构,右值引用buf的生命周期与其并不一致。
std::move
上面使用强制类型转换的语法不太优雅,C++直接提供了std::move函数来简化我们的使用,下面是MSVC的源码
1
2
3
4template <class T>
constexpr remove_reference_t<T>&& move(T&& Arg) noexcept {
return static_cast<remove_reference_t<T>&&>(Arg);
}
std::move就是帮我们将普通类型强制类型转换为右值引用类型,仅此而已,当然了为了实现这个功能,代码中还用到了一些类型转换工具和万能引用,对于万能引用的讨论见后面的笔记。
在前面的例子中,使用std::move是完全一样的效果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16void ProcessBuf(Buffer &&buf) {
for (int i = 0; i < buf.size(); i++) { buf.at(i) = 2 * i; }
for (int i = 0; i < buf.size(); i++) { std::cout << buf.at(i) << " "; }
std::cout << "\n";
}
void test2() {
Buffer a{5};
a.at(0) = 100;
ProcessBuf(std::move(a));
// a已经被我们用右值引用“夺舍”了,我们不应该再次使用它,否则是未定义行为
std::cout << "a should not be used again\n";
}
std::move所体现的就是移动语义:
- 将一个对象手动变成将亡对象,主动交出控制权,使得右值引用可以绑定并接管它;(实际上并没有影响它的生命周期)
- 被移动的对象不应该被再次使用,除非对变量重新赋值,否则行为未定义。
被移动之后的对象处于一个非常特殊的状态,此时只有对它赋予新值和析构的操作是合法的,其它操作全都是未定义行为!在赋值之后又会恢复到正常状态。
std::move只是编译期的处理,不会在运行期引入任何额外成本。
移动构造和移动赋值
有了右值引用和移动语义之后,我们还必须明确对自定义类型的下面两个行为的语义:
- 通过自定义类型的右值引用(它接管着某个将亡值对象的“遗体”)如何构造正常的对象,
- 通过自定义类型的右值引用,如何赋值给正常对象。
为了实现这两个功能,我们需要为自定义类型提供移动构造函数/移动赋值运算,它们和拷贝构造函数/赋值运算具有对应关系:
- 前者负责支持“移动”语义,后者负责支持“拷贝”语义;
- 对于管理内存资源的自定义类型,前者的行为通常是“浅复制”,后者的行为通常是“深复制”,这代表前者通常不会遇到内存分配失败等意外情况;
- 赋值和移动赋值都需求考虑自己给自己赋值的特殊情况。
延续上一篇的例子,下面是移动构造和移动赋值的使用示例
1
2
3
4
5
6
7
8
9
10void Demo() {
Buffer buf1{16};
// 移动构造
Buffer buf2(std::move(buf1)); // 把buf1强制“将亡”,但用它的“遗体”构造新的buf2
Buffer buf3{8};
// 移动赋值,因为已经构造过了
buf3 = std::move(buf2); // 把buf2强制“将亡”,把“遗体”转交个buf3,buf3原本的东西被丢弃了
}
自定义类型的这四个函数通常具有如下形式 1
2
3
4
5Buffer(const Buffer &ob); // 拷贝构造
Buffer &operator=(const Buffer &ob); // 赋值
Buffer(Buffer &&ob); // 移动构造
Buffer &operator=(Buffer &&ob); // 移动赋值
为了支持移动语义,我们提供的这两个函数需要满足如下要求:
- 移动会让当前对象接管所有的资源,并且保证被移动对象的析构操作是合法但无害的:
- 合法是指不会产生任何错误或异常,通常需要将管理资源的指针置空
- 无害是指不会对当前对象产生任何影响
- 虽然语法上允许移动操作抛异常,但是考虑到性能因素,建议不要在其中抛出任何异常,并且明确标记
noexcept
Buffer类型的移动构造和移动赋值的具体实现如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20Buffer::Buffer(Buffer &&ob) noexcept : m_buf_size(ob.m_buf_size), m_buf(ob.m_buf) {
PRINT_INFO("call move constructor");
ob.m_buf_size = 0;
ob.m_buf = nullptr;
}
Buffer::Buffer &operator=(Buffer &&ob) noexcept {
PRINT_INFO("call move assignment operator");
if (this == &ob) { return *this; }
deallocate();
m_buf_size = ob.m_buf_size;
m_buf = ob.m_buf;
ob.m_buf_size = 0;
ob.m_buf = nullptr;
return *this;
}
作为对比,下面是拷贝构造和赋值的具体实现 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16Buffer::Buffer(const Buffer &ob) {
PRINT_INFO("call copy constructor\n");
allocate(ob.m_buf_size);
memcpy(m_buf, ob.m_buf, sizeof(int) * ob.m_buf_size);
}
Buffer::Buffer &operator=(const Buffer &ob) {
PRINT_INFO("call copy operator\n");
if (this == &ob) { return *this; }
deallocate();
allocate(ob.m_buf_size);
memcpy(m_buf, ob.m_buf, sizeof(int) * ob.m_buf_size);
return *this;
}
对于自定义类型,编译器可能会为我们自动生成相应的移动构造和移动赋值运算,具体细节这里不作讨论。 为了安全考虑,对于和
Buffer类似的资源管理类,最好自行提供所有的特殊方法:构造+析构,拷贝构造+赋值,移动构造+移动赋值。
补充
函数的只读参数类型
我们通常会使用const引用作为函数中只读参数的类型,因为它可以兼容更多方式的函数调用,例如
1
2
3
4
5
6
7
8
9
10
11
12struct Demo {};
void func(const Demo &arg) {
// ...
}
void test() {
func(Demo{});
Demo a;
func(a);
}
如果使用普通的传值方式,也可以做到这一点,但是正如前文中讨论的,在传值过程中会发生额外的拷贝(其实也未必,因为编译器很可能自动进行了优化)
1
2
3
4
5
6
7
8
9
10
11
12struct Demo {};
void func(Demo arg) {
// ...
}
void test() {
func(Demo{});
Demo a;
func(a);
}
如果改成非const的左值引用则无法做到(并且失去了只读参数的语义),此时第一种调用方式是编译错误
1
2
3
4
5
6
7
8
9
10
11
12struct Demo {};
void func(Demo &arg) {
// ...
}
void test() {
func(Demo{}); // compile error
Demo a;
func(a);
}
如果改成右值引用也无法做到,此时第二种调用方式是编译错误
1
2
3
4
5
6
7
8
9
10
11
12struct Demo {};
void func(Demo &&arg) {
// ...
}
void test() {
func(Demo{});
Demo a;
func(a); // compile error
}
小结:
- 对于复杂类型的只读参数,
const引用是最优选择,但是对于简单类型的参数(例如基本类型),直接使用值传递也是很好的选择; - 如果我们希望只允许使用某一种只读参数,例如只允许传入字面量,那么右值引用就是更合适的选择;
- 如果函数需要的是读写参数,那么对参数类型的选择通常有非
const的左值引用和指针两种选择,除了使用风格不同,两者之间并没有明显的优劣。
补充:这里讨论的是非模板的普通函数,对于模板函数的参数类型设计过于复杂,不在本文的讨论范围之内。