C++移动操作,以及复制消除,RVO和NRVO

本文讨论了何时C++会自动进行移动操作,并且说明了复制消除,RVO和NRVO优化。
移动操作主要参考了cppreference 的这个说明, 优化部分的主要的参考来自于stack overflow 的这篇文章

移动操作

移动操作有关的函数

和移动操作相关的类函数有两个:
移动构造函数:

1
A(A&& rhs);

移动赋值运算符:

1
A& operator=(A&& rhs);

注意这两个函数的参数类型都不是const,这也是C++默认会生成的函数声明。
移动构造函数用于在构造类型的时候使用:

1
2
3
4
5
6
A a1;

// 使用std::move强制进行移动
A a2 = std::move(a1);

A a2(std::move(a1));

而移动赋值运算符就是在赋值的时候进行移动:

1
2
3
A a1;
A a2;
a1 = std::move(a2); // 使用move进行强制移动

何时自动声明移动构造函数和赋值移动构造函数

隐式的移动构造函数将会在可以被生成且满足如下所有条件的情况下自动生成:

  • 没有用户声明的 复制构造函数
  • 没有用户声明的 复制赋值运算符(即operator=(const A&)这类)
  • 没有用户声明的 移动赋值运算符(即operator=(A&&)这类)
  • 没有用户声明的 析构函数

所谓可以被生成的意思是满足以下所有条件:

  • 类中没有不能移动的非静态成员
  • 继承时,基类可以被移动
  • 继承时,基类的构造函数可以被访问

而移动赋值运算符的产生条件也差不多,只不过将没有声明的 移动赋值构造函数改成没有用户声明 移动构造函数即可。

总之,这两个函数生成的条件就一句话:除了普通的构造函数外(指默认构造函数和带其他参数的构造函数),不得声明任何其他的构造函数,operator=函数和析构函数

何时自动移动

使用std::move是一种强制的,显式的移动。但是C++很多时候为了效率会自动帮我们移动。主要的规则其实就是所有的右值都会进行移动,如果不能移动,进行拷贝。但是为了严谨,我们还是摆出cppreference上的规则:

  • 初始化的时候使用std::move()T a = std::move(b)或者T a(std::move(b));这种。这里要加上std::move(),不然会调用复制构造函数。
  • 函数实参传递的时候使用std::move()func(std::move(a))
  • 函数返回时,如:
1
2
3
4
5
6
7
8
9
class A {};

A CreateA() {
	return A();
}

// call

A a = CreateA();

的时候,使用A()产生的变量会首先移动到CreateA()函数产生的返回值中,这个时候这个返回值是一个临时变量(我们记为temp),接下来就是执行这段代码:A a = temp,然后temp是临时变量, 会再次调用A的移动构造函数给a变量。

前两个是属于显式的移动,最后一种就是隐式移动。移动赋值运算符的规则也是一样,只有等号右边是临时变量就会自动调用。

复制消除,RVO和NRVO

虽然C++对移动操作定义的很明确,但编译器却并不总是按照这个定义去做。因为编译器中有三个重要的优化经常会减少拷贝,甚至是移动操作。

在GCC和Clang下可以添加-fno-elide-constructors选项来关闭这三种优化。

复制消除

来看一看下面代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class C {
public:
  C() {}
  C(const C&) { std::cout << "A copy was made.\n"; }
  C(C&& rhs) { std::cout << "A move was made.\n"; }

};

C f() {
  return C();
}

int main() {
  std::cout << "Hello World!\n";
  C obj = f();
}

这里建议在C++17标准下编译,因为C++17起所有的复制消规则除被写在语言规范内,大部分编译器应该都会做这件事。我的Clang++ 12.0.5上的执行结果仅仅是输出了一行Hello World:

1
Hello World!

按照上面的规则,函数在返回的时候会进行移动,也就是说在f()的调用内,会先移动给临时变量,然后临时变量再移动给obj,但是这里什么都没发生,没有任何的移动和拷贝,obj就像凭空出现了一样。

在C++17起,复制消除是强制执行的,而C++11中是看编译器心情。
在如下条件下会进行复制消除:

  • 在return语句中,return的值是和函数返回值类型一样右值类型一样是为了防止隐式转换,否则会产生新的变量从而阻止移动,右值是因为C++自动移动只能对右值操作。
  • 在变量初始化的时候,初始化表达式是右值。如:
1
2
3
4
5
6
class A{};

A f() { return A(); } // 这里是第一种情况,会自动复制消除

// call
A a = f(); // 这里函数返回值的临时变量到a的过程中的移动也会被消除

这也就解释了为什么上面的代码没有调用任何的拷贝,移动函数了。

RVO和NRVO

RVO是Return Value Optimization(返回值优化)的简写,而NRVO是Named Return Value Optimization(命名返回值优化)的简写。这两个优化是复制消除的常见形式。
通过他们的名字就可以看出,这是在函数返回的时候做的优化。

RVO是指在函数返回一个临时变量时的优化,具体的优化如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 原本的函数
T CreateT(int value) {
	return T(value);
}

T a = CreateT(10);

// 优化后的函数(伪代码):
void CreateT(T& v, int value) {
	v.T::T(value);	// 直接在内部进行构造
}

即通过将要接收函数返回值的对象以引用的形式放入函数内部初始化,这样就避免了一次移动/拷贝。

而NRVO则是更加宽泛的RVO。对于如下的代码可以执行NRVO:

1
2
3
4
T CreateT(int values) {
	T t(value);
	return t;
}

编译器也会优化成上面RVO优化的样子。

updatedupdated2023-06-082023-06-08