移动语义和右值引用
特性说明
C++11中最为重要的特性就是移动语义和右值引用。这两者带来的革命性变化,使得其成为大家选择C++11的理由,以及提升代码效率的必备之法。
左值和右值
C++中所有的表达式和值,要么是左值,要么是右值。通俗的来说,左值指可以使用&
取得其地址的“非临时对象”,而右值则是指不可用&
取得其地址的“临时对象”。
int a = 0;
在上面这个例子中,a
可以使用&a
获得其地址,a
是一个左值。而0
不可以使用&0
,0
是一个右值。经过运算得到的临时对象也是一个右值,例如:
std::string s { "Hello, pangda" };
auto ns = s + "!\n";
在这里, s + "!\n"
就是一个std::string
类型的临时对象,于是它也是一个右值。
在之前版本的C++中,早已有了“左值引用”的概念,左值引用只可以使用“可取地址的对象”来赋值,即用左值赋值。
而在C++11中,我们引入了新的“右值引用”的概念。类比于左值引用,右值引用也仅可以使用右值赋值。我们用T &&
来表示T
类型的右值引用,例如:
int &&rval1 = 1; // 正确,1是右值,可以赋值给右值引用
int a = 1;
int &&rval2 = a; // 错误,a是左值,不可以赋值给右值引用
当然,同一类型的右值引用和左值引用是完全不同的两种类型。所以,下面的重载形式是合法的:
void f(int &a) {
std::cout << "Left Version" << std::endl;
}
void f(int &&a) {
f(a); // 这里a是一个左值,使用左值版本
std::cout << "Right Version" << std::endl;
}
int main(int argc, char *argv[]) {
int a = 0;
f(a); // 左值版本
f(a + 1); // 注意:临时对象也是右值,所以这里是右值版本
}
正如上面注释所说的,在右值引用版本的f
函数中,调用f(a)
将采用左值版本的f
函数。这很容易就能想明白,这是由于在这里a已经成为一个int &&
类型的左值,我们可以使用&a
获取它的地址。
移动语义
移动语义依赖于右值引用。移动语义可以理解为“放弃持有权而转移给其他对象”。对于一个对象来说,它持有的各种资源(堆上资源、系统对象等等),可以通过移动语义,赋予另一个对象。
移动语义是相对于拷贝语义的。在引入移动语义之前,只有拷贝语义,于是在这个例子当中:
std::string a { "hello, world" };
std::string b = a;
字符串a
和b
的内容均为hello, world
,并且在赋值之后,a
和b
都是有效且相互独立的。在这里,operator =
就是“拷贝语义”,它完全复制了字符串a
中的各个部分。
通常情况下,拷贝语义已经足够了。不过,我们举一个稍显极端的例子:若之前的a
字符串的长度足够长(比如:10^10),而a
字符串在此之后不再使用,那么将a
字符串复制一份就将是一个极大而无意义的消耗。
当然,上面的例子中可以使用swap
来减小代价,像这样:
std::string a { "hello, world" };
std::string b;
b.swap(a);
但,若是函数传参的情况:
void f(std::string v);
对于函数f
,将没有任何手段防止对a
的复制。虽然我们可以考虑将参数改为常量引用const std::string &
,但这可能会限制函数f
的实现。
这时,新引入的移动语义显得极有意义,正如上面我们讨论到的,一般而言,右值均是“临时对象”,临时对象在完成其使命之后就会立即被析构,既然被析构,那么给他分配的资源也将无意义。那么为何不把给他分配的资源直接“转移”给更恒久的对象呢?
比如,既然我们可以肯定字符串a
传参给函数f
之后就不再使用,那么为何不直接将字符串a
中所有成员直接赋值给函数的参数v
呢?这样我们就不必再次分配空间,也不必再次拷贝这些内容。
当然,像之前例子的移动语义版本:
std::string a { "hello, world" };
std::string b = std::move(a);
不同于拷贝语义,此时a
中的内容将不再有效,我们只能肯定b
中一定为Hello, world
。
实现拷贝语义,我们已经很熟悉了,需要定义拷贝构造函数和拷贝赋值运算符。那么,为了实现移动语义,我们也要实现移动构造函数和移动赋值运算符。若未实现移动构造函数和移动赋值运算符而使用移动语义,那么C++调用拷贝构造函数和拷贝赋值运算符——也就是会使用拷贝语义。像这样:
class ClassName {
// 移动构造函数的原型
ClassName(ClassName&& str);
// 移动赋值运算符的原型
ClassName& operator=(ClassName&& str);
};
当然,不只是类,普通的函数和运算符也可以利用右值引用运算符实现移动语义。
std::move
概念
使用移动语义之前,我们有必要了解std::move
这个标准库函数。这是一个模板函数,作用是将参数强制转换为右值。这样配合移动构造函数和移动赋值运算符,我们就可以实现移动语义。如之前的例子:
std::string a { "hello, world" };
std::string b = std::move(a);
在这里,我们将a
转换为了右值,并通过移动赋值运算符进行了移动语义的赋值操作。
没有真的移动
与函数名字不同的是,std::move
函数并不真的“移动”对象。例如:
std::string a { "Move Or Not" };
std::move(a);
执行std::move
并没有发生任何移动,上面的代码段执行完毕之后,a
中的内容不会有任何变化。std::move
的功能仅仅是强制类型转换的缩写形式,也就是说,如果我将之前的例子改写为这样:
std::string a { "hello, world" };
std::string b = static_cast<std::string &&>(a);
除了形式更加繁琐之外,仍然正确地执行了移动语义的赋值操作。也就是说,真正的“移动”是通过移动赋值运算符和移动构造函数进行的,与std::move
并没有关系,他只是在“显式地声明放弃控制权”。
使用移动语义提升性能
标准库部分
若你使用了C++11以及更后的版本,整个标准库已经完全地升级以支持移动语义,若你想要转交标准库容器的控制权,可以直接使用std::move
。比如:
std::vector<int> deal_something(std::vector<int> numbers) {
// do something...
return std::move(numbers);
}
int main(int argc, char *argv[]) {
std::vector<int> v;
auto ret = deal_something(std::move(numbers));
}
使用这个代码,将减少vector
的拷贝。
自实现部分
正如我们之前所说的,移动语义并不是凭空出现的,若你不手动地声明移动构造函数和移动赋值操作符,那么C++将会使用拷贝语义的版本。所以,若你自己实现的类也想使用移动语义,那么你需要手动实现这两个函数。
什么时候用?
到这里我们也发现了,移动语义本身并不直接具有提升性能的作用。在这之中仍然有临时对象,移动语义并不负责将对象从一个地方移动到另一个地方,而只是以更小的代价创建一个新的对象。
因此,若不能提升性能,满屏幕的std::move
反而成为一种心智负担,错误的使用甚至会创造出更多的临时对象。因此,我们应当在正确的情况下使用。
我们可以分析出,首先,POD对象不适合使用移动。因为无论如何,POD对象中的成员仍需要被复制;其次,资源管理对象应当配置移动语义。一般而言这些类都会被声明为“不可复制”的,配置移动语义可以使得操作这个类更加方便;第三,需要大量的额外资源的对象可以配置移动语义,这样将提供减少不必要赋值的机会。
上面的情况其实也可以总结为,“移动”当且仅当比“拷贝”更迅速时才应当使用,若两者时间相差不大甚至“拷贝”更迅速时,采用“拷贝”是更好的选择。