C++右值

C++11标准引入了右值引用的概念,但右值的概念是在之前的版本中就有的。在引入右值引用概念后,左右值也被分为左值(lvalue)、将亡值(xvalue)、纯右值(prvalue)。其中将亡值和左值合称为泛左值,将亡值和纯右值合称为右值。左值例如字符串字面量。纯右值例如整型字面量或者求职结果相当于是字面值或者不具名的临时对象。将亡值包括类似T && foo()函数返回的右值引用或由std::move强转来的右值引用。将亡值属于泛左值,又属于右值。属于泛左值是由于将亡值作为右值引用是具名的,这和纯右值(如字面量)不一样,所以被视为左值。作为右值是由于将亡值具有可移动性。而将亡值之所以又具名又能移动,是因为它要死了。注意类似T foo()的函数返回值是纯右值。在使用右值和移动语义时容易产生下面的问题:

  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()函数将它转换成一个右值,而这个函数实际上就是调用了static_cast进行一次强转,得到的是一个将亡值xvalue。将亡值表示在逻辑上当做右值来用的左值。
查看下面的代码,发现编译错误,为什么?

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

42是右值,但i是个右值引用,右值引用并不等于右值,它和引用类型、指针类型一样,属于一个新的类型,一个右值引用能够接受一个右值,而一个左值引用不能绑定到一个右值,这是区别。在绑定之后,i是个具名对象,是个左值。

函数返回右值与(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掩盖,但其能够对资源移动行为进行更精细化的定义。进一步看,右值包括将亡值和纯右值是一个临时的、字面的,或者即将被销毁的对象,在一定程度上它是可有可无的。因此我们有了移动构造函数和移动赋值运算符,现在我们需要构造我们的对象,如果别人传进来一个左值,那我们只有老老实实地调用复制构造或者复制赋值,但现在如果别人传进来一个右值引用T&& other,那就是一个赤裸裸的暗示,“我现在没人要,你可以来掏空我啦~”。所以移动构造或者移动赋值就会直接掏空other,因为它知道other没人要了。但我们同样需要注意在之前的讨论中提到的,在等号右边接受一个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持有的指针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语义并不是单纯的“移动”,而是通过默认或自定义的移动构造等函数掏空对象的过程。

pass by value和pass by const reference

Effective Modern C++中指出在需要copy的情况下,与其传const T &不如直接传T。我们查看SoF上的一个例子。提问者认为1需要copy一次再move一次,而2只需要copy一次,为什么1的效率会更好。回答指出2中的copy可能不一定发生,这是由于对临时对象可能做copy elide的缘故。

1
2
3
4
// 1. Better than 2
Dog::Dog(std::string name) : _name(std::move(name)) {}
// 2. Not bad
Dog::Dog(const std::string &name) : _name(name) {}

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

如果一个程序中显式定义了拷贝构造函数,编译器便不会合成移动构造函数。这会造成下面的效果,如果程序中没有定义移动构造函数,那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这个函数来创建对象。

通用引用和完美转发

完美转发是来自于C++的引用折叠特性,也就是右值引用叠加到右值引用,还是右值,其他所有引用类型的叠加会变成左值。
通用引用是针对模板编程的一个概念,当T是一个模板参数(这个强调的是T是要被推导的)时,T&&是一个通用引用。