C++模板从入门到劝退(1)——右值引用与移动语义

谈到C++模板,多少业界大佬无不讳莫如深,而基于模板的元编程更是历来被业界同仁视为黑魔法。模板从诞生至今已有几十年的历史,实际上在经历了C++11标准的革命性洗礼以及后续的缝缝补补后,模板元编程的门槛和抽象程度已经大大降低。纵观历史长河,优秀的C++书籍鳞次栉比,然而涉及到元编程的书籍却屈指可数,那些传世经典的圣经对于模板皆是浅尝辄止。本系列文章将致力于揭开模板元编程的迷雾,通过牺牲一定程度的表述严谨性,按照笔者的归纳方式穿针引线,为痴迷于黑魔法而又始终不得其解的同学打通任督二脉。

本篇是走进模板世界的第二道前菜,只有搞懂了这些C++中的基本概念,才能掌握模板世界的黑魔法。

右值引用与移动语义

右值引用初探

上一讲我们对表达式的值分类进行了展开,围绕值语义和引用语义的探讨对现代C++的左值和右值有了深入的理解。但搞懂值分类的区分仅是第一步,我们尚未揭晓移动语义的帷幕。

在传统C++中,引用非常纯粹,比如代码:

1
2
int a = 1;
int &b = a; // 这里的&不是取址符,而是左值引用的声明符

其中b就是a的引用,开发者简单地将b视为变量a的别名,通过ba对变量值进行读写本质上并没有什么不同。引用语法相比从C继承的指针来说更加简洁,也更容易理解。而从现代C++的视角来看,这里的b是一个左值引用(显然,它绑定的a是个左值),这里对左值的强调是为了区分C++11所引入的另一种引用类型:右值引用。那么右值引用如何声明呢?既然是全新的语法,那肯定得量身定制:语法规定用&&作为右值引用的声明符。因此,形如S& D;是将D声明为S所确定类型的左值引用;形如S&& D;则是将D声明为S所确定类型的右值引用。顾名思义,左值引用绑定到左值,右值引用绑定到右值(也就是xvalue和prvalue)。

你可能还见到过别人这样写:int& b = a;,声明符的空格位置和上述写法相反,而实际上这里怎么书写都是正确的(&两边都有空格也无所谓),因为&是声明符而非取地址符(address of),我个人习惯于这种写法,它有一种把&int联系起来的感觉。

1
2
3
4
5
6
7
8
9
int a = 1;
int& b = a; // 正确:b是绑定到左值a的左值引用
int& c = 1; // 错误:字面量1是prvalue,左值引用不能绑定到右值
int&& d = a + 1; // 正确:算术运算符表达式是prvalue

std::string s1 = "hello";
std::strin&& r1 = s1; // 错误:右值引用不能绑定到左值
std::string&& r2 = s1 + s1; // 正确:左右操作数为std::string的操作符重载(operator+)返回的是一个值类型,值分类上是prvalue
std::string&& r3 = static_cast<std::string&&>(s1); // 正确:到右值引用类型转换的表达式值类型是xvalue

上面是用基础内建类型int和标准库的std::string类类型作为示例演示的说明。

关于引用,还有些开发者必须知道的细节:

  • 引用必须被初始化为指代一个有效的对象或函数,这一点对左值引用一如既往,对右值引用也一视同仁。
  • 尽管void类型存在指针,但不存在引用,实际上void*语义上也并非void类型的指针,而是某种未知/不确定类型的指针。
  • 不存在引用类型的引用(禁止套娃),也不存在引用类型的指针或是引用的数组(但是存在数组的引用(int(&a)[3])),本质原因在于引用并非对象。
  • 引用类型本身不能有CV(const/volatile)限定(顶层),但被引用的类型可以有CV限定(底层),本质原因同样是在于引用并非对象。

引用与const限定

上述的最后一个条例提到了CV限定,它是指C++运行时对对象的一种constvolatile限定,后者对于大部分开发场景来说十分罕见(这货跟其他语言比如Java的volatile有着甚许微妙差别,而volatile在Java日常编程中十分常见),而前者则常伴于身。对于引用来说,const限定无疑让语法的复杂度提升了一个维度,我们先用int基础内建类型来做示例:

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
int a = 1;
const int& b = a; // 正确:const左值引用可以绑定到非const变量
b = 2; // 错误:左值引用b所绑定的变量有const限定,RT不可修改
const_cast<int&>(b) = 2; // 正确:const引用的RT限定可以通过const_cast来擦除,这里确实修改了变量a的值为2;
/*
test2.cpp:9:7: error: cannot assign to variable 'b' with const-qualified type 'const int &'
b = 2;
~ ^
test2.cpp:8:16: note: variable 'b' declared const here
const int& b = a;
~~~~~~~~~~~^~~~~
1 error generated.
*/
const int &&ra = a; // 错误:常量右值引用亦不能绑定到左值
/*
test2.cpp:11:17: error: rvalue reference to type 'const int' cannot bind to lvalue of type 'int'
const int&& ra = a;
*/
const int &&rb = 1; // 正确:常量右值引用当然可以绑定到prvalue,只不过毫无卵用


const int c = 1;
int& d = c; // 错误:左值引用类型与所绑定的变量类型不一致
/*
test2.cpp:11:11: error: binding reference of type 'int' to value of type 'const int' drops 'const' qualifier
int&& d = c;
^ ~
*/
const int& e = c; // 正确
const_cast<int&>(e) = 2; //正确:虽然语法上可以通过const_cast擦除来间接访问修改const对象,但产生的结果是undefined behaviour,不要依赖UB行为
int&& f = c; // 错误:右值引用类型与所绑定的变量类型不一致(第一关都没过去)
/*
test2.cpp:17:11: error: binding reference of type 'int' to value of type 'const int' drops 'const' qualifier
int &&f = c;
*/


// 一个出人意料的case
const int& g = 1; // 正确:虽然字面量1是prvalue,但是const左值引用可以绑定到右值(可以理解成字面量值先隐式转换成了一个匿名的const int,然后将const引用与其绑定)

根据上述代码,我们简单归纳一下:

  • 引用绑定过程中const限定不能drop,但是可以add。换句话说,非const引用不能绑定const值,但const引用可以绑定到非const值。
  • 对于绑定到非const值的const引用,可以通过const_cast来做临时的remove以开绿灯。
  • const左值引用之所以可以绑定到右值,是为了语言的向前兼容性,传统C++无所谓左值引用,可以理解成是const引用到非左值的绑定。

关于const左值引用可以绑定到右值的历史原因与标准化演进,可以参考《C++设计与演化》一书。

引用类型 非const左值 const左值 非const右值 const右值
非const左值引用
const左值引用
非const右值引用
const右值引用

移动构造和移动赋值运算符

连同上一讲,我们耗费了大量的篇幅来对左值、右值、引用与const做了阐释,可是不论是左值引用还是右值引用,引用终归只是个引用,我们知道引用本身不会产生拷贝,但是这和移动语义有什么关系呢?截止到目前,我们利用右值引用并不能做将资源A移动给资源B的操作。

到这里,就需要正式介绍C++11所引入的移动语义了,与传统的类成员函数:拷贝构造器(copy ctor)和拷贝赋值运算符(copy assignment)对应,C++11为支持移动语义,引入了额外的两个类成员函数:移动构造器(move ctor)和移动赋值运算符(move assignment)。正如拷贝构造器和拷贝赋值运算符的“孪生性”,移动构造器和移动赋值运算符也是如此,相比前一对的const Object&参数,后一对的参数换成了右值引用Object&&

举个例子:

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
#include <iostream>

using namespace std;

class Object{
public:
Object() { cout << "default ctor" << endl; }
Object(const Object& rhs) {
cout << "copy ctor" << endl;
}
Object(Object &&rhs) {
cout << "move ctor" << endl;
}

Object& operator=(const Object& rhs) {
cout << "copy assignment" << endl;
return *this;
}
Object& operator=(Object&& rhs) {
cout << "move assignment" << endl;
return *this;
}

~Object() {}
};

int main(){
Object a; // 调用默认构造器
Object b = a; // 调用拷贝构造器

Object c; // 调用默认构造器
c = a; // 调用拷贝赋值运算符

Object d = static_cast<Object&&>(c); // 调用移动构造器
Object e; // 调用默认构造器
e = static_cast<Object&&>(c); // 调用移动赋值运算符

return 0;
}

输出结果:

1
2
3
4
5
6
7
default ctor
copy ctor
default ctor
copy assignment
move ctor
default ctor
move assignment

实际上看到这里你就会发现,所谓的移动构造器,不过就是一种参数为对象右值引用类型的构造器罢了,C++语法上的扩充仅仅是右值语义而已,移动构造和移动赋值运算符不过是一种优雅的称谓。

你可能听说过默认构造器+BigFive(传统C++的BigThree(拷贝构造、拷贝赋值运算符、析构)再填上这两个移动兄弟,正好凑成五排(bushi)),当然这不是标准化的称谓(应该是侯捷老师带来的叫法),但编译器确实会按照标准所规定的那样,对默认构造和BigFive做一些特殊照顾。

思考:如果上述代码不定义移动构造器和移动赋值,那么Object d = static_cast<Object&&>(c);e = static_cast<Object&&>(c);会不会编译报错呢?如果不会又该调用谁呢?

然而很快你就会发现另一个问题,对Object来说,移动构造器和拷贝构造器除了打印信息不同以外,没有任何区别。回想一下传统C++中曾学过的拷贝构造器,如果是编译器默认生成的版本,拷贝构造器所做的事非常简单:

  • 对于基础内建类型、POD(Plain Of Data)和指针类型,它以bit为单位将对象A中相应数据成员复制到对象B对应数据成员的存储地址。
  • 对于复杂类型,则调用其拷贝构造函数,如有嵌套则逐层递归。

基于第一条,如果数据成员里有指针类型,我们就必须自己重写BigFive,因为尽管C++基于值语义的默认拷贝动作是深拷贝,但是对于指针型变量来说,深拷贝拷贝的是地址值,而不是该地址所指向的对象(所以在C++中,这种情况被称作浅拷贝,此浅拷贝和其他引用语义编程语言中的浅拷贝有着微妙差别)。

这里的“必须“并不是语法要求,而是基于编程常理,或者换句话说,如果不这样做,就很可能会引入诸如double free, uaf, memory leak等问题。这也是C++上手门槛高的一个原因。

而另一方面,Object是个空类,我们原本打算通过Object d = static_cast<Object&&>(c);调用移动构造器把c“搬空”,资源归属转移到d而不是复制给d,可在实际实现移动构造器的时候才发现Object空空如也。没错,这个例子中Object是个空类(当然这种情况其实例化对象也是有size的),对于这种类本不用定义它的BigFive(编译器会默认生成),因为它并没有需要区分拷贝或者移动的资源。

我们改进一下,引入三个数据成员,分别用具有代表性的基础类型int,标准库的复杂类类型std::string和指针类型char*,请一定仔细阅读代码注释:

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
82
83
84
85
86
87
88
89
90
#include <iostream>
#include <string>

using namespace std;

class Object{
public:
// custom ctor
Object(const string& name, int size) : name_(name), size_(std::max(size, 16)), data_ptr_(new char[size_]) {}

// copy ctor
// 对于拷贝构造器来说,我们需要对name_调用std::string的拷贝构造器来做一次copy
// 而基础内建类型没有拷贝构造器,它只能进行bit copy,这也是自动生成的拷贝构造器的动作
// 自动生成的copy ctor对data_ptr_做的也是bit copy,但显然我们希望对其做深拷贝,因此要自己写逻辑
Object(const Object& rhs) : name_(rhs.name_), size_(rhs.size_), data_ptr_(new char[rhs.size_]) {
std::copy(rhs.data_ptr_, rhs.data_ptr_ + rhs.size_, data_ptr_);
}

// move ctor
// 对于移动构造器来说,我们只需要对name_调用std::string的移动构造器来做move而非copy
// 基础内建类型没有移动构造器,它只能进行bit copy,这也是自动生成的移动构造器的动作
// 自动生成的move ctor对于data_ptr_做的也是bit copy,但显然我们不仅希望做浅拷贝,还希望传进来的右值引用所refer to的值可以被转移释放(偷走)
Object(Object &&rhs) : name_(std::move(rhs.name_)), size_(rhs.size_), data_ptr_(rhs.data_ptr_) {
rhs.data_ptr_ = nullptr;
rhs.size_ = 0;
}

// copy assignment
// 拷贝赋值运算符是copy ctor的孪生,用于适配非初始化赋值情景的赋值值语义
// 参数类型为const左值引用,意味着可以绑定到所有const/非const左值或右值,因此右操作数是啥都接得住
Object& operator=(const Object& rhs) {
if (this != &rhs) {
char *ptr = new char[rhs.size_];
std::copy(rhs.data_ptr_, rhs.data_ptr_ + rhs.size_, ptr);
if (data_ptr_ != nullptr) {
delete[] data_ptr_;
}
data_ptr_ = ptr;
size_ = rhs.size_;
name_ = rhs.name_;
}
return *this;
}

// move assignment
// 移动赋值运算符是move ctor的孪生,用于适配非初始化赋值情景的赋值值语义
// 参数类型为右值引用,意味着仅可以绑定到非const右值引用,同时对于传递非const右值引用时
// 根据函数重载的优先级会优先匹配,换句话说,如果没有该定义,那就只能退一步去调用copy assignment
Object& operator=(Object&& rhs) {
if (this != &rhs) {
if (data_ptr_ != nullptr) {
delete[] data_ptr_;
}
data_ptr_ = rhs.data_ptr_;
size_ = rhs.size_;

name_ = std::move(rhs.name_);
rhs.data_ptr_ = nullptr;
rhs.size_ = 0;
}
return *this;
}

// dtor
// 对于拥有raw pointer数据成员的类来说,析构都是少不了的
~Object() { if(data_ptr_ != nullptr) delete[] data_ptr_; }

void DebugPrint() {
std::cout << name_ << "\t" << size_ << "\t" << static_cast<const void*>(data_ptr_) << std::endl;
}
private:
std::string name_;
int size_;
char* data_ptr_;
};

int main(){
Object a("a", 32);
Object b("b", 64);

Object c = a;
Object d = std::move(b); // std::move见下文

a.DebugPrint();
b.DebugPrint();
c.DebugPrint();
d.DebugPrint();

return 0;
}

运行结果:

1
2
3
4
a	32	0x60000180d120
0 0x0
a 32 0x60000180d140
b 64 0x600000d0c1c0

在解释运行结果之前,我们先来说明一个C++11标准库所引入的一个非常常见的函数模板:std::move,该函数的名称如果是从C++的语法角度来看非常的具有误导性,std::move本身并不能做”移动“或者说“资源归属转移”操作,它仅仅是返回一个调用参数的右值引用而已,在我们的代码中,它曾两次出现:

  • Object d = std::move(b);
  • Object(Object &&rhs) : name_(std::move(rhs.name_)), size_(rhs.size_), data_ptr_(rhs.data_ptr_){...}

由于std::move函数模板本身比较复杂(个锤子),这里暂且按下不表,只需要了解这两行可以等价替换成:

  • Object d = static_cast<Object&&>(b);
  • Object(Object &&rhs) : name_(static_cast<std::string&&>(rhs.name_)), size_(rhs.size_), data_ptr_(rhs.data_ptr_){...}

再来看看运行结果,a的打印结果直截了当,三个成员的值(data_ptr_的值就是地址,只不过通过cout打印需要做一点小小的trick)符合预期,尽管我们曾进行了Object c = a;的操作,但是由于值语义,这里调用的是Object的拷贝构造器,拷贝构造器并没有修改a的数据成员(事实上const左值引用也修改不了(不考虑const_cast绿灯大法))

b的打印结果就不那么直观了,看上去name_变成了空字符串,size_也变成了0data_ptr_更是变成了空指针。这是为什么呢,我们不是明明调用custom ctor构造了一个“有血有肉”的b对象实例吗,为何它的数据看起来就像是被搬空了一样呢?没错,它的数据正是被搬空了,答案就在于Object d = std::move(b);这一行代码,这里由于参数类型是Object&&,故优先匹配上了移动构造器而非拷贝构造器,而移动构造器中,我们的所作所为正是将参数的数据成员搬空!换言之,std::move就像是一个标记器,它将一个左值标记成了将亡值(还记得上一讲的表达式值分类吗?返回类型为右值引用的类型转换表达式是一个xvalue),而后根据值语义和重载函数参数匹配规则,触发了移动构造器(或移动赋值运算符)的调用,而真正做资源归属转移的正是移动构造器(或移动赋值运算符),这也就是上文一再强调,std::move本身没有做移动操作,真凶另有其人,std::move只能算作一个帮凶,没有移动构造器(或移动赋值运算符),它什么也做不成。

接下来,c是复制的a,由于我们的拷贝构造器中对data_ptr_做了深拷贝,故其打印结果与a并不相同。而d是移动的b,打印出的结果实际上就是b未被搬空前的成员值。

总结一下,这里用了三种典型的数据类型来对比拷贝和移动的差别:

  • 对于基础内建类型,无所谓拷贝还是移动,通通copy bit by bit
  • 对于复杂的类类型嵌套,递归调用它本身的拷贝或移动构造器(or 赋值运算符)
  • 对于指针类型,默认行为是copy bit by bit,但我们往往需要自己重写BigFive来谨慎处理

看到这里,一些同学一定会有这样的问题:说好的移动语义,怎么看上去还是东拼西凑呢?语法上为什么不对基础类型也来一个移动语义呢?比如:

1
2
3
4
5
6
7
8
9
int a = 3;
int b = std::move(a);

cout << a << "\t" << b << endl;
cout << &a << "\t" << &b << endl;
// 输出结果:
// 3 3
// 0x16d11b718 0x16d11b714
// a并没能被搬空,甚至继续使用也没有问题,a和b的地址也各不相同

还记得移动语义的初衷吗?我们想要解决的是大对象冗余的拷贝,像是基础内建类型这种size非常小的数据,比如64bit机寄存器就可以一次性装载8B数据了,copy的成本过于低廉,真要支持个移动说不定反而效率更低(其他引用语义的语言如Java又何尝不是如此)。

基础类型的raw数组也一样是copy bit by bit, 但这个成本可能会很高昂,这也是modern C++不推荐用raw数组的原因之一。

而另一方面,对于复杂的类类型比如std::string,它本身也是基础类型、指针和其他复杂类型堆砌而成,因此,它内部的移动构造或是移动赋值运算符的实现其实和我们这里对size_data_ptr_的处理大同小异。

移动语义的本质

在了解了语言层面的移动语义实现机制后,我们需要再从复杂的语法规则中跳出来,高屋建瓴的思考移动语义的适用场景。一言以蔽之:移动语义适用于那些当某种资源已确定不再被需要的场景,此时仅需要通过移动语义做资源归属转移而非复制。 C++为支撑移动语义所提供的语言机制,从值分类到右值引用再到移动构造/赋值运算符帮助我们完成了这一项工作,仅此而已。

上文中的例子是个"can be cast to xvalue"的场景,其中b不再被需要,通过std::move我们拿到其xvalue传递给了d的移动构造器,进而窃取资源。实际使用中,往往还有另一种场景,就是传入移动构造器的参数不是xvalue,而是一个prvalue,这常见于将那些返回类型为非引用类型的函数调用结果作为构造器参数的情景(虽然以前也有(N)RVO遮了羞)。

于是,对于移动构造器来说,我们在将参数的资源窃为己有后,一般会将其重置为默认态或者零值态(标准没有硬性规定要如何善后,但是规定了接下来对它的操作皆是UB行为(由编译器具体实现机制来买单)),正如我们在上例中对size_清零,对data_ptr_置空的操作,而std::string的移动构造器则是将字符串重置成空串作为善后处理手段。而移动赋值运算符则麻烦一点,因为被赋值的对象可能有历史的数据,尤其是对于指针型就要及时释放,避免memory leak。

此外,移动构造器和移动赋值运算符只是两种比较特别的成员函数,并不是说移动只能在这两种函数内处理,比如,我们定义这样一个构造器:

1
2
3
4
5
class Object{
public
Object(std::string&& name) : name_(std::move(name)), size_(16), data_ptr_(new char[16]) {}
。。。
};
参数也可以是一个右值引用,此时我们可以通过std::string的移动构造器来将参数的资源偷走,而如果不进行窃取的话,比如改成: name_(name)(就会调用std::string的拷贝构造,因为name本身是左值)。

std::move

上文也提到std::move是个函数模板,它的标准定义如下:

1
2
3
4
template<typename T>
constexpr typename std::remove_reference_t<T>&& move(T&& t) noexcept {
return static_cast<typename std::remove_reference_t<T>&&>(__t);
}

想要理解std::move,需要前置掌握这两个知识点:

  1. std::remove_reference_t<T>是个type trait,它会cut掉类型T的引用修饰符,返回裁剪后的类型,实际上是typename std::remove_reference<T>::type的别名模板,由C++14标准引入。
  2. 函数参数类型T&&在此并非右值引用,这里它搭配模板参数一起出现(本质上是T需要被推导时),是一个万能引用(Universal Reference),它的真实类型遵循引用折叠规则。
    1. 左值引用-左值引用T& &:左值引用T&
    2. 左值引用-右值引用T& &&:左值引用T&
    3. 右值引用-左值引用T&& &:左值引用T&
    4. 右值引用-右值引用T&& &&:右值引用T&&

Scott的《Effective Modern C++》的称其为万能引用(Universal Reference),这并不是标准的称呼,C++标准出台后称其为转发引用(Forwarding Reference),这个名称历来被诟病见名不知义,远不如Scott给出的称呼(当然它能当选也说明有忠实的拥趸,必有可取之处),故坊间皆以“万能引用”的说法传世,我们也弃用这个标准的说法,使用“万能引用”。

所有的引用折叠最终都代表一个引用,要么是左值引用,要么是右值引用。如果任一引用为左值引用,则结果为左值引用。否则(即两个都是右值引用),结果为右值引用。

因此,对于std::move来说:

  • 如果传入的是左值,则参数类型被推导成左值引用,通过static_cast类型转换成右值引用返回。
  • 如果传入的是右值,则参数类型被推导成右值引用,也通过static_cast类型转换成右值引用返回。

至此,我们完成了右值引用和移动语义的学习,下一讲我们深入了解引用折叠、万能引用和完美转发机制。

参考文献


C++模板从入门到劝退(1)——右值引用与移动语义
https://r00tk1ts.github.io/2022/06/04/C++模板从入门到劝退(1)——右值引用与移动语义/
作者
r00tk1t
发布于
2022年6月4日
许可协议