简介
作为 C++11 支持的新特性,移动语义允许我们以一种更轻量级的(相较于拷贝)形式实现对象资源的复用。这里需要注意的是,移动语义移动的是对象所持有的资源,而不是对象本身。
C++ 值的类型
在 C++ 中值的类型可以被分为三种:
- 左值:has identity,but can’t be moved from。包括:
- 变量、函数或者数据成员的名字;
- 返回左值引用的表达式,比如 ++x、x = 1;
- 字符串字面量,如 “hello world”
- 纯右值:no identity,but can be moved from。包括:
- 返回非引用类型的表达式,比如 x++、x + 1;
- 除字符串字面量之外的字面量,比如 42、true;
- this 指针也是纯右值
- 将亡值:has identity, can be moved from。包括:
- 右值引用类型的返回值,比如 std::move(x)
右值引用
为了支持移动语义,C++11 引入了右值引用类型,因此 C++ 中就有了三种引用类型:
- 右值引用:只能绑定到右值上,比如
int &&
; - 非 const 的左值引用: 只能绑定到左值上,比如
int &
; - const 的左值引用: 可以绑定到左值或右值上,比如
const int &
移动构造和移动赋值成员函数
如何定义一个移动构造或移动赋值成员函数
作为一个移动构造或移动赋值成员函数,要满足以下要求:
- 完成资源移动。资源的所有权移交给新创建的对象或赋值对象;
- 确保移动操作完成后,销毁源对象是无害的。也就是说源对象不再指向被移动的资源,比如成员指针被设置为
nullptr
; - 确保移动操作完成后,源对象依然是有效的。也就是说,源对象可以被赋予一个新值,再次持有新的资源。
- 声明为
noexcept
, 这是因为标准库中的一些容器接口需要作出不会抛出异常的保证。比如说vector
中的push_back
接口:
If an exception is thrown (which can be due to Allocator::allocate() or element copy/move constructor/assignment), this function has no effect (strong exception guarantee).
这里给出一个移动赋值成员函数的通用实现。它采用拷贝并交换
, 它会根据右侧对象是左值还是右值,调用拷贝构造或者移动构造去初始化形参rhs
,从而同时实现拷贝赋值和移动赋值语义。
ClassA& ClassA::operator=(ClassA rhs)
{
swap(*this, rhs);
return *this;
}
移动和拷贝成员函数的匹配原则
考虑下面示例:
Widgt wt1;
Widgt wt2 = std::move(wt1);
如果定义了移动构造函数,那么编译器会优先选择移动构造函数。如果没有定义移动构造函数,参数为const的左值引用的拷贝构造函数就会被匹配。
移动赋值亦是如此。
何时定义移动构造/赋值
这里先列出编译器合成这些函数的规则:
- 规则1: 如果某个类有自定义拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了。根据函数匹配规则,这种情况下会调用拷贝操作来处理右值
- 规则2: 如果某个类定义了移动构造函数,没有定义拷贝构造函数,那么后者被编译器定义为删除的(对于赋值运算符也是一样的)。
一般情况下,建议如果定义了其中的一个,最好都定义了,理由如下:
- 如果你析构函数里要做事,不管是释放内存资源、锁还是关闭数据库连接,那么你就应该把析构函数的这些好兄弟都定义出来;
- 如果定义了这些操作中的某一个,就应该把其他的操作都定义出来,以避免所有(潜在的)可移动的场景都变成昂贵的拷贝(对应规则 1)或者使得类型变成仅能移动的(对应规则 2)。
std::move和std::forward
首先,简要说明一下这两个函数的作用:
- std::move 无条件地将实参转换为右值;
- std::forward 将左值、左值引用转换为左值,将右值、右值引用转换为右值
std::move 的实现
template <typename T> typename remove_reference<T>::type &&move(T &¶m) {
using ReturnType = typename remove_reference<T>::type &&;
return static_cast<ReturnType>(param);
}
这里 T&& 是通用引用,因此这个函数几乎可以接收任何类型的参数。
通过 remove_reference 去掉 T 的引用性质(并不会去掉 cv 限定符,即const和volatile),然后给它加上 &&,形成 ReturnType 类型,由于右值引用类型的返回值是右值,因此结果是实参被无条件地转换为右值。
所以,需要注意:使用 std::move 并不代表移动操作一定会发生。具体原因如下:
- 可能这个类型根本没有定义移动操作;
- std::move 并不会去除实参的 const 性质,因此把 const 的对象传给它,得到的返回值类型也是 const 的,对它的操作会变为拷贝操作
std::forward 和完美转发
先看std::forward的实现:
template< class T >
T&& forward( typename std::remove_reference<T>::type& t ) noexcept {
return static_cast<T&&>(param);
}
template< class T >
T&& forward( typename std::remove_reference<T>::type&& t ) noexcept {
return static_cast<T&&>(param);
}
它可以将一个左值、左值引用转换为左值,将一个右值、右值引用转换为右值。
某些函数需要将其一个或多个实参连同类型不变地转发给其他函数,转发后需要保持被转发实参的所有性质,包括
- 实参是否是 const 的;
- 实参是左值还是右值
这种场景我们往往称之为完美转发,C++11 可以通过 std::forward 来实现。比如make_unique
的实现代码:
template <typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts &&... params) {
return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}
移动语义和返回值优化
匿名返回值优化
如果 return 语句的表达式是纯右值,且它和返回值的类型相同。此时,编译器可以实施 copy elision(拷贝省略、拷贝消除),将对象直接构造到调用者的栈上去。具体示例如下:
T f() {
return T();
}
f(); // only one call to default constructor of T
命名返回值优化NRVO
先看如下代码:
X bar()
{
X xx;
// ...
return xx;
}
对于上面的函数 bar,编译器会直接用参数 __result 代替命名的返回值 xx,即改写为:
void
bar( X &__result )
{
__result.X::X();
// ...
return;
}
也就是说返回值会被直接构造在调用者的栈上,少了一次拷贝操作,这种优化被称为 Named Return Value Optimization(NRVO)。
移动语义和NRVO
C++11 开始,NRVO 仍可以发生,但在没有 NRVO 的情况下,编译器将试图把本地对象移动出去,而不是拷贝出去。
这一移动行为不需要程序员手工用 std::move 进行干预,使用 std::move 对于移动行为没有帮助,反而会影响返回值优化,因为相对于 std::move,返回值优化可以减少一次移动构造函数的调用。
参考
[1] C++ Primer, 第五版
[2] C++ Core Guidelines
[3] 移动语义和完美转发浅析
[4] 在纷繁多变的世界里茁壮成长:C++ 2006–2020