C++右值

C++11标准开始引入了右值引用的概念,容易产生下面的问题:

  1. 右值、右值引用之间有什么区别
  2. 重载决议中右值引用、左值引用、通用引用有什么区别
  3. 右值、(N)RVO之间的关系是什么
  4. 移动语义在哪些地方可以提高性能

右值与右值引用

左值是不能绑定到右值引用的,如下面的代码是错误的

1
2
3
4
5
6
7
8
9
10
11
12
// code 1
int i = 42;
int && ir = i; // error

// code 2
int test(int && ir){
// ...
}
int main(){
int i = 42;
test(i); // error
}

正确的做法是使用std::move()函数将它转换成一个右值。
查看下面的代码,发现编译错误,为什么?

1
2
3
4
int main(){
int && i = 42;
test(i); // error
}

42是右值,但i是个右值引用,右值引用并不等于右值,它和引用类型、指针类型一样,属于一个新的类型。这时候i是个具名对象,是个左值。
C++11标准引入了右值引用的概念,但右值的概念是在之前的版本中就有的。在引入右值引用概念后,左右值也被分为左值(lvalue)、将亡值(xvalue)、纯右值(prvalue)。其中将亡值和左值合称为泛左值,将亡值和纯右值合称为右值。
左值例如字符串字面量
纯右值例如整型字面量
将亡值包括由函数返回的右值引用或由std::move强转来的右值引用。

函数返回右值与(N)RVO

什么是RVO

RVO不能解决callee内部的临时对象的问题,考虑下面的代码

1
2
3
4
5
6
7
8
X fun(){
X x1;
// ...
return x1;
}
int main(){
X _x = fun(); // RVO
}

RVO的目的是fun()的返回值在main中不会产生一个临时对象,但并不会优化掉fun中的x1

什么是NRVO

1
2
3
4
5
6
7
8
X && fun(){
X x1;
// ...
return x1; // NRVO
}
int main(){
X _x = fun();
}

别忘了编译器并不一定会进行NRVO,例如编译器遵循下面一些规则

  1. return的类型和函数签名中的返回值类型相同
  2. return的是局部变量
  3. 条件语句返回时会抑制NRVO

使用移动语义从函数返回

1
2
3
4
5
6
7
8
X && fun(){
X x1;
// ...
return std::move(x1);
}
int main(){
X _x = fun();
}

这似乎“替编译器做了”RVO同样的工作,这是一个比较好的实践么?
事实上这是大错特错的。首先x1是一个自动变量,它在栈上,它的生命周期到fun函数结束就终止了。std::movex1强转成X &&返回,然而到了调用者main那里,x1对象早已被析构了,这个右值引用X &&和相应的左值引用X &、悬挂指针X *一样,对该对象的生命进程是无能为力的。因此不要返回局部变量的右值引用。在StackOverflow上有关于Best Practice的讨论
唯一能正常运行的代码是这样的

1
2
3
4
5
X fun(){
X x1;
// ...
return std::move(x1);
}

在这段代码中,从函数签名上来看,我们返回的不是右值引用,而是一个对象了;但是从return语句上看,我们返回的是一个右值引用,因此阻止了编译器执行NRVO。因此这个std::move除了潜在地暗示编译器不要进行NRVO外并没有任何作用。在上面的讨论中我们知道x1自动变量并没有得到续命,我们返回的是通过X的移动构造函数创建的一个新临时变量X{std::move(x1)}。如果要返回的对象定义了一个良好的移动构造函数,那么使用这种方式返回一个非局部变量尚可一议,否则最佳的实践是直接依赖编译器提供的NRVO。

1
2
3
4
5
X fun(){
X x1;
// ...
return x1;
}

使用移动语义提高性能

如果要根据一个对象创建另一个对象,传统的方式是调用复制构造函数

1
2
X a{b}; // or
X a = b;

让我们重新考虑之前的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
X fun(){
X x1;
X(){
puts("default constructor");
}
X(const X & _x){
puts("default constructor");
}
~X(){
puts("destructor");
}
// ...
return x1;
}
int main(){
X _x = fun();
}

现在我们知道,由于(N)RVO的存在,这段代码中实际上值调用了一次默认构造函数。

虽然右值引用的“光辉”常被(N)RVO掩盖,但其能够对资源移动行为进行更精细化的定义。进一步来看,像std::move或者X && fun()这样的函数,他们返回的是一个右值引用,这样的右值引用称为将亡值。将亡值是一个即将被销毁的对象。既然它马上要被销毁,而我们又要创建一个它的副本,那为什么不在销毁前把它里面的东西直接掏出来给副本呢?
移动构造函数和移动赋值运算符就是做这样的事情。

1
2
3
4
5
6
7
8
9
10
11
12
struct A
{
A(A && a) : p(a.p)
{
a.p = nullptr;
}
~A()
{
delete p;
}
int * p;
};

查看上面的代码节选,移动构造函数将第一个参数将亡值A && a持有的指针a.p直接取来,然后将其设为nullptr。这一步是即为重要的,因为如果不设为nullptr,在a销毁时会执行delete p,那么this好不容易从a那里掏来的p就会被销毁,this->p会变成悬挂指针。这样,将亡值a里面的东西通过了this的皮囊得到了续命。而this也避免了调用一个拷贝构造函数的开销。
现在我们能够理解为什么将亡值属于泛左值,又属于右值了。属于泛左值是由于将亡值作为右值引用是具名的,这和纯右值(如字面量)不一样,所以被视为左值。作为右值是由于将亡值具有可移动性。而将亡值之所以又具名又能移动,是因为它要死了。
类似地我们可以写出这样的代码

1
2
3
4
X fun(){

}
int && x = fun();

fun返回值的生命在x中得到了延长。
但在cppreference的value_category词条上我看到了这样的阐述:右值可以用于初始化右值引用,这种情况下该右值所标识的对象的生存期被扩展到该引用的作用域结尾。由于将亡值也是右值,所以是不是下面的代码中也存在续命呢?

1
2
3
4
5
6
7
8
9
10
11
// case 1
X && fun(){
X x1;
return x1;
}
int && x = fun();
// case 2
X get_x(){
return X{};
}
int && x = std::move(get_x());

首先看case 1,x1的什么有没有通过x得到延长呢?就算是延长了,也是延长了fun()的返回值的寿命,而不是fun里面的x1得到了延长,作为自动变量,x1是肯定要在栈上被销毁的。如果我们加上了一次隐式转换,可以发现这和之前在NRVO讨论的情况是一样的,自动变量只会被销毁。
此外,在StackOverflow中我找到了答案,它阐释了cppreference的论点,因为有一种叫dangling reference的东西,如果将xvalue绑定到rvalue reference上就可能会产生这个。这也说明了为什么函数返回T&&是危险的。对此SOF上还有进一步的讨论
综上所述,当fun返回一个prvalue时,用T &&来续命是合法的。

注意,我们虽然定义了移动构造函数可以从old_x构造new_x,但是old_xnew_x在地址上仍然是不同的,例如被移动对象指针被其他对象持有的话,同样会发生错误。这是因为move语义并不是单纯的“移动”,而是通过默认或自定义的移动构造等函数掏空对象的过程。

拷贝构造函数与移动构造函数的关系

如果一个程序中显式定义了拷贝构造函数,编译器便不会合成移动构造函数。这会造成下面的效果,如果程序中没有定义移动构造函数,那std::move会调用拷贝构造函数。

将移动构造函数声明为noexcept

noexcept在C++17标准中成为了函数类型的一部分。
通常来说应该将移动构造函数声明为noexcept。特别是当你的类型会和STL中的容器一起用时,如果移动构造函数不是noexcept的,那么容器会调用复制构造函数
一个类似的考虑是是否需要将拷贝构造函数设为noexcept

标准库容器中的右值

在C++11后,诸如std::vector<T>的容器的push_back方法也能接受右值了,这样他们会直接移动我们传入时创建的临时对象。不过更有效的方式是使用另一个添加的emplace_back的方法。这个函数直接免去了创建临时对象的成本,而直接在原地进行构造,一如我们的placement new的行为一样,它调用std::allocator_traits::construct这个函数来创建对象。

通用引用和完美转发

这一部分是针对模板变成来说的。