cpp11-移动语义详解
今天在业务代码里看到了move的使用,竟然感到一点震惊,因为代码里大多数都还是用的cpp98的标准在写,cpp11的特性其实用的很少,所以特地重新复习了一下cpp11中move的设计,尝试更加深入理解移动语义的原理和作用,顺便把一些思考沉淀下来。
一、rule of three/five
rule of three是自从C++98标准问世以来, 大家总结的一条最佳实践. 这个实践其实很简单, 用一句话就能说明白:
析构函数, 拷贝构造函数, =操作符重载应当同时出现, 或同时不出现
那么, 这背后的缘由是什么呢? 这里就来说道说道.
C++中, 所有变量都是值类型变量, 这意味着在C++代码中, 隐式的拷贝是非常常见的, 最常见的一个隐式拷贝就是参数传递: 非引用类型的参数传递时, 实质上发生的是一次拷贝, 首先我们要明白, 所谓的发生了一次拷贝, 所谓的拷贝, 到底是指什么.
我们从一段短的代码片断开始:
1 | class person |
上面是一个简单的类, 仅实现了一个构造函数.
到底什么是拷贝的本质? 在上面代码片断中, Line 1显然不是拷贝, 这是一个非常显然的初始化, 它调用的也很显然是我们定义的唯一的那个构造函数: person(const std::string& name, int age). Line 2和Line 3呢?
Line 2: 也是一个初始化: 初始化了对象b. 它调用的是类person的拷贝构造函数.
Line 3: 是一个赋值操作. 它调用的是person的=操作符重载
但问题是, 在Line 2中, 我们并没有定义某个构造函数符合person b(a)的调用. 在Line 3中, 我们也并没有实现=操作符的重载. 但上面那段代码, 是可以被正常编译执行的. 所以, 谁在背后搞鬼?
答案是编译器, 编译器在背后给你偷偷实现了拷贝构造函数(person(const person & p))与=操作符重载(person& operator =(const person & p)). 根据C++98的标准:
拷贝构造函数(copy constructor),=操作符(copy assignment operator),析构函数(destructor)是特殊的成员函数(special member functions- 当用户没有显式的声明
特殊的成员函数的时候, 编译器应当隐式的声明它们. - 当用户没有显式的声明
特殊的成员函数(显然也并没有实现它们)的时候, 如果代码中使用了这些特殊的成员函数, 编译器应当为被使用到的特殊的成员函数提供一个默认的实现
并且, 根据C++98标准, 编译器提供的默认实现遵循下面的准则:
拷贝构造函数的默认实现, 是对所有类成员的拷贝. 所谓拷贝, 就是对类成员拷贝构造函数的调用.=操作符重载的默认实现, 是对所有类成员的=调用.析构函数默认情况下什么也不做
也就是说, 编译器为person类偷偷实现的拷贝构造函数和=操作符大概长这样:
1 | // 拷贝构造函数 |
问题来了: 我们需要在什么情况下显式的声明且实现特殊的成员函数呢? 答案是: 当你的类管理资源的时候, 即类的对象持有一个外部资源的时候. 这通常也意味着:
- 资源是在对象构造的时候, 交给对象的. 换句话说, 对象是在构造函数被调用的时候获取到资源的
- 资源是在对象析构的时候被释放的.
为了形象的说明管理资源的类与普通的POD类之间的区别, 我们把时光倒退到C++98之前, 那时没有什么标准库, 也没有什么std::string, C++仅是C的一个超集, 在那个旧时光, person类可能会被写成下面这样:
1 | class person |
这种上古风格的代码, 其实直到今天都还在有人这样写, 并且在将这种残缺的类套进std::vector, 并且调用push_back后发出痛苦的嚎叫: “MMP为什么代码一跑起来一大堆内存错误?”, 就像下面这样:
1 | int main(void ) { |
这是因为: 你并没有提供拷贝构造函数, 所以编译器给你实现了一个. 你调用vec.push_back(person("allen", 27))的时候, 调用了编译器的默认实现版本. 编译器的默认实现版本仅是简单的复制了值, 意味着同一段内存被两个对象同时持有着. 然后这两个对象在析构的时候都会去试图delete[]同一段内存, 所以就炸了.
这就是为什么, 如果你写了析构函数的话, 就应当再写复制构造函数与=操作符重载, 它的逻辑是这样的:
- 你自行实现了析构函数, 说明这个类并不是简单的POD类, 它有一些资源需要在析构的时候进行释放, 或者是内存, 或者是其它句柄
- 为了避免上面示例中的资源重复释放问题, 你需要自行实现对象的
拷贝语义, 根据资源是否能被安全的重复释放, 或者资源是否能被安全的多个对象持有多份拷贝, 来决定拷贝的语义 - 为了实现
拷贝的语义, 你需要自行实现拷贝构造函数与=操作符重载
所以一个安全的person类应当实现如下的拷贝构造函数和=操作符重载
1 | // 拷贝构造函数 |
注意上面的=操作符重载的实现是很不安全的, 但如何正确的写一个=操作符重载并不是本节所要讨论的内容(下一节”copy-and-swap idiom”中再进行讨论). 这里只要明白为什么析构函数, 拷贝构造函数, =操作符重载应当同生共死就行了.
某些场合中, 对象所持有的资源是不允许被拷贝的, 比如文件句柄或互斥量. 在这种场合, 与其费尽心机的去研究如何让多个对象同时持有同一个资源, 不如直接禁止这种行为: 禁止对象的拷贝与赋值. 要实现这种功能, 在C++03标准之前, 有一个很简单的方式, 即是把拷贝构造函数和=操作符在类内声明为private, 并且不去实现它们, 如下:
1 | private: |
在C++11标准下, 你可以使用delete关键字显式的说明, 不允许拷贝操作, 如下:
1 | person(const person& that) = delete; |
所以, 至此, 就基本说明白了为什么rule of three是大家自从C++98以来, 总结出来的一个最佳实践. 比较遗憾的是, 在语言层面, C++并没有强制要求所有程序员都必须这样写, 不然不给编译通过. 所以说呀, C++这门语言还真是危险呢.
而自C++11以来, 类内的特殊的成员函数由三个, 扩充到了五个. 由于移动语义的引入, 拷贝构造函数和=操作符重载都可以有其右值引用参数版本以支持移动语义, 所以rule of three就自然而然的被扩充成了rule of five, 下面是例子:
1 | class person |
二、copy-and-swap idiom
在rule of three/five小节, 我们已经讨论了, 任何一个管理资源的类, 都应当实现拷贝构造函数, 析构函数与=操作符重载. 这三者中, 实现拷贝构造函数和析构函数的目的是很显而易见的, 但=操作符重载的实现目的, 以及实现手段在很长一段时间内都是有争论的, 人们在实践中发现, 要实现一个完善的=操作符重载, 其实并不像表面上想象的那么简单, 那么, 到底应当如何去写一个完美的=操作符重载呢? 这其中又有哪些坑呢? 这一节我们将进行深入讨论.
简单来说, copy-and-swap就是一种实现=操作符重载的最佳实践, 它主要解决(或者说避免了)两个坑:
- 避免了重复代码的书写
- 提供了强异常安全的保证
逻辑上来讲, copy-and-swap在内部复用了拷贝构造函数去拷贝资源, 然后将拷贝好的资源副本, 通过一个swap函数(注意, 这不是标准库的std::swap模板函数), 将旧资源与拷贝好的资源副本进行交换. 然后复用析构函数将旧资源进行析构. 最终仅保留拷贝后的资源副本.
上面这段话你看起来可能很头晕, 这不要紧, 后面会进行详细说明.
copy-and-swap套路的核心有三:
- 一个实现良好的
拷贝构造函数 - 一个实现良好的
析构函数 - 一个实现良好的
swap函数.
所谓的swap函数, 是指这样的函数:
- 不抛异常
- 交换两个对象的所有成员
- 不使用
std::swap去实现这个swap函数. 因为std::swap内部调用了=操作符, 而我们要写的这个swap函数, 正是为了实现=操作符重载
上面说了那么多, 可能看的你脑壳越来越痛, 不要紧, 现在我们用代码来阐述. 比如下面这个dump_array类, 内部持有堆区的资源(也就是一个通过new分配的数组), 我们先给它把拷贝构造函数和析构函数实现掉.
1 |
|
我们先来看一个失败的=操作符重载实现
1 | // the hard part |
表面上看这个实现好像也没什么大问题, 但实际上它有三个缺陷:
在
(1)处, 需要首先判断=操作符左右是不是同一个对象. 这个逻辑上来讲其实没什么问题. 但实际应用中,=左右两边都是同一个对象的可能性非常低, 几乎为0. 但这种判断你又不得不做, 你做了就是拖慢了代码运行速度. 但坦白讲这并不是一个什么大问题.在
(2)处, 先是把旧资源释放掉, 然后再在(3)处进行新资源内存的再申请与数据拷贝. 但如果第(3)步, 申请内存的时候抛异常失败了呢? 整个就垮掉了.一个改进的实现是先申请内存与数据拷贝, 成功了再做旧资源的释放, 如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15dumb_array& operator=(const dumb_array& other)
{
if (this != &other)
{
std::size_t newSize = other.mSize;
int* newArray = newSize ? new int[newSize]() : nullptr;
std::copy(other.mArray, other.mArray + newSize, newArray);
delete [] mArray;
mSize = newSize;
mArray = newArray;
}
return *this;
}整个
=重载的实现, 几乎就是抄了拷贝构造函数中的代码(虽然在本例中不是很明显: 因为拷贝构造函数中使用了成员初始化列表).
看到这里你可能觉得我在吹毛求疵, 但你稍微想一下, 如果我们要管理的资源的非常复杂的初始化步骤的话, 上面的写法其实就很恶心了. 首先是异常安全的保证就需要非常小心, 其次就是抄代码的情况就会非常明显: 同样的逻辑, 你要在拷贝构造函数和=操作符重载里, 写两遍!
那么一个正确的实现应当怎么写呢? 我们上面说过, copy-and-swap套路能规避掉上面的三个缺陷, 但在继续讨论之前, 我们首先要实现一个swap函数. 这个swap函数是如此的重要与核心, 我甚至愿意为此, 将所谓的rule of three改名叫成rule of three and a half, 其中的a half就是指这个swap函数. 多说无益, 我们来看swap的实现, 如下:
1 | class dumb_array |
这个swap的实现初看起来很平平无奇, 其目的也十分显而易见(交换两个对象中的所有成员), 但实际上, 上面这个写法里也是有一些门道的, 限于篇幅关系, 这里不会掰开揉碎细细讲, 你最好仔细琢磨一下这个swap的写法, 比如:
- 为什么它非要写成
friend void swap, 而不是写成一个普通函数 - 里面那句
using std::swap有什么玄机? 想一想, 如果dumb_array的成员变量不是基础类型, 而是一个类类型, 并且这个类类型也完整的实现了rule of three and a half, 会发生什么?
总之, 现在先不关心swap实现上的一些细节, 仅仅只需要关注它的功能即可: 它是一个函数, 它能完成两个dumb_array对象的交换, 而所谓的交换, 是交换两个对象的成员的值.
在此基础上, 我们的=操作符重载可以实现成下面这样:
1 | dumb_array& operator=(dumb_array other) // (1) |
是的, 就这是么简洁. 你没有看错, 就是这么有魔力! 那么, 为什么说它规避了我们先前提到的三个缺陷呢? 它又是如何规避的呢?
首先再回顾一下, 我们实现=操作符重载的逻辑思路:
- 在
fuck = shit的内部, 我们先将shit拷贝一份, 称其为shit2好了 - 然后使用一个
swap函数, 将fuck与shit2进行交换: 即交换两个对象的所有成员变量的值. 这样就达到了”把shit的值赋给fuck“的目的 - 第三步, 在
=操作符实现的内部退栈的时候,shit2会自动由于退栈而被析构.
整个过程没有风险, 没有异常, 很是流畅.
这里有几个点也需要额外说明一下:
首先, 这个
=操作符重载的实现, 其参数是值类型, 而不是const dumb_array & other. 这里面是有门道的, 如果采用引用类型, 如下所示:1
2
3
4
5
6
7dumb_array& operator=(const dumb_array& other)
{
dumb_array temp(other);
swap(*this, temp);
return *this;
}从功能上看, 和先前的值引用版本没什么区别. 但内在上, 你实质上放弃了一个”让编译器自动优化代码”的契机. 这个细节展开来说也比较复杂, 具体缘由在这里 有详细解释, 但总结起来就是: 在C++中, 普通的拷贝操作(调用拷贝构造函数), 比起在函数传参时, 编译器在背后执行的拷贝操作(虽然从表面看它也是在调用拷贝构造函数), 效率要低, 并且还低得多!
使用值传递来自动使编译器在背后调用
拷贝构造函数(实质上编译器会做一些优化, 但你可以这样理解), 保证了只要执行流程进入到了=操作符的内部, 数据拷贝就已经完成了. 这暗地里还复用了拷贝构造函数的代码. 所以, 代码重复的问题解决了.并且由于这样的写法, 只要函数调用这个动作被成功发起了, 就代表着数据拷贝已经成功: 这意味着拷贝过程中发生的内存分配等其它高危操作已经完成, 如果有异常, 应当在函数调用之前被扔出来, 而一旦代码执行进调用内部, 就不可能再抛异常了. 这解决了异常安全的问题
我们也规避了用以检查
=左右两边是否为同一个对象的逻辑. 虽然如果这种情况发生, 这种写法会导致一次额外的数据拷贝与析构, 但这也是可以接受的, 毕竟, 如果出现了这种情况, 你应当反思的是为什么出现了自己 = 自己这种奇怪的逻辑, 而不是去苛责自己 = 自己执行的不够快.
至此, 就是copy-and-swap套路的所有内容.
那么, 在C++11中, 事情发生了任何变化了吗? 我们在rule of three/five这一小节说过, 由于C++11引入了右值引用和移动语义, 所以three变five: 你要新增一个移动构造函数, 与右值引用版的=操作符重载. 但实质上, 使用copy-and-swap套路的话, 你并不需要为=操作符再写一个右值引用版本的重载, 你只需要像下面这样, 添加一个移动构造函数就可以了:
1 | class dumb_array |
关于为什么不需要再写一个右值引用版的=操作符重载, 这个, 你可以先了解一下下一节的内容: 移动语义后, 再来看这里. 总之, 就是, 使用copy-and-swap套路, 在C++11中, 可以将所谓的rule of five变成rule of four and a half, 分别是:
1 | 1. 析构函数 |
三、移动语义
要理解移动语义, 其实用代码说话更容易让人理解. 我们就从下面的代码片断开始: 这是一个非常简单简陋的string的实现(注意它不是标准库中的std::string, 这里仅是我们自己实现的一个非常简陋的字符串类), 它内部使用一个指针成员持有着数据:
1 |
|
由于我们在这个简陋的实现里选择使用指针来管理数据, 即是作为类的设计者, 我们需要手动管理具体数据占用的内存的分配与释放, 所以按C++03标准的最佳实践, 我们应当遵循rule of three. 即是: 析构函数, 拷贝构造函数, =操作符的重载三者必须同时在场. 我们先在这里把析构函数和拷贝构造函数补上, 关于=的重载, 后面一点再谈
1 | ~string() |
拷贝构造函数实现了拷贝的语义, 参数const string & that是const引用, 这代表着它可以指向C++03标准中的右值, 即是一个表达式的值的最终类型是为上面这个简陋的string, 都可以作为拷贝构造函数的参数使用. 所以, 在假定我们还实现了类似于标准库中std::string对+的重载的话, 我们可以以如下三种方式调用拷贝构造函数:
1 | ... |
现在就到了理解移动语义的关键点:
注意在第一行, 我们使用x作为参数去调用拷贝构造函数初始化a, 拷贝构造函数内部实现了深拷贝: 即完整的把x底层持有的数据拷贝了一份. 这没有任何毛病, 因为这就是我们想要的, 完成初始化a之后, a和x分别持有两份数据, 后续再对x做一些数据修改的操作, 不会影响到a, 反之亦然. x显然也是C++03标准中的左值.
而第二行和第三行的参数, 无论是x + y还是some_function_returning_a_string(), 显然都不能算是C++03中的左值, 显然它们都是右值. 因为这两个表达式的运算结果虽然确实是一个string的实例, 但没有一个变量名去持有这些实例, 这些实例都是临时性的实例: 阅后即焚. 即在这个表达式之后, 你没有任何办法再去访问先前表达式指代的那个string实例. 按照C++03的规范, 这种临时量占用的内存在下一个分号之后就可以被扔掉了(更精确一点的说: 在整个包含着这个右值的表达式单元执行完毕之后. 再精确一点: 编译器的实现是不确定的, 你应当假定在表达式执行完毕后这个对象就被析构了, 但编译器多数情况下只会在遇到下个}的时候才析构这种临时对象).
这就给了我们一个灵感: 既然在下个分号之后, 再也无法访问x + y与some_function_returning_a_string()这两个表达式指向的临时string对象, 意味着我们可以在下个分号之前(换句话说, 在初始化b和c的过程中: 在拷贝构造函数中), 随意蹂躏这两个临时量! 反正蹂躏完了也不会产生任何额外副作用.
基于这种思路, C++11标准中引入了一种新的机制叫右值引用, 右值引用一般用于函数重载(的参数列表)中, 它的目的是探测调用者传入的参数是否是C++03中的临时量. 一旦探测到调用者传入的是一个临时量的话, 重载调用机制就会匹配到有右值引用参数的重载中. 在这种函数内部, 你通过右值引用可以去访问这个临时量, 并在内部随意蹂躏这个临时量.
说起来有一点绕, 我们直接使用右值引用这个机制去写一个拷贝构造函数的重载, 如下所示:
1 | string(string&& that) // string&& is an rvalue reference to a string |
在向string的内部添加了这个拷贝构造函数后, string类内部目前就有了两个拷贝构造函数: string(const string& that)与string(string&& that). 我们再回到上面的a, b, c三个初始化语句上. 这时, 由于x是一个左值, 所以a的初始化会匹配至string(const string& that). 而由于x + y与some_function_returning_a_string()是两个显然的临时量右值, 所以对于b和c的初始化, 就会匹配到string(string&& that).
那么string(string&& that)内部到底做了什么事情呢? 看上面的代码就很显然, 它并没有像string(const string& that)那样去真正的拷贝一份数据, 而仅仅是把临时量内部持有的数据偷了过来, 用读书人的说法, 就叫移动.
这里需要注意, 在string(string&& that)执行结束之后, 临时量x + y与some_function_returning_a_string()还是会和C++03一样, 阅后即焚. 这两个临时对象依然会被析构. 临时量始终都是临时量, 从C++03到C++11, 这个行为没有变化. 只不过, 在析构之前, 我们已经通过string(string&& that)把它内部的数据偷掉了! 真正这两个临时量被析构的时候, 执行的只不过是delete nullptr罢了.
恭喜你, 到目前为止, 理解了C++11中移动语义的基本概念.
现在, 在进一步讨论之前, 让我们先把string类的=操作符重载再补上. 根据C++03的最佳实践之copy and swap idiom, 一个行为正确异常安全的=操作符重载应当被实现成下面这样:
1 | string& operator=(string that) |
看到上面这个代码你是不是准备问我, “右值引用哪去了? “. 我的回答是: “这里并不需要右值引用”, 至于为什么, 我们再来看下面三行代码:
1 | // x, y, a, b, c 均是string类型的变量 |
我们先来分析第四行(Line 4).
- 由于
string& operator=(string that)是值类型参数, 所以在调用发生时, 参数的传递会使用x先去初始化that, 你可以理解为string that(x)这种. 由于x是一个左值. 所以that的初始化使用的是string(const string& that)这个构造函数: 即that是x的一个完整副本, 深度拷贝了x的数据 - 在执行
std::swap(data, that.data)的过程中,a持有的数据与that持有的数据相互交换. 至此,a持有的数据其实就是x数据的一个完整副本. - 在
return *this执行之后,that由于函数退栈, 被析构.that中持有的数据(其实是原a持有的数据)被析构函数安全释放
总结起来: a = x内部, 将x的数据完整的复制了一份给a, 再把a原持有的数据安全析构掉了.
我们再来分析第五行(Line 5)
- 由于
string& operator=(string that)是值类型参数, 所以在调用发生时, 参数的传递会使用x + y先去初始化that, 你可以理解为string that(x)这种. 由于x + y是一个临时量右值, 所以that的初始化使用的是string(string&& that)这个构造函数, 在这个构造函数内部,that偷掉了x + y内部持有的数据, 并没有发生数据拷贝. - 在执行
std::swap(data, that.data)的过程中,b持有的数据与that持有的数据相互交换. 至此,x + y原持有的数据经过二次转手, 来到了b的手上. 而b原持有的数据, 则交换给了that - 在
return *this执行之后,that由于函数退栈, 被析构.that中持有的数据(其实是原b持有的数据)被析构函数安全释放
总结起来: b = x + y内部, 经过两次转手, 将x + y持有的数据转交给了b, 而b原持有的数据被完全的析构掉了.
第六行和第五行类似.
至此, 你可算是基本明白了C++11中的移动语义. 现在, 请回头再看copy-and-swap小节的末尾, 你就会明白, 为什么`copy-and-swap + rule of three + C++11 == rule of four and a half了。
cpp11-移动语义详解

