RVO(Return Value Optimization) 返回值优化
NRVO(Name Return Value Optimization) 命名返回值优化
禁用 RVO
首先看一下禁用掉 RVO 的效果
#include <iostream>
using namespace std;
class Test {
public:
int a;
Test() {
cout << "Test::Test()" << endl;
}
Test(const Test&) {
cout << "Test(const Test&)" << endl;
}
Test(Test&&) noexcept{
cout << "Test(Test&&)" << endl;
}
~Test() {
cout << "~Test()" << endl;
}
};
Test foo() {
return Test();
}
int main() {
Test t = foo();
return 0;
}
GCC 编译器, c++17 以下标准,使用 -fno-elide-constructors
参数编译时得到的输出是这样的
Test::Test()
Test(Test&&)
~Test()
Test(Test&&)
~Test()
~Test()
之所以使用 c++17 以下标准是因为, RVO 已被 C++ 标准(C++17 开始)规定为强制性优化,编译器必须执行,就算添加了-fno-elide-constructors 参数也没有作用
汇编分析
可看到,临时对象是在主函数的栈帧中创建的,并将它的地址作为参数传递给 foo 函数的, 接下来看一看 RVO 所做的优化
过程分析
通过汇编我们可以看到这样一个过程
- 首先 main 函数中调用 foo 函数
- foo 函数中 return Test(); 语句中的 Test() 会创建一个临时的对象,也就是下图中的 Ano object 【第一行 Test::Test()】
- 然后 Ano object 这个对象会通过移动构造,创建出一个临时对象 Temp object 【第二行 Test(Test&&)】
- 接下来 Ano object 超出作用域被释放 【第三行 ~Test()】
- 通过 Temp object 这个对象移动构造创建出主函数中 t 这个对象 【第四行 Test(Test&&)】
- 释放掉 Temp object 【第五行 ~Test()】
最后,主函数执行结束, 调用 t 的析构函数, 【第五行 ~Test()】
可以看到图中三个对象,自然对应三次构造和析构,临时对象是实际创建在主调函数的栈帧, 这里方便理解画在外边了, 这样其实是做了一些无用功的, 为了得到 foo 函数返回的对象还需要创建一个临时的对象,启用 RVO
还是同上一节的代码, 这次不添加 -fno-elide-constructors 参数
输出如下Test::Test() ~Test()
如你所见,只调用了一次构造和析构函数, 不仅优化掉了临时对象,连 return 语句的匿名对象也优化掉了!
过程分析
实际上它的做法就是,在主函数中预留了对象的空间, 并且把这个空间的地址作为参数传递给了 foo 函数, foo 函数在这个地址上直接构造对象,从而减少了不必要的临时对象创建及销毁
汇编分析
可以通过汇编来验证一下
可以看到相比指令少了很多NRVO
上述 RVO 的例子中, 我们返回的都是匿名对象, 由名称可以看到 NRVO 比 RVO 多了一个 N(Name), 接下来看看返回命名的局部对象会是什么情况
foo 函数修改为Test foo() { Test t1; t1.a = 1; return t; }
禁用 NRVO
先看看禁用是什么效果, c++17 以下, -fno-elide-constructors 参数编译
输出:Test::Test() Test(Test&&) ~Test() Test(Test&&) ~Test() ~Test()
可以看到同样是三次构造和析构, 整体过程与禁用 RVO 的例子基本一致, 只不过 foo 函数内匿名对象 Test() 变成了局部对象 t1;
启用 NRVO
输出:
Test::Test() ~Test()
这里的实现与rvo 也基本相似, 只不过 foo 函数内局部对象 t 是创建在主调函数的栈帧中的,可以看到 foo 内 t1 和 main 函数内 t 实际上是同一个对象
汇编分析
这里再看看汇编的实现
注意
这里有一点要注意, 只有当编译器可以确定返回的是哪个对象时才可以进行上述的优化
例如 对 foo 函数做一些修改
Test foo() {
Test t0,t1;
int a; cin >> a;
if(a == 0) return t0;
return t1;
}
编译阶段无法确定要返回哪个对象,无法提前将对象空间预留到主调函数的栈帧上, 所以它的结果是这样的
Test::Test()
Test::Test()
Test(Test&&)
~Test()
~Test()
~Test()
前两个Test::Test()分别为 t0, t1 的构造,第三个 Test(Test&&) 是当确定分支后,调用移动构造出主函数内 t , 从汇编层面看的话, t的地址是传递给 foo 函数的。最后 t1, t0, t 分别析构掉, 对应后三个~Test()
这样的话就只能优化掉临时对象的开销了。
std::move 的影响
// TODO