现代 C++ 性能飞跃之:移动语义
*以下内容为本人的学习笔记,如需要转载,请声明原文链接 微信公众号「ENG八戒」https://mp.weixin.qq.com/s/Xd_FwT8E8Yx9Vnb64h6C8w
带给现代 C++ 性能飞跃的特性很多,今天一边聊技术,一边送福利!
过去写 C/C++ 代码,大家对数据做传递时,都习惯先拷贝再赋值。比如,把数据从 t1 复制到 t2,复制完成后 t2 和 t1 的状态是一致的,t1 状态没变。这里的状态指的是对象内部的非静态成员数据集合。
在程序运行过程中,复制过程既要分配空间又要拷贝内容,对于空间和时间都是种损耗。复制操作,无疑是一门很大的开销,何况经常触发资源复制的时候。
来看看普通的函数返回值到底有哪些开销,
std::string getString()
{
std::string s;
// ...
return s;
}
int main()
{
std::string str = getString();
// ...
}
假设你的编译器还不支持 C++ 11,那么,在 main() 函数里调用 getString() 时,需要在调用栈里分配临时对象用于复制 getString() 的返回值 s,复制完成调用 s 的析构函数释放对象。然后,再调用 std::string 类的复制赋值运算符函数将临时对象复制到 str,同时调用临时对象的析构函数执行释放。
那么,有没有技巧可以实现上面示例代码同样的效果,同时避免复制?
有的,就是接下来重点介绍的移动(和中国移动无关)。
相对于复制,移动无须重新分配空间和拷贝内容,只需把源对象的数据重新分配给目标对象即可。移动后目标对象状态与移动前的源对象状态一致,但是移动后源对象状态被清空。
实际上,大部份的情况下,数据仅仅需要移动即可,拷贝复制显得多余。就像,你从图书馆借书,把自己手机的 SIM 卡拔出来再插到其它手机上,去商店买东西你的钱从口袋移动到收银柜等等。
那么,是不是可以对所有的数据都执行移动?
答案是否定的。在现代 C++ 中,只有右值可以被移动。
左右值概念
在 C++ 11 之前,左右值的划分比较简单,只有左值和右值两种。
但是从 C++ 11 开始,重新把值类别划分成了五种,左值(lvalue, left value),将亡值(xvalue, expiring value),纯右值(prvalue, pure right value),泛左值(glvalue, generalized left value),右值(rvalue, right value)。不过后边的两种 glvalue 和 rvalue 是基于前面的三种组合而成。从集合概念来看,glvalue 包含 lvalue 和 xvalue,rvalue 包含 xvalue 和 prvalue。
左右值划分的依据是:具名和可被移动。
具名,简单点理解就是寻址。可被移动,允许对量的内部资源移动到其它位置,并且保持量自身是有效的,但是状态不确定。
- lvalue:具名且不可移动
- xvalue:具名且可移动
- prvalue:不具名且可移动
那么,可以看到泛左值(glvalue)其实就是具名的量,右值就是可移动的量。
以往在往函数传参的时候,经常有用到值引用的模式,形式如下:
function(T& obj)
T 是类型,obj 是参数。
到了现代 C++,原来的值引用就变成了左值引用,另外还出现了右值引用,形式如下:
function(T&& obj)
那么 C++ 11 是怎样实现移动操作的呢?
实现移动操作
移动操作依赖于类内部特殊成员函数的执行,但前提是该对象是可移动的。如果恰好对象是左值(lvalue)呢?
C++ 11 的标准库就提供了 std::move() 实现左右值转换操作。std::move() 用于将表达式从 lvalue(左值) 转换成 xvalue(将亡值),但不会对数值执行移动。当然,使用强制类型转换也是可以达到同样目的。
std::move(obj); // 等价于 static_cast<T&&>(obj);
在 stack overflow 上看到对 std::move() 的一段描述,与其说它是一个函数,不如说,它是编译器对表达式值评估的方式转换器。
以往惯常使用 C++ 类定义时,我们都知道有这么几个特殊的成员函数:
- 默认构造函数(default constructor)
- 复制构造函数(copy constructor)
- 复制赋值运算符函数(copy assignment operator)
- 析构函数(destructor)
来看看一个简单的例子:
class MB // MemoryBlock
{
public:
// 为下面代码演示简单起见
// 在 public 定义成员属性
size_t size;
char *buf;
// 默认构造函数
explicit MB(int sz = 1024)
: size(sz), buf(new char[sz]) {}
// 析构函数
~MB() {
if (buf != nullptr) {
delete[] buf;
}
}
// 复制构造函数
MB(const MB& obj)
: size(obj.size),
buf(new char[obj.size]) {
memcpy(buf, obj.buf, size);
}
// 复制赋值运算符函数
MB& operator=(const MB& obj) {
if (this != &obj) {
if (buf != nullptr) {
delete[] buf;
}
size = obj.size;
buf = new char[size];
memcpy(buf, obj.buf, size);
}
return *this;
}
}
为了支持移动操作,从 C++ 11 开始,类定义里新增了两个特殊成员函数:
- 移动构造函数(move constructor)
- 移动赋值运算符函数(move assignment operator)
移动构造函数
在构造新对象时,如果传入的参数是右值引用对象,就会调用移动构造函数创建对象。如果没有自定义移动构造函数,那么编译器就会自动生成,默认实现是遍历调用成员属性的移动构造函数,并移动右值对象的成员属性数据到新对象。
定义一般声明形式如下:
T::T(C&& other);
基于上面的简单例子:
class MB // MemoryBlock
{
public:
// ...
// 移动构造函数
MB(MB&& obj)
: size(0), buf(nullptr) {
// 移动源对象数据到新对象
size = obj.size;
buf = obj.buf;
// 清空源对象状态
// 避免析构函数多次释放资源
obj.size = 0;
obj.buf = nullptr;
}
}
可见,移动构造函数的执行过程,仅仅是简单赋值的过程,不涉及拷贝资源的耗时操作,自然执行效率大大提高。
移动赋值运算符函数
在调用赋值运算符时,如果右边传入的参数是右值引用对象,就会调用移动赋值运算符函数。同样,如果没有自定义移动赋值运算符函数,那么编译器也会自动生成,默认实现是遍历调用成员属性的移动赋值运算符函数并移动成员属性的数据到左边参数对象。
一般声明形式如下:
T& T::operator=(C&& other);
基于上面的简单例子:
class MB // MemoryBlock
{
public:
// ...
// 移动赋值运算符函数
MB& MB::operator=(MB&& obj) {
if (this != &obj) {
if (buf != nullptr) {
delete[] buf;
}
// 移动源对象数据到新对象
size = obj.size;
buf = obj.buf;
// 清空源对象状态
// 避免析构函数多次释放资源
obj.size = 0;
obj.buf = nullptr;
}
return *this;
}
}
移动赋值运算符函数的执行过程,同样仅仅是简单赋值的过程,执行效率明显远超复制操作。
总结
回顾文首的示例代码,由于 C++ 11 加入了返回值优化 RVO(Return Value Optimization) 的特性,所以代码无需变更即可获得效率提升。对于部分编译器而言,比如 IBM Compiler、Visual C++ 2010 等,已经提前具备返回值优化的支持。
对于 RVO 的内容,暂不展开讨论,有兴趣的同学可以关注公众号【ENG八戒】了解后续更新,关注后甚至可以参与赠书活动!