RVO 和 NRVO 探究

默认分类 · 2024-12-19 · 33 人浏览

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 参数也没有作用

汇编分析

未命名绘图1.drawio.png
可看到,临时对象是在主函数的栈帧中创建的,并将它的地址作为参数传递给 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()】

    过程.png
    可以看到图中三个对象,自然对应三次构造和析构,临时对象是实际创建在主调函数的栈帧, 这里方便理解画在外边了, 这样其实是做了一些无用功的, 为了得到 foo 函数返回的对象还需要创建一个临时的对象,

    启用 RVO

    还是同上一节的代码, 这次不添加 -fno-elide-constructors 参数
    输出如下

    Test::Test()
    ~Test()

    如你所见,只调用了一次构造和析构函数, 不仅优化掉了临时对象,连 return 语句的匿名对象也优化掉了!

    过程分析

    实际上它的做法就是,在主函数中预留了对象的空间, 并且把这个空间的地址作为参数传递给了 foo 函数, foo 函数在这个地址上直接构造对象,从而减少了不必要的临时对象创建及销毁
    未命名绘图.drawio (7).png

    汇编分析

    可以通过汇编来验证一下
    未命名绘图11.drawio.png
    可以看到相比指令少了很多

    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 实际上是同一个对象

    汇编分析

    这里再看看汇编的实现
    未命名绘图.drawio (8).png

注意

这里有一点要注意, 只有当编译器可以确定返回的是哪个对象时才可以进行上述的优化
例如 对 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

Theme Jasmine by Kent Liao