C++:右值引用,移动构造,完美转发及返回值优化
本文采用知识共享署名 4.0 国际许可协议进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,可适当缩放并在引用处附上图片所在的文章链接。
C++的左值与右值
在 C 语言中,左值和右值即字面意思,左值是表达式左边的值,而右值是表达式右边的值。而 C++ 为了支撑移动语义,对值的类型做了新的划分。
区分左值和右值
C++ 中值有两个独立的属性:
- 有身份(has identity)
- 或者说,有地址,有指向它的指针
- 有身份的值统称为 glvalue (“generalized” lvalue)
- 可以被移动(can be moved from)
- 可以移动的值统称为 rvalue
glvalue 和 rvalue 就是我们一般说的左值和右值。
根据是否有这两种属性,我们可以对 C++ 中的值做如下划分(i 表示有身份,m 表示可以被移动,大写字母表示没有这种属性,第四种类型 IM 在 C++中没有被使用):
- lvalue( iM )
- 有身份,且不能被移动
- 包括
- 变量、函数或数据成员的名字
- 返回左值引用的表达式,比如
++x
、x = 1
- 字符串字面量,如
"hello world"
- prvalue(“pure” rvalue, Im)
- 一般译作纯右值
- 没有身份,可以被移动,也就是所谓的“临时对象”
- 包括
- 返回非引用类型的表达式,比如
x++
、x + 1
- 除字符串字面量之外的字面量,比如
42
、true
- 返回非引用类型的表达式,比如
- 有趣的是 this 指针是 prvalue,你会发现没法对 this 指针求地址
- xvalue(an “eXpiring” value, im)
- 一般译作将亡值
- 有身份,且可以被移动
- 包括
- 右值引用类型的返回值,比如
std::move(x)
- 右值引用类型的返回值,比如
虽然说,C++ 对值做了很细粒度的划分,但事实上,大多数时候只需要区分一个值是左值还是右值即可,因此,这里给出一个实践上可以用来区分左右值的法则:
- 如果你可以对某个表达式取地址,那么它是左值
- 如果一个表达式的类型是左值引用( T& 或 const T& 等),那么它是左值
- 否则,这个表达式是右值
- 函数的返回值(非引用类型的或右值引用类型的)
- 通过隐式类型转换创建的值
- 除字符串以外的字面量(比如 10 和 5.3)
其他的理解如:在C++中右值指的的临时值或常量,更准确的说法是保存在CPU寄存器中的值为右值,而保存在内存中的值为左值。
一个常数5,我们在使用它时不会在内存中为其分配一个空间,而是直接把它放到寄存器中,所以它在C++中就是一个右值。
再比如说我们定义了一个变量 a,它在内存中会分配空间,因此它在C++中就是左值。
那么a+5是左值还是右值呢?当然是右值对吧,因为a+5的结果存放在寄存器中,它并没有在内存中分配新空间,所以它是右值。
|
|
通用引用和右值引用
右值引用
伴随着新的右值定义,C++11 也引入了一种新的引用类型——右值引用,比如 int &&
,右值引用的特点是它只能绑定到右值上,因此 C++11 中也就有了三种引用类型:
- 右值引用只能绑定到右值上,比如
int &&
- 非 const 的左值引用只能绑定到左值上,比如
int &
- const 的左值引用可以绑定到左值或右值上,比如
const int &
区分右值引用和左值引用
04区分通用引用和右值引用 Scott Meyers的effective modern c++讲座摘要
条款24:区分通用引用和右值引用
- 右值引用 一定是 type&&
- type&& 不一定是 右值引用(还有可能是通用引用)
|
|
在这里,“type&&“中的”&&“意味着:
右值引用
绑定右值(Binds rvalues only.)
促进移动(Facilitates moves.)
通用引用
右值引用或左值引用(type&& 有可能是 type& 或 type&& )
绑定所有值,不管是左值,右值,const, 非const...
即促进拷贝,也促进移动(May facilitate copies, may facilitate moves)
与转发引用(Forwarding Reference)相同
如何区分,简单来说:
如果一个变量或参数的声明类型是T&&,并且需要推导出类型T, 这就是通用引用,否则就是右值引用。
通用引用是需要初始化的,如果是左值,那就是左值引用,如果是右值,那就是右值引用。
移动构造函数和移动赋值运算符
为了支持移动语义,C++11 引用两个新的特殊成员函数,它们是移动构造函数和移动赋值运算符,想要支持移动操作的类必须定义它们。
|
|
移动构造函数
移动构造函数的任务
- 完成资源移动
- 资源的所有权移交给新创建的对象
- 确保移动操作完成后,销毁源对象是无害的
- 不再指向被移动的资源
- 确保移动操作完成后,源对象依然是有效的
- 可以赋予它一个新值
- 对留下的值没有任何要求
也就是说移动操作完成后,可以销毁移后源对象,也可以赋予它一个新值,但不能使用移后源对象的值。
移动操作和异常安全
- 移动操作一般不分配新资源,因此不会抛出异常
- 如果移动操作不抛异常,必须注明
noexcept
如果你的移动操作不注明 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).
也就是说有异常抛出时(可能是由于内存分配或元素拷贝/移动),这个调用不产生任何效果。
push_back 可能会导致 vector 扩容,也就是说会申请一块新的内存空间,将现有的元素拷贝/移动到这块新的空间里。
如果我们的移动构造函数会抛异常,假设扩容的过程中,只有部分元素被移动到了新的空间里,这时候有异常抛出,不仅扩容操作没完成,而且原有空间里的部分元素还被已执行的移动操作破坏掉了,不符合 push_back 做出的异常保障。因此,这种情况下,vector 只会使用拷贝操作来完成扩容操作。
移动操作和函数匹配
- 移动右值,拷贝左值
- 移动构造函数只能用于实参是右值的情况下,其他情况下,都会发生拷贝
- 但如果没有移动构造函数,则右值也被拷贝
- 拷贝构造函数的参数是 const 的左值引用,既能接受左值也能接受右值
移动赋值运算符
定义移动赋值运算符最简单的方法就是定义一个“拷贝并交换”的拷贝赋值运算符(如果你在疑惑该怎样自定义 swap 操作,请看 Effective C++ Item 25):
“拷贝并交换”赋值运算符的参数不再是引用,而是传值
- rhs 将是右侧运算对象的一个副本;
- 将
*this
与这个副本交换,也就是将右侧运算对象的值赋给了左侧运算对象; - 函数返回时,rhs 被销毁,析构函数销毁 rhs 现在指向的内存,即左侧运算对象原来的内存。
“**拷贝并交换”**的优势是正确处理了自赋值而且是异常安全的。
赋值运算符的异常安全问题主要来自于拷贝时可能申请内存,如果 new 抛异常了,要确保左侧运算对象原本的数据结构还没有被破坏(显然, rhs 做拷贝的时候,左侧运算对象原有数据结构还没有做任何修改)。
如果你定义了移动构造函数,那么这个拷贝赋值运算符同时也是移动赋值运算符:
- 如果实参是右值,就会用移动构造函数来初始化 rhs;
- 相反,如果实参是左值,就会用拷贝构造函数来初始化 rhs
何时该定义移动构造/赋值
the rule of zero
C.20: If you can avoid defining default operations, do
也就是说,如果默认行为够用,就不要再去定义自己的特殊成员函数。
map 和 string 定义了所有的特殊成员函数,编译器生成的默认实现就已经够用了。
the rule of five
C.21: If you define or =delete any copy, move, or destructor function, define or =delete them all
如果定义拷贝、移动或析构中的任意一个,或将任意一个声明为 =delete 的;那么就需要将它们都定义出来或全部声明为 =delete 的。
实践 the rule of five 时,最简单的判断方法就是看析构函数,如果你析构函数里要做事,不管是释放资源还是关闭数据库连接,那么你就应该把析构函数的这些好兄弟都定义出来。
定义这些特殊成员时,如果你想要默认实现,就将它声明为 =default;如果你想要禁用某个特殊成员,就将它声明为 =delete(这两种情况都被编译器认为是用户定义的)。
the rule of five 背后的逻辑是这些特殊成员函数的语义是息息相关的:
- 规则 1:如果某个类有自定义拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会为它合成移动构造函数和移动赋值运算符了
- 根据函数匹配规则,这种情况下会调用拷贝操作来处理右值
- 规则 2:如果某个类定义了移动构造函数,没有定义拷贝构造函数,那么后者被编译器定义为删除的(对于赋值运算符也是一样的)
如果定义了这些操作中的某一个,就应该把其他的操作都定义出来,以避免所有(潜在的)可移动的场景都变成昂贵的拷贝(对应规则 1)或者使得类型变成仅能移动的(对应规则 2)。
|
|
这段代码没能遵循 the rule of five,造成的后果是 rep 被 double free。
std::move 和 std::forward
虽然这两个函数的名字很有迷惑性,但事实上,从它们所做的事情上来看:move 不移动;forward 不转发,它们只是执行了类型转换操作罢了:
- std::move 无条件地将实参转换为右值;
- std::forward 在部分条件下将实参转换为右值
熟悉 C++ 类型转换的朋友应该知道 static_cast 事实上在运行时什么也不做,因此这俩函数也并不会在运行时做什么事情。
std::move
一个简化的 move 实现是这样的:
T&& 是通用引用,因此这个函数几乎可以接收任何类型的参数。
通过 remove_reference 去掉 T 的引用性质(并不会去掉 cv 限定符),然后给它加上 &&
,形成 ReturnType 类型,由于右值引用类型的返回值是右值,因此结果是实参被无条件地转换为右值。
为什么要使用 std::move?
既然 std::move 只是无条件地做 static_cast,那为什么不直接做类型转换,而要调用 std::move 呢?
std::move 允许我们截断左值,也就是说不再使用该左值,可以自由移动它所拥有的资源;这是非常特殊的类型操作,通过使用 std::move 方便我们确定在哪里对左值做了截断,语义上更加清晰。
C++ 标准库使用比如vector::push_back 等这类函数时,会对参数的对象进行复制,连数据也会复制.这就会造成对象内存的额外创建, 本来原意是想把参数push_back进去就行了,通过std::move,可以避免不必要的拷贝操作。
std::move是将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存的搬迁或者内存拷贝所以可以提高利用效率,改善性能.。
对指针类型的标准库对象并不需要这么做.
使用 std::move 并不代表移动操作一定会发生
- 可能这个类型根本没有定义移动操作
- std::move 并不会去除实参的 const 性质,因此把 const 的对象传给它,得到的返回值类型也是 const 的,对它的操作会变为拷贝操作
- 因为移动操作往往会修改源对象,所以我们不希望在 const 对象上触发移动操作
std::forward 和完美转发
某些函数需要将其一个或多个实参连同类型不变地转发给其他函数,转发后需要保持被转发实参的所有性质,包括
- 实参是否是 const 的;
- 实参是左值还是右值
这种场景我们往往称之为完美转发,C++11 可以通过 std::forward
来实现。
比如工厂函数需要将初始化参数传递给构造函数。一个常见的例子就是 make_unique C++14 才支持,如果我们想自己写一个 make_unique 应该怎么写呢?
std::forward 的实现
std::forward 的模板参数是没法推导的,称为无法推导的上下文(nondeduced context)。
理解这个实现的重点在于它的返回值类型是 T&&
,我们看一个例子:
flip3 接受一个可调用对象,以及两个额外实参,将参数逆序传递给可调用对象。
- 如果实参是 int 变量 i
- T1 的类型为
int&
,std::forward 的返回类型为int& &&
,根据引用折叠,结果是int&
- t1 的类型为
int&
- 参数的类型和返回值的类型相同,所以转换不会做任何事
- T1 的类型为
- 而如果实参是 42
- T2 的类型为
int
,std::forward 的返回类型是int &&
- t2 的类型为
int &&
- 从函数返回的右值引用是右值,所以 t2 会被转换为右值
- T2 的类型为
就此,我们也理解了为什么说 forward 是有条件地将实参转换为右值。
怎么判断该用 move 还是 forward?
- 对右值引用 move
- 右值引用只能绑定到右值上,所以可以无条件地将它转换为右值
- 对通用引用 forward
- 通用引用既能绑定到左值上,也能绑定到右值上,在后一种情况下,我们希望能将它转换为右值
在右值引用上调用 std::forward 表现出的行为是正确的,但由于 std::forward 没法自动做类型推导,写出来的代码会比较繁琐;但如果在通用引用上调用 std::move,可能会导致左值被错误地修改,导致异常的行为。
什么时候用 move 和 forward?
你可能需要在函数中多次使用某个右值引用或通用引用,那么只有在最后一次使用它的时候,才可以对它调 std::move 或 std::forward,因为将它转为右值后,它的内容就不能再被使用了。
名字查找和 move、forward
std::move
和 std::forward
的形参都是通用引用,它们几乎可以匹配任何类型的参数。
因此如果我们定义了自己的 move 或 forward 函数,如果它接受单一形参,不管类型如何,都将与标准库的版本冲突。
同时,move 和 forward 执行的是非常特殊的类型操作,用户特意去修改函数原有行为的概率非常小,因此最好使用带限定语的版本 std::move
和 std::forward
来明确指出使用标准库的版本。
移动和返回值优化
RVO
如果 return 语句的操作数是 prvalue ,且它和返回值的类型相同。
此时,编译器可以实施 copy elision(拷贝省略、拷贝消除),将对象直接构造到调用者的栈上去。
return 语句所在的地方,T 的析构函数必须是可访问的且没有被删除,尽管此处并没有 T 对象被析构掉。
C++17 强制编译器做 RVO,RVO 不再是一项可选的编译器优化,而是 C++ 对 prvalue 的新规定,即返回和使用 prvalue 时不再去实体化一个临时对象
NRVO
对于上面的函数 bar,如果直接用参数 __result 代替命名的返回值 xx,即改写为:
也就是说返回值会被直接构造在调用者的栈上,少了一次拷贝操作,这种优化被称为 Named Return Value Optimization(NRVO)。
移动和 NRVO
C++11 开始,NRVO 仍可以发生,但在没有 NRVO 的情况下,编译器将试图把本地对象移动出去,而不是拷贝出去。
这一移动行为不需要程序员手工用 std::move 进行干预,使用 std::move 对于移动行为没有帮助,反而会影响返回值优化,因为这种情况下,你返回的并不是局部对象,而是局部对象的引用。