最近在调试代码时,发现拷贝和移动系列构造函数的调用和预期不太一样,经过查阅相关资料,发现是返回值优化(RVO)从中做梗。了解了事实真相后,今天就和大家聊聊这个 RVO。
直接看一个例子:
#include
class A {
public:
A (){
std::cout << "A(): addr= " << this << std::endl;
}
A (const A&) {
std::cout << "A(const A&): addr= " << this << std::endl;
}
A& operator=(const A&) {
std::cout << "operator=(&&): addr= " << this << std::endl;
return *this;
}
// A (A&&) {
// std::cout << "A(A&&): addr= " << this << std::endl;
// }
// A& operator=(A&&) {
// std::cout << "operator=(A&&): addr=" << this << std::endl;
// return *this;
// }
~A() {
std::cout << "~A(): addr=" << this << std::endl;
};
};
// A makeA () {
// A a;
// return a;
// }
A makeA () {
return A();
}
int main () {
A a = makeA();
return 0;
}
按照我之前的理解,这里应该发生一次默认构造函数、两次拷贝构造函数和三次析构函数的调用:
但是,编译运行窗口打印如下,只调用了一次默认构造函数和一次析构函数:
A(): addr= 0x7fffdf058eb7
~A(): addr=0x7fffdf058eb7
这是因为编译器默认开启了返回值优化(RVO),可以通过编译选项 -fno-elide-constructors 关闭 RVO(这里使用的编译器是 g++),关闭 RVO 重新编译运行,输出结果符合预期:
A(): addr= 0x7ffc7e2cec07
A(const A&): addr= 0x7ffc7e2cec37
~A(): addr=0x7ffc7e2cec07
A(const A&): addr= 0x7ffc7e2cec36
~A(): addr=0x7ffc7e2cec37
~A(): addr=0x7ffc7e2cec36
可以看到,RVO 减少了两次拷贝和两次析构。同样的,对于移动构造也是一样的,打开 A 中自定义的移动构造函,a = makeA() 这里会调用移动构造函数初始化 a(自定义了拷贝操作会阻止编译器生成移动操作,可以查阅 Understand special member function generation 了解更多。
自定义 move 操作,关闭 RVO:
A(): addr= 0x7ffd575627e7
A(A&&): addr= 0x7ffd57562817
~A(): addr=0x7ffd575627e7
A(A&&): addr= 0x7ffd57562816
~A(): addr=0x7ffd57562817
~A(): addr=0x7ffd57562816
自定义 move 操作,开启 RVO:
A(): addr= 0x7ffc4f79c227
~A(): addr=0x7ffc4f79c227
和拷贝一样,RVO 也同样消除了两次移动构造函数的调用。因此返回值优化包括返回值的拷贝优化和移动优化。
返回值优化(RVO:Return Value Optimization)是编译器的一种编译优化,通过该优化可以减少函数返回时产生临时对象,从而消除部分拷贝或移动操作,进而提高代码性能。
A makeA () {
return A();
}
int main () {
A a = makeA();
return 0;
}
为了减少不必要的拷贝或者移动,RVO 试图减少临时对象的分配,将最终的对象直接以引用的方式传递到函数中,直接对最终的对象进行构造。RVO 的效果相当于将 func 函数优化如下:
void makeA (A& a) {
a.A::A();
}
调用点直接传入要构造的对象,也就是相当于直接构造出最终的对象,消除了两次拷贝或者移动操作,也就自然消除了两次析构操作。
RVO 有一个变种 NRVO(Named Return Value Optimization),与 RVO 的区别其实就是函数中返回的对象已经具名了,类似这样:
A makeA () {
A a
return a;
}
NRVO 的原来和 RVO 基本一致。
Effective Modern C++ 中将 RVO 优化的条件概括为两点:
我们开头的例子就完全满足这两个条件。
下面看几种 RVO 限制的场景。
返回 std::move(a)。 会使 RVO 失效,会多产生一次拷贝和一次析构。
A makeA () {
A a
return std::move(a);
}
// output
A(): addr= 0x7ffc19323917
A(A&&): addr= 0x7ffc19323947
~A(): addr=0x7ffc19323917
~A(): addr=0x7ffc19323947
调用点不是对象初始化,而是赋值。
// ....
A makeA () {
A a;
return a;
}
int main () {
A a;
a = makeA();
return 0;
}
// output
A(): addr= 0x7ffcfd28bab6
A(): addr= 0x7ffcfd28bab7
operator=(A&&): addr=0x7ffcfd28bab6
~A(): addr=0x7ffcfd28bab7
~A(): addr=0x7ffcfd28bab6
返回的局部对象存在分支判断。
// ...
A makeA (bool flag) {
A a1, a2;
if (flag) {
return a1;
} else {
return a2;
}
}
int main () {
int x;
A a = makeA(x);
return 0;
}
// output
A(): addr= 0x7ffd5be33b56
A(): addr= 0x7ffd5be33b57
A(A&&): addr= 0x7ffd5be33b83
~A(): addr=0x7ffd5be33b57
~A(): addr=0x7ffd5be33b56
~A(): addr=0x7ffd5be33b83
总之, 编译器的返回值优化也是有限制的,有些场景不一定生效。这里 列举了更多 RVO 限制的场景,有兴趣的可以前去查看。