Contents

C++:右值引用,移动构造,完美转发及返回值优化

本文采用知识共享署名 4.0 国际许可协议进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,可适当缩放并在引用处附上图片所在的文章链接。

C++的左值与右值

在 C 语言中,左值和右值即字面意思,左值是表达式左边的值,而右值是表达式右边的值。而 C++ 为了支撑移动语义,对值的类型做了新的划分。

区分左值和右值

C++ 中值有两个独立的属性:

  • 有身份(has identity)
    • 或者说,有地址,有指向它的指针
    • 有身份的值统称为 glvalue (“generalized” lvalue)
  • 可以被移动(can be moved from)
    • 可以移动的值统称为 rvalue

glvalue 和 rvalue 就是我们一般说的左值和右值。

根据是否有这两种属性,我们可以对 C++ 中的值做如下划分(i 表示有身份,m 表示可以被移动,大写字母表示没有这种属性,第四种类型 IM 在 C++中没有被使用):

https://cdn.jsdelivr.net/gh/zhangyuhu/share_images/images/202301221640716.png

  • lvalue( iM )
    • 有身份,且不能被移动
    • 包括
      • 变量、函数或数据成员的名字
      • 返回左值引用的表达式,比如 ++xx = 1
      • 字符串字面量,如 "hello world"
  • prvalue(“pure” rvalue, Im)
    • 一般译作纯右值
    • 没有身份,可以被移动,也就是所谓的“临时对象”
    • 包括
      • 返回非引用类型的表达式,比如 x++x + 1
      • 除字符串字面量之外的字面量,比如 42true
    • 有趣的是 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的结果存放在寄存器中,它并没有在内存中分配新空间,所以它是右值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int main(int argc, char *argv[]){

    int && a = 5;  // 正确,5会被直接存放在寄存器中,所以它是右值
    int b = 10;
    int && c = b;  // 错误,b在内存中有空间,所以是右值;右值不能赋值给左值
    int && d = b + 5; // 正确,虽然 b 在内存中,但 b+5 的结果放在寄存器中,它没有在内存中分配空间,因此是右值 

    int && e = a; // 错误,因为a是左值而e必须接收右值。
    int && e = std::move(a); // 正确,通过std::move可以将一个左值转成右值。e虽然接收的必须是右值,但它本身是左值。换句话说e是一种特殊的变量,它是只能接收右值的变量。我们再从左值的本质来看,e也是占内存空间的,所以它肯定是左值。
}

通用引用和右值引用

右值引用

伴随着新的右值定义,C++11 也引入了一种新的引用类型——右值引用,比如 int &&,右值引用的特点是它只能绑定到右值上,因此 C++11 中也就有了三种引用类型:

  • 右值引用只能绑定到右值上,比如 int &&
  • 非 const 的左值引用只能绑定到左值上,比如 int &
  • const 的左值引用可以绑定到左值或右值上,比如 const int &

区分右值引用和左值引用

04区分通用引用和右值引用 Scott Meyers的effective modern c++讲座摘要
条款24:区分通用引用和右值引用

  • 右值引用 一定是 type&&
  • type&& 不一定是 右值引用(还有可能是通用引用)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
void f(Widget&& param);        // 右值引用
Widget&& var1 = Widget();      // 右值引用

auto&& var2 = var1;            // 不是右值引用!!!

decltype(var1) var3 = var1;    // 无法编译通过
Widget&& var4 = var1;          // 无法编译通过

template<typename T>           
void f(std::vector<T>&& param); // 右值引用

template<typename T>
void f(T&& param);              // 不是右值引用!!!

在这里,“type&&“中的”&&“意味着:

右值引用
    绑定右值(Binds rvalues only.)
    促进移动(Facilitates moves.)
通用引用
    右值引用或左值引用(type&& 有可能是 type& 或 type&& )
    绑定所有值,不管是左值,右值,const, 非const...
    即促进拷贝,也促进移动(May facilitate copies, may facilitate moves)
    与转发引用(Forwarding Reference)相同

如何区分,简单来说:
如果一个变量或参数的声明类型是T&&,并且需要推导出类型T, 这就是通用引用,否则就是右值引用。
通用引用是需要初始化的,如果是左值,那就是左值引用,如果是右值,那就是右值引用。

移动构造函数和移动赋值运算符

为了支持移动语义,C++11 引用两个新的特殊成员函数,它们是移动构造函数和移动赋值运算符,想要支持移动操作的类必须定义它们。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Widget {
private:
    int i{0};
    string s{};
    unique_ptr<int> pi{};

public:
    // Move constructor
    // 该构造函数的参数是 Widget &&w。&&它表示的是C++中的右值,也就是只有创建Widget对象时传入的是右值才会执行该构造函数。 
    // 需要注意的是,在移动构造函数操作之后原 Widget 对象的指针地址已经指向NULL了,因此此时就不能再通过其访问之前的堆空间了
    Widget(Widget &&w) = default;

    // Move assignment operator
    Widget &operator=(Widget &&w) = default;
};

移动构造函数

移动构造函数的任务

  • 完成资源移动
    • 资源的所有权移交给新创建的对象
  • 确保移动操作完成后,销毁源对象是无害的
    • 不再指向被移动的资源
  • 确保移动操作完成后,源对象依然是有效的
    • 可以赋予它一个新值
    • 对留下的值没有任何要求

也就是说移动操作完成后,可以销毁移后源对象,也可以赋予它一个新值,但不能使用移后源对象的值。

移动操作和异常安全

  • 移动操作一般不分配新资源,因此不会抛出异常
  • 如果移动操作不抛异常,必须注明 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 扩容,也就是说会申请一块新的内存空间,将现有的元素拷贝/移动到这块新的空间里。

https://cdn.jsdelivr.net/gh/zhangyuhu/share_images/images/202301221647082.png

如果我们的移动构造函数会抛异常,假设扩容的过程中,只有部分元素被移动到了新的空间里,这时候有异常抛出,不仅扩容操作没完成,而且原有空间里的部分元素还被已执行的移动操作破坏掉了,不符合 push_back 做出的异常保障。因此,这种情况下,vector 只会使用拷贝操作来完成扩容操作

移动操作和函数匹配

  • 移动右值,拷贝左值
    • 移动构造函数只能用于实参是右值的情况下,其他情况下,都会发生拷贝
  • 但如果没有移动构造函数,则右值也被拷贝
    • 拷贝构造函数的参数是 const 的左值引用,既能接受左值也能接受右值

移动赋值运算符

定义移动赋值运算符最简单的方法就是定义一个“拷贝并交换”的拷贝赋值运算符(如果你在疑惑该怎样自定义 swap 操作,请看 Effective C++ Item 25):

1
2
3
4
5
ClassA& ClassA::operator=(ClassA rhs)
{
    swap(*this, rhs);
    return *this;
}

“拷贝并交换”赋值运算符的参数不再是引用,而是传值

  • rhs 将是右侧运算对象的一个副本;
  • *this 与这个副本交换,也就是将右侧运算对象的值赋给了左侧运算对象;
  • 函数返回时,rhs 被销毁,析构函数销毁 rhs 现在指向的内存,即左侧运算对象原来的内存。

“**拷贝并交换”**的优势是正确处理了自赋值而且是异常安全的。

赋值运算符的异常安全问题主要来自于拷贝时可能申请内存,如果 new 抛异常了,要确保左侧运算对象原本的数据结构还没有被破坏(显然, rhs 做拷贝的时候,左侧运算对象原有数据结构还没有做任何修改)。

如果你定义了移动构造函数,那么这个拷贝赋值运算符同时也是移动赋值运算符:

  • 如果实参是右值,就会用移动构造函数来初始化 rhs;
  • 相反,如果实参是左值,就会用拷贝构造函数来初始化 rhs

何时该定义移动构造/赋值

the rule of zero

C.20: If you can avoid defining default operations, do

也就是说,如果默认行为够用,就不要再去定义自己的特殊成员函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct Named_map {
public:
    // ... no default operations declared ...
private:
    string name;
    map<int, int> rep;
};

Named_map nm;        // default construct
Named_map nm2 {nm};  // copy construct

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)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
struct M2 {   // bad: incomplete set of copy/move/destructor operations
public:
    // ...
    // ... no copy or move operations ...
    ~M2() { delete[] rep; }
private:
    pair<int, int>* rep;  // zero-terminated set of pairs
};

void use()
{
    M2 x;
    M2 y;
    // ...
    x = y;   // the default assignment
    // ...
}

这段代码没能遵循 the rule of five,造成的后果是 rep 被 double free。

std::move 和 std::forward

虽然这两个函数的名字很有迷惑性,但事实上,从它们所做的事情上来看:move 不移动;forward 不转发,它们只是执行了类型转换操作罢了:

  • std::move 无条件地将实参转换为右值;
  • std::forward 在部分条件下将实参转换为右值

熟悉 C++ 类型转换的朋友应该知道 static_cast 事实上在运行时什么也不做,因此这俩函数也并不会在运行时做什么事情。

std::move

c++ 之 std::move 原理实现与用法总结

一个简化的 move 实现是这样的:

1
2
3
4
5
template <typename T> typename remove_reference<T>::type &&move(T &&param) {
  using ReturnType = typename remove_reference<T>::type &&;

  return static_cast<ReturnType>(param);
}

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 应该怎么写呢?

1
2
3
4
template <typename T, typename... Ts>
std::unique_ptr<T> make_unique(Ts &&... params) {
  return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
}

std::forward 的实现

1
2
3
4
5
6
7
8
9
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);
}

std::forward 的模板参数是没法推导的,称为无法推导的上下文(nondeduced context)。

理解这个实现的重点在于它的返回值类型是 T&&,我们看一个例子:

1
2
3
4
5
6
7
8
9
void g(int &&i, int& j);

template <typename F, typename  T1, typename T2>
void flip3(F f, T1 &&t1, T2 &&t2)
{
    f(std::forward<T2>(t2), std::forward<T1>(t1));
}

flip3(g, i, 42);

flip3 接受一个可调用对象,以及两个额外实参,将参数逆序传递给可调用对象。

  • 如果实参是 int 变量 i
    • T1 的类型为 int&,std::forward 的返回类型为 int& &&,根据引用折叠,结果是 int&
    • t1 的类型为 int&
    • 参数的类型和返回值的类型相同,所以转换不会做任何事
  • 而如果实参是 42
    • T2 的类型为 int,std::forward 的返回类型是 int &&
    • t2 的类型为 int &&
    • 从函数返回的右值引用是右值,所以 t2 会被转换为右值

就此,我们也理解了为什么说 forward 是有条件地将实参转换为右值。

怎么判断该用 move 还是 forward?

  • 对右值引用 move
    • 右值引用只能绑定到右值上,所以可以无条件地将它转换为右值
  • 对通用引用 forward
    • 通用引用既能绑定到左值上,也能绑定到右值上,在后一种情况下,我们希望能将它转换为右值

在右值引用上调用 std::forward 表现出的行为是正确的,但由于 std::forward 没法自动做类型推导,写出来的代码会比较繁琐;但如果在通用引用上调用 std::move,可能会导致左值被错误地修改,导致异常的行为。

什么时候用 move 和 forward?

你可能需要在函数中多次使用某个右值引用或通用引用,那么只有在最后一次使用它的时候,才可以对它调 std::move 或 std::forward,因为将它转为右值后,它的内容就不能再被使用了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
void sink(X&& x);   // sink takes ownership of x

void user()
{
    X x;
    // error: cannot bind an lvalue to a rvalue reference
    sink(x);
    // OK: sink takes the contents of x, x must now be assumed to be empty
    sink(std::move(x));

    // ...

    // probably a mistake
    use(x);
}

名字查找和 move、forward

std::movestd::forward 的形参都是通用引用,它们几乎可以匹配任何类型的参数。

因此如果我们定义了自己的 move 或 forward 函数,如果它接受单一形参,不管类型如何,都将与标准库的版本冲突。

同时,move 和 forward 执行的是非常特殊的类型操作,用户特意去修改函数原有行为的概率非常小,因此最好使用带限定语的版本 std::movestd::forward 来明确指出使用标准库的版本。

移动和返回值优化

RVO

如果 return 语句的操作数是 prvalue ,且它和返回值的类型相同。

1
2
3
4
5
T f() {
    return T();
}
 
f(); // only one call to default constructor of T

此时,编译器可以实施 copy elision(拷贝省略、拷贝消除),将对象直接构造到调用者的栈上去。

return 语句所在的地方,T 的析构函数必须是可访问的且没有被删除,尽管此处并没有 T 对象被析构掉。

C++17 强制编译器做 RVO,RVO 不再是一项可选的编译器优化,而是 C++ 对 prvalue 的新规定,即返回和使用 prvalue 时不再去实体化一个临时对象

NRVO

1
2
3
4
5
6
X bar()  
{  
   X xx;  
   // process xx ...  
   return xx;  
}

对于上面的函数 bar,如果直接用参数 __result 代替命名的返回值 xx,即改写为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void  
bar( X &__result )  
{  
   // default constructor invocation  
   // Pseudo C++ Code  
   __result.X::X();  
 
   // ... process in __result directly  
 
   return;  
}

也就是说返回值会被直接构造在调用者的栈上,少了一次拷贝操作,这种优化被称为 Named Return Value Optimization(NRVO)。

移动和 NRVO

C++11 开始,NRVO 仍可以发生,但在没有 NRVO 的情况下,编译器将试图把本地对象移动出去,而不是拷贝出去。

这一移动行为不需要程序员手工用 std::move 进行干预,使用 std::move 对于移动行为没有帮助,反而会影响返回值优化,因为这种情况下,你返回的并不是局部对象,而是局部对象的引用。