现代 C++ 性能飞跃之:移动语义
Posted ENG八戒
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了现代 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八戒】了解后续更新,关注后甚至可以参与赠书活动!
c++ 移动语义及使用(续)
接上文~
5 如果使用移动语义
前边我们讲了好多为什么使用移动语义,以及一些使用的例子,这里我们再具体展开说下。讲之前我还是要啰嗦下移动引用和move,右值引用的重要目的就是用来实现移动语义,move方法的目的是将左值强制转为右值,正好方便应用移动语义。
所以可以简单的生成一下方法论,如果我们打算移动一个资源到另一个变量时,如果原有资源是是右值,我们可以直接使用右值引用接收即可,但是如果原有资源是左值,我们可能就需要用到move转换成右值。我们再从例子中深化下:
5.1 一些会搞错的点
我们明确一点,调用函数接收返回值时,c++11之前通过拷贝到接收对象,c++11之后通过移动到接收对象
Obj getObj(int v) {
if (v < 1) {
return Obj();
}
else {
Obj o;
o.ptr_[0] = 10;
return o;
}
}
Obj o1 = getObj();
还是这个例子,我们学完了移动语义,来看下,提三个问题
函数声明Obj要不要做下改动,Obj&& getObj(int),或者const Obj& getObj(int),再或者Obj& getObj(int) else的那个分支要不要用move,即return std::move(o); 调用getObj时,要不要Obj&& o = getObj(),或者const Obj& o = getObj(),再或者Obj& o = getObj();
是不是看完有点懵,是不是感觉自己好像没学过呢,哈哈。看到这里,大家可以直接写代码试试看,解答三个问题之前,先要明确的两点:
Obj o1;
Obj&& o2 = getObj(); // 1
Obj o3 = std::move(o1); // 2
看上边代码,仅对这个“=”操作来说,1会不会执行移动构造函数,不会对吧,因为o2是getObj的引用,相同指向,不涉及新对象创建。我们可以类比左值引用。2会不会执行移动构造函数,会的,因为o3是一个新的需要创建的对象,这一点很对同学会搞懵掉,需要注意。
然后我们来解答问题:
-
首先看第一个:a).如果返回值是Obj&,if分支编译不过,因为左值引用不能指向右值, 即使编译过也千万注意,返回的对象不是栈上的,因为函数执行完后会释放掉,接收这块资源的对象指向地址是无效的,当然你如果返回的不是栈上的变量倒是可以,生命周期一直存在的。b).如果是Obj&&,else分支编译不过,不过o可以使用move转成右值。不过同样是返回栈上的对象,接收的时候也会被释放掉 c).同a。所以abc三种写法一般情况下不会这么写,除非声明周期一直存在,而不是返回栈上变量。 -
然后看第二个,返回值我们要不要用move, 答案是没必要,使用std::move对于移动行为没有帮助,有时反而会影响返回值优化。
Obj getObj(int v) {
Obj o;
o.ptr_[0] = 10;
return std::move(o);
}
Obj oo = getObj(1);
我们看这个例子,各个函数执行情况是:
我们把return std::move(o);改成return o;结果是这样的:
结果我写了move反而会影响效率,真是头疼。哈哈~
-
继续看第三个,Obj& o = getObj()这个语法错误,或者const Obj& o = getObj()和Obj&& o = getObj()这两个对效率来讲少一次移动,这样写针对返回的prvalue来说是延长了生命周期,和接收对象保持一样的生命周期。有时候编译器也会做优化即使不这么写也没有移动,不过这样写确实效率还是提高了。
5.2 我们怎样使用移动语义
-
我们上边的那个例子其实就是,因为我们要返回临时对象, 如果这个对象是stl的,因为几乎所有stl的类都有移动构造函数,但是如果是我们自定义的类,我们就需要加一个移动构造函数了。 -
参数使用右值引用使用移动语义,但是一般我们需要写重载函数也要来接收左值的。比如说我们上边的那个push_back函数。还有一些我们使用stl或者其他库,我们也尽量使用那个右值引用参数那个函数,这样如果我们是个左值,就需要用到move了。 -
还有就是用右值引用接收函数返回值。
我目前能想到大概也是这样几种场景,欢迎大家补充
5.3 自己写一个可以移动的类
我们上边也说了如果要一个类可以移动,那么我们要实现他的移动构造函数,但是还是有一些注意点。C++中五种特殊的称为拷贝控制成员的成员函数来控制对象拷贝,移动,赋值和销毁。分别是拷贝构造函数,拷贝赋值符,移动构造函数,移动赋值符和析构函数。以及比较著名的三五法则,大家也可以去看下,这里不是我们的重点,我们这里只讨论和移动相关的。
-
如果我们拷贝控制成员函数 均不写,编译器为我们实现默认版本。 -
如果我们有析构函数或者拷贝构造函数或者拷贝赋值符,那么编译器不会为我们生成移动构造函数及移动赋值符。(只有没有定义任何一个拷贝控制成员且成员可以移动,编译器才会生成默认移动构造函数和移动赋值符)
class Obj {
public:
Obj() : ptr_(new int[10]{0}) {
std::cout << "Obj()" << std::endl;
}
~Obj() {
std::cout << "~Obj()" << std::endl;
if (ptr_ != nullptr) {
delete []ptr_;
}
}
int* ptr_;
};
如果只是这么写,那么在Obj o = getObj(10);函数执行析构时会报错。因为如果没有移动构造函数,在执行getObj时会使用拷贝构造函数,而默认的拷贝构造函数是一个浅拷贝,对象析构时两个对象的指针指向相同资源,被析构两次执行报错。
-
如果写了移动构造函数或者移动赋值符,编译器也不会生成拷贝构造函数,拷贝赋值符及析构函数。
所以给予这些原则,我们一般来说,拷贝控制成员看成一个整体,写一个就得写全部,不过也不是必须的,但是你的头脑里也要过一下这些原则。
-
比如说你只进行拷贝操作或者拷贝和移动实现基本一致,那可以只写拷贝相关的。 -
或者说你只写拷贝构造函数和移动构造函数,而不想写=操作符,这样也就不会去用赋值操作嘛
等等一些情况吧
我们这里来看一个完整的例子,还是以obj为例:
class Obj {
public:
Obj() : ptr_(new int[10]{0}) {}
~Obj() {
if (ptr_ != nullptr) {
delete []ptr_;
}
}
Obj(const Obj& o1) {
ptr_ = new int[10]{0};
for (int i= 0; i < 10; ++i) {
ptr_[i] = o1.ptr_[i];
}
}
Obj(Obj&& o1) noexcept {
ptr_ = o1.ptr_;
o1.ptr_ = nullptr;
}
Obj& operator=(Obj o) noexcept {
o.swap(*this);
return *this;
}
void swap(Obj& o) noexcept {
using std::swap;
swap(ptr_, o.ptr_);
}
int* ptr_;
};
void swap(Obj& lhs, Obj& rhs) noexcept
{
lhs.swap(rhs);
}
我们看下那些地方有改动,首先移动构造函数多了一个noexcept表示不抛出异常,这里是告诉编译器这个函数不会抛出异常,大家写移动构造函数最好也不要抛出异常,因为本来只是一个资源的转移,实在是没有理由抛出异常。然后我们只写了一个“=”运算符,也不抛出异常,使用swap可以做到,一个成员函数swap用来自己使用,一个全局swap函数,用来给其他拥有这个Obj对象的类的赋值时使用。
引用
-
深入应用c++11 -
c++ primer -
C++0x漫谈》系列之:右值引用 -
https://github.com/adah1972/geek_time_cpp
以上是关于现代 C++ 性能飞跃之:移动语义的主要内容,如果未能解决你的问题,请参考以下文章