C++ 返回值优化

2022年 6月 16日 84点热度 0人点赞

看完大呼过瘾, C++ 去死吧, 你姆妈滴, 一个返回值能整出这么多幺蛾子, 也是没谁了, C++ 为啥难, 就是因为有这些犄角旮旯的东西, 而且很多.

file

返回临时变量的现象

原文链接: 复杂的 C++, 当函数返回对象到底发生了什么?

我们知道, 当函数运行结束的时候, 函数内部的局部变量就会消失, 这是 C/C++ 里没有任何疑问的规定, 但是今天我在写代码的时候突然就想到了一个相当纠结的问题, 那就是当我一个函数返回类型是一个对象的时候, 以我当时掌握的知识理解, 当函数返回时回要生成一个临时对象, 这个临时对象可能会开销很多资源, 那么这样我们的函数就不能设计成一个返回类型为对象的函数了, 或者想办法避免产生这个临时对象, 方法就是使用动态分配内存. 出于验证的想法, 我写了下面一段代码进行测试:

#include <algorithm>
#include <cstring>
#include <iostream>
#include <set>
#include <stdlib.h>
#include <vector>

using namespace std;

#include <iostream>
using namespace std;
struct Test {
  Test() { cout << "Constructor" << endl; }
  Test(const Test &) { cout << "copy Constructor" << endl; }
  ~Test() { cout << "destroy" << endl; }
};

Test fun() {
  Test t1;
  cout << "&t1 is " << &t1 << endl;
  return t1;
}

int main() {
  fun();
  cout << "This is a test!" << endl;
  return 0;
}

对于上面的代码, 我的思考是这样的:

首先调用函数 fun(), 调用构造函数产生 t1, 输出 t1 的地址, 到达 return t1 语句, 这时调用拷贝构造函数产生一个临时对象, t1 析构, 临时对象析构, 输出 "This is a test!", 程序结束.

没错吧? 对于上面的思考过程, 如果你以前对 C++ 有深入的了解, 对这样的思考过程应该是认同的吧!

好, 我们用 g++ 编译运行一下, 我的 g++ 版本是 gcc version 4.8.4 (Ubuntu 4.8.4-2ubuntu1~14.04.3), 下面是运行结果

Constructor
&t1 is 0x7ffce5a4956f
destroy
This is a test!

不对吧? 从这个结果看, 好像没有产生临时对象啊, 难道是我错了? 还是说 g++ 发现我没有使用函数的返回值然后帮我优化了? OK, 我就修改一下代码:

int main()
{
    Test t2 = fun();
    cout << "&t2 is " << &t2 << endl;
    cout << "This is a test!" << endl;
    return 0;
}

如果的想法是没有错的, 那么过程应该是这样的:

首先调用函数 fun(), 调用构造函数产生 t1, 输出 t1 的地址, 到达 return t1 语句, 这时调用拷贝构造函数产生一个临时对象, t1 析构, 调用拷贝构造函数产生对象 t2, 临时对象析构, 输出 t2 的地址, 输出 "This is a test!", t2 析构, 程序结束.

实际用 g++ 编译运行的结果是:

Constructor
&t1 is 0x7fff5303673f
&t2 is 0x7fff5303673f
This is a test!
destroy

妈呀! 这是什么情况? 我怎么有点懵逼啊? 从这个结果看, 只在 fun() 里调用了一次构造函数, 产生 t1 对象, 没有产生临时对象就算了, 把我的 t2 都搞没了是怎么回事? 不对, 发现一个新问题, t1 对象的析构函数怎么变成了在程序结束的时候才调用? 不应该是函数 fun() 结束后就调用的吗? 难道又是编译器给优化了? 我把优化选项关了, 编译时采用的是 -O0 参数, 不过结果还是没有调用拷贝构造函数产生 t2, 这里我对优化选项了解得不深, 所以我采用了代码进行另外的测试.

带着新问题, 我又继续写了几段测试代码, 测试为什么没有产生 t1 对象, 发现真的是编译器进行了优化 (可见 -O0 选项其实也是对性能进行了一定优化的), 我们看看下面这段, 编译器不可以采用优化的:

Test fun2(Test const &t)
{
    cout<<"&t3 is "<<&t3<<endl;
    return t;
}
int main()
{
    const Test t3;
    Test t2 = fun2(t3);
    cout<<"&t2 is "<<&t2<<endl;
    cout<<"This is a test!"<<endl;
    return 0;
}

运行的结果是:

Constructor
&t3 is 0x7ffec045a2be
copy Constructor
&t2 is 0x7ffec045a2bf
This is a test!
destroy
destroy

没错, 这里调用拷贝构造函数产生了 t2 了, 是编译器优化了之前的那段代码, 把原本应该在 fun() 结束时就析构的 t1 继续当成了 t2 使用, 提高了效率.

好了, 我们回到一开始的问题了, 按照之前的理解, 函数返回应该是要产生一个临时对象的, 我记得在 《C++ Primer》 中都是有提及的, 带着这个疑问, 我又继续百度了, 果然证明我一开始的认知是没有错的, 传送门, 这是一种叫 返回值优化的机制 (Return Value Optimization, 简称 RVO), 具体有兴趣的同学去仔细看看.

原来就是 g++ 编译器帮我们优化, 以提高性能的, 我们可以通过在编译的时候, 加上 -fno-elide-constructors 这个选项, 来去除这个优化 (再次证明我对 g++ 优化没有一个系统的学习)

我们重新编译最开始的那段程序

g++ -o test test.cpp -fno-elide-constructors

运行:

./test

运行结果:

Constructor
&t1 is 0x7ffeaf3d693f
copy Constructor
destroy
destroy
This is a test!

这下没错了, 完全符合一开始的猜想.

返回值优化的原理

很多人应该都听过 RVO 或者返回值优化, 又或者构造优化, 但是返回值优化的实现原理是什么样的, how?

编译器会对需要返回值优化的函数进行改造, 从有返回值的类型, 变成多了一个额外参数类型, 如下 A::makeA() -> A::makeA(void* ptr)

要明白构造函数到底做了什么:

  1. 申请内存, operator
  2. 在申请的内存进行初始化
  3. 返回内存起始地址

23 的顺序可以颠倒

举例说明

    A a = A::makeA()
    //经过返回值优化有以下改变
    1. void* y = operator new(sizeof(A));
    2.A::makeA() —> A::makeA(void* y) // 在里面进行初始化
    3. A* a = (A*)y;

下面具体测试用例

class A {
public:
    A() {
        cout << "A construct" << endl;
    }

    A(const A&) {
        cout << "A copy construct" << endl;
    }

    void name() {
        cout << "I am A" << endl;
    }

    static A  makeA() {
        A a;
        cout << &a << endl;
        cout << "make A()" << endl;
        return a;
    }

    /*
     *   编译器将 static A  makeA() 签名优化成下面的有参形式 void makeA(void* ptr);
     * */
    static void makeA(void* ptr) {
        ptr = new (ptr) A;
        cout << ptr << endl;
        cout << "make A(ptr)" << endl;
        return;
    }
};

int main() {
    auto x = A::makeA();
    cout << &x << endl;

    cout << "++++++++++++++++++++++++++++ " << endl;
    // 模拟返回值优化
    // 核心是吧 构造的时候的 分配内存和初始化分离
    // 1 . operator new 分配内存
    A* y = (A*)::operator new(sizeof (A));
    // 2. 调用编译器优化后的函数, 对 y 进行初始化
    A::makeA(y);
    cout << y << endl;
    y->name();
    free(y);
    y = nullptr;
}
 1. 禁止返回值优化 g++ main.cpp  -fno-elide-constructors
 3. 默认返回值优化 g++ main.cpp

版权声明: 本文为 CSDN 博主「Silent_Blue_Sky」的原创文章, 遵循 CC 4.0 BY-SA 版权协议, 转载请附上原文出处链接及本声明.
原文链接:https://blog.csdn.net/qq_34179431/article/details/116230043

什么时候用 RVO

作者:spiritsaway
链接:https://www.zhihu.com/question/27000013/answer/34846612
来源: 知乎
著作权归作者所有. 商业转载请联系作者获得授权, 非商业转载请注明出处.

根据 effective modern c++ 中介绍, 编译器进行 RVO 条件有二:

  • return 的值类型与函数签名的返回值类型相同
  • return 的是一个局部对象

现在我们来考虑下面这个语句 return std::move(w)

此时返回的并不是一个局部对象, 而是局部对象的右值引用. 编译器此时无法进行 RVO 优化, 能做的只有根据 std::move(w) 来移动构造一个临时对象, 然后再将该临时对象赋值到最后的目标. 所以, 不要试图去返回一个局部对象的右值引用.

下面来谈一下右值引用与函数之间的关系. 第一个例子:

std::vector<int> return_vector(void) {
    std::vector<int> tmp {1, 2, 3, 4, 5};
    return tmp;
}
std::vector<int> &&rval_ref = return_vector();

此时, 并不调用 RVO, 拷贝构造临时对象, 同时临时对象的生命周期延长至与 rval_ref 相同, 等价于下面的代码 const std::vector<int>& rval_ref = return_vector();

第二个例子:

std::vector<int>&& return_vector(void) {
    std::vector<int> tmp {1, 2, 3, 4, 5};
    return std::move(tmp);
}

std::vector<int> &&rval_ref = return_vector();

该代码会造成一个运行时错误, 因为 rval_ref 最终指向被析构了的 tmp . 类似于返回了内部对象的左值引用.

第三个例子:

std::vector<int> return_vector(void) {
    std::vector<int> tmp {1, 2, 3, 4, 5};
    return std::move(tmp);
}
std::vector<int> &&rval_ref = return_vector();

该例子类似于第一个例子, 只不过临时对象的构造是由右值移动构造的.

最好的例子:

std::vector<int> return_vector(void) {
    std::vector<int> tmp {1, 2, 3, 4, 5};
    return tmp;
}
std::vector<int> rval_ref = return_vector();

该代码会调用 RVO, 不生成临时对象, 返朴归真了.

什么时候应当依靠返回值优化 (RVO)? - spiritsaway 的回答 - 知乎
https://www.zhihu.com/question/27000013/answer/34846612

恰好之前写过一篇类似文章, 发表于公众号【高性能架构探索】, 原文链接如下: [编译器之返回值优化 (N)RVO]()

在上一篇文章【Modern C++】深入理解左值, 右值中, 为了说明什么是将亡值, 通过一段代码进行举例, 以便大家理解. 后面有读者私下跟我沟通, 那块代码举例不是很合适, 因为编译器会进行返回值优化. 在这块特此说明下, 当时的举例, 目的是为了让读者理解引入 move 语义的原因, 忽略了编译器优化这个特点.

今天, 借助本文, 聊聊编译器的函数返回值优化.

函数返回机制

既然本文的主题是返回值优化, 那么就不得不提一下函数返回值在编译器中的实现机制, 这样以便更好的理解本文内容.

函数返回值的传递分为两种情况:

  • 当返回的对象大小不超过 8 字节时, 通过寄存器 (eax edx) 返回
  • 当返回的对象大小大于 8 字节时, 通过栈返回. 此处需要注意的时候, 如果返回的是 struct 或者 class 对象, 即使其大小不大于 8 字节, 也是通过栈返回的.

在通过栈返回的时候, 栈上会有一块空间来保存函数的返回值. 当函数结束的时候, 会把要返回的对象拷贝到这块区域, 对于内置类型是直接拷贝, 类类型的话是调用拷贝构造函数. 这块区域又称为函数返回的临时对象.

示例

为了能够方便通过输出理解程序的运行情况, 本文中所有的代码示例均如下类所示:

class Obj {
 public:
  Obj() { // 构造函数
    std::cout << "in Obj() " << " " << this << std::endl;
  }

  Obj(int n) {
    std::cout << "in Obj(int) " << " " << this << std::endl;
  }

  Obj(const Obj &obj) { // 拷贝构造函数
    std::cout << "in Obj(const Obj &obj) " << &obj << " " << this << std::endl;
  }

  Obj &operator=(const Obj &obj) { // 赋值构造函数

    std::cout << "in operator=(const Obj &obj)" << std::endl;
    return *this;
  }

  ~Obj() { // 析构函数
    std::cout << "in ~Obj() " << this << std::endl;
  }

  int n;
};

在本示例代码中

  • 在各个函数中均加入了函数名称输出, 以方便了解函数的调用情况
  • 各个函数中加入了对象指针地址输出, 以方便了解构造, 拷贝以及赋值等情况
  • 禁用优化

为了提升程序的性能, 编译器在某些情况下, 会对我们的代码进行优化, 为了搞清楚编译都做了哪些优化, 我们首先在禁用优化的情况下, 看看程序的.

首先, 我们看一段代码, 如下:

Obj fun() {
  Obj obj;
  // do sth;
  return obj;
}

int main() {
  Obj obj = fun();
  std::cout << "&obj is " << &obj << std::endl;
  return 0;
}

现在, 撇开编译器优化, 我们单纯从上面代码进行分析, 调用的 Obj 类成员函数的顺序应该为:

  1. 调用构造函数, 生成对象
  2. 调用拷贝构造函数, 生成临时对象
  3. 析构第 1 步生成的对象
  4. 调用拷贝构造函数, 将第 2 步生成的临时变量拷贝到 main() 函数中的局部对象 obj 中
  5. 调用析构函数, 释放第 2 部生成的临时对象
  6. 调用析构函数, 释放 main() 函数中的 obj 局部对象

为了验证我们所理解的顺序是否跟程序运行结果一致, 编译并运行, 结果如下:

g++ -g -std=c++11 test.cc -o test && ./test

in Obj()  0x7ffd6fb15240
&obj is 0x7ffd6fb15240
in ~Obj() 0x7ffd6fb15240

输出结果与我们预期差别很大, 是不是怀疑之前的理解是错误的? 其实这是因为编译器对函数返回值做了优化导致.

编译器提供了个编译选项 -fno-elide-constructors 来禁用返回值优化, 编译并运行之后, 输出如下:

g++ -std=c++11 -fno-elide-constructors -g test.cc -o test && ./test

in Obj()  0x7ffee18a9a00 // 在 fun() 函数中, 构造 obj 对象
in Obj(const Obj &obj) 0x7ffee18a9a00  0x7ffee18a9a40 // 通过拷贝构造创建临时变量 (fun() 函数定义的 obj---> 临时对象)
in ~Obj() 0x7ffee18a9a00 // 析构 fun() 函数中构造的 obj 对象 (fun() 函数定义的 obj)
in Obj(const Obj &obj) 0x7ffee18a9a40 0x7ffee18a9a30 // 通过拷贝构造函数构建 obj(main 函数中的) 对象 (临时对象--->main() 函数定义的 obj)
in ~Obj() 0x7ffee18a9a40 // 释放临时对象
&obj is 0x7ffee18a9a30
in ~Obj() 0x7ffee18a9a30 // 释放 main() 函数中定义的 obj 对象

上述输出结果跟我们之前的理解一致了吧 .

通过上述示例可以看出, 如果编译器没有进行返回值优化, 则一个简单的拷贝赋值行为, 总共调用了 6 次, 分别为 1 次构造函数, 2 次拷贝构造函数, 以及 3 次析构函数. 而如果做了优化之后呢, 只调用了 1 次构造函数和 1 次析构函数, 相比起来, 优化的程度非常高.

当一个函数返回一个对象实例的时候, 理论上会产生临时便利, 那必然会导致新对象的构造和就对象的析构, 这对性能是有影响的. C++标准允许省略拷贝构造函数. 简单来说, 就是在调用的地方, 将需要初始化对象的引用作为函数参数传递给函数, 进而避免不必要的拷贝.

编译器对函数返回值优化的方式分为 RVONRVO (自 c++11 开始引入), 在后面的文章中, 我们将对该两种方式进行详细分析.

在此需要说明的是, 因为自 C++11 起才引入了 NRVO, 而 NRVO 针对的是具名函数对象返回, 而 C++11 之前的 RVO 相对 NRVO 来说, 是一种 URVO(未具名返回值优化).

RVO

RVO(Return Value Optimization), 是一种编译器优化技术, 通过该技术, 编译器可以减少函数返回时生成临时对象的个数, 从某种程度上可以提高程序的运行效率, 对需要分配大量内存的类对象其值复制过程十分友好.

当一个未具名且未绑定到任何引用的临时变量被移动或复制到一个相同的对象时, 拷贝和移动构造可以被省略. 当这个临时对象在被构造的时候, 他会直接被构造在将要拷贝/移动到的对象. 当未命名临时对象是函数返回值时, 发生的省略拷贝的行为被称为 RVO(返回值优化).

RVO 优化针对的是返回一个未具名对象, 也就是说 RVO 的功能是消除函数返回时创建的临时对象.

Obj fun() {
  return Obj();
}

int main() {
  Obj obj = fun();
  std::cout << "&obj is " << &obj << std::endl;
  return 0;
}

为了理解编译所做的优化, 我们首先禁用 RVO 优化, 输出如下:

in Obj()  0x7ffd9bd8ab30 // 在 fun() 函数中, 构造 obj 对象
in Obj(const Obj &obj) 0x7ffd9bd8ab30 0x7ffd9bd8ab70 // // 通过拷贝构造创建临时变量 (fun() 函数定义的 obj---> 临时对象)
in ~Obj() 0x7ffd9bd8ab30 // // 析构 fun() 函数中构造的 obj 对象 (fun() 函数定义的 obj)
in Obj(const Obj &obj) 0x7ffd9bd8ab70 0x7ffd9bd8ab60 // // 通过拷贝构造函数构建 obj(main 函数中的) 对象 (临时对象--->main() 函数定义的 obj)
in ~Obj() 0x7ffd9bd8ab70 // 释放临时对象
&obj is 0x7ffd9bd8ab60
in ~Obj() 0x7ffd9bd8ab60 // 释放 main() 函数中定义的 obj 对象

从上述输出, 我们可以看出, 上述代码总共调用了 1 次构造函数, 2 次拷贝构造函数以及 3 次析构函数.

下面, 我们去掉禁用优化的选项, 重新编译运行, 输出如下:

in Obj()  0x7ffcc33536e0
&obj is 0x7ffcc33536e0
in ~Obj() 0x7ffcc33536e0

可以看出, 经过编译器优化之后, 总共调用了 1 次构造函数, 一次拷贝构造函数.

那么, 编译器优化后较优化前相比, 减少了 2 次拷贝构造函数以及两次析构函数.

编译器明确知道函数会返回哪一个局部对象, 那么编译器会把存储这个局部对象的地址和存储返回值临时对象的地址进行复用, 也就是说避免了从局部对象到临时对象的拷贝操作, 这就是 RVO.

可以通过 -fno-elide-constructors 来禁用 RVO.

NRVO

NRVO, 又名具名返回值优化 (Named Return Value Optimization), 为 RVO 的一个变种, 也是一种编译器对于函数返回值优化的方式. 此特性从 C++11 开始支持, 也就是说 C++98, C++03 都是没有将此优化特性写到标准中的, 与 RVO 的不同之处在于函数返回的临时值是具名的.

NRVO 与 RVO 的区别是返回的对象是具名的, 既然返回的对象是具名的, 那么对象是在 return 语句之前就构造完成.

我们仍然以一个例子来分析编译器的 NRVO 都做了哪些优化.

Obj fun() {
  Obj obj; // 具名对象
  // do sth;
  return obj;
}

int main() {
  Obj obj = fun();
  std::cout << "&obj is " << &obj << std::endl;
  return 0;
}

通过 -fno-elide-constructors 禁用编译器优化之后, 输出如下:

in Obj()  0x7ffee18a9a00 // 在 fun() 函数中, 构造 obj 对象
in Obj(const Obj &obj) 0x7ffee18a9a00  0x7ffee18a9a40 // 通过拷贝构造创建临时变量 (fun() 函数定义的 obj---> 临时对象)
in ~Obj() 0x7ffee18a9a00 // 析构 fun() 函数中构造的 obj 对象 (fun() 函数定义的 obj)
in Obj(const Obj &obj) 0x7ffee18a9a40 0x7ffee18a9a30 // 通过拷贝构造函数构建 obj(main 函数中的) 对象 (临时对象--->main() 函数定义的 obj)
in ~Obj() 0x7ffee18a9a40 // 释放临时对象
&obj is 0x7ffee18a9a30
in ~Obj() 0x7ffee18a9a30 // 释放 main() 函数中定义的 obj 对象

从上述输出, 我们可以看出, 总共 1 次构造函数, 2 次拷贝构造函数, 以及 3 次析构函数.

如果启用编译器返回值优化后, 输出如下:

in Obj()  0x7ffd9e16b2b0
&obj is 0x7ffd9e16b2b0
in ~Obj() 0x7ffd9e16b2b0

与 RVO 一样, 也可以通过 -fno-elide-constructors 来禁用 NRVO.

NRVO 优化后, 输出与 RVO 一致, 在下一节, 将通过分析实现原理来进行说明.

原理

从上述几节中, 我们可以看到, 编译器对返回值进行优化后, 减少了很多不必要的函数调用开销, 那么 (N)RVO 的原理到底是什么呢?

事实上, 返回值优化的原理是将返回一个类对象的函数的返回值当做该函数的参数来处理.

为了能够更加清晰明了的分析编译器 RVO 和 NRVO 的优化机制, 我们以下面代码为例, 如下:

Obj fun() {
  Obj obj(1);
  return obj;
}

int main() {
  Obj obj = fun();
  return 0;
}

可能会有人有疑问, 上面代码编译器是可以执行 NRVO 的, 为什么还可以 RVO 呢? 这是因为 NRVO 相比于 RVO, 是一种要求更为严格的优化方式, 编译器启用 NRVO 的前提条件是返回值是具名的, 但并不能说一段代码可以 NRVO 就不能 RVO.

本节的内容, 均是对于<<深度探索 C++对象模型>>的理解, 如果有误, 请私信或者在评论区讨论

RVO 原理

RVO 优化的原理是消除函数返回时产生的一次临时对象.

正如<<深度探索 C++对象模型>>中所述, 编译器会将返回值函数的原型进行调整, 编译器启用 RVO 优化, fun() 函数会变成如下:

void fun(Obj &_obj) {
  Obj obj(1);
  _obj.Obj::Obj(obj); // 拷贝构造函数
  return;
}

而 main 函数内的调用则会变成:

int main() {
  Obj obj; // 仅定义不构造
  fun(obj);
  return 0;
}

经过上述转换, 编译器将只调用一次构造函数和一次拷贝构造函数:

  • 构造函数:fun() 函数中局部对象的构造
  • 拷贝构造函数: 在 fun() 返回前用局部对象 obj 的值来拷贝构造传入的引用参数

经过上述优化, 消除了为保存返回值而创建的临时对象以及将该局部对象拷贝给该临时对象的一次拷贝构造调用.

虽然经过 RVO 优化后, 性能有了部分提升, 但是仍然存在一次拷贝构造, 那么, 在哪种场景下, RVO 能够彻底优化呢? 我们看下如下代码:

Obj fun() {
  return Obj(1);
}

int main() {
  Obj obj = fun();
  return 0;
}

编译会将 fun 函数优化为如下:

void fun(Obj &_obj) {
  _obj.Obj::Obj(1);
}

同样的, main 函数会优化为如下:

int main() {
  Obj obj;
  fun(obj);

  return 0;
}

上述两种代码都进行了 RVO 优化, 但是针对第一段代码, RVO 的优化并没起什么作用, 相反第二种代码块的优化优化的更彻底, 彻底的消除了拷贝构造.

NRVO 原理

在上面内容中, 我们讲述了在对一开始的代码进行了 RVO 优化, 但是并没有什么实质行的优化, 那么, 如果进行 NRVO 优化, 编译器会将上述代码优化成什么样子呢?

fun 函数会被优化成如下:

void fun(Obj &_obj) {
   _obj.Obj::Obj(1);
}

同样的, main 函数会优化为如下:

int main() {
  Obj obj;
  fun(obj);

  return 0;
}

fun 函数经过优化之后, 去掉了 RVO 优化遗留的拷贝构造问题, 达到了优化目标.

从上述代码可以看出, 同样一块代码, RVO 和 NRVO 的优化机制不同, 得到的优化效果也不同.

编译器的优化, 针对不同的场景, 采取不同的优化方式, 了解了这些, 方便我们更好的写出更为高效的代码.

优化失效

在前面几节中, 我们详细讲解了编译器在函数返回值中, 启用 (N)RVO 进行优化, 以提升程序性能, 但是, 编译器并非智能的, 对于某些复杂场景或者特殊场景, 是不会启用优化的, 这个时候就需要开发人员依赖具体的情况, 进行具体分析, 以达到优化的目的.

运行时依赖 (根据不同的条件分支, 返回不同变量)

当编译器无法单纯通过函数来决定返回哪个实例对象时, 会禁用 (N)RVO.

代码如下:

Obj fun(bool flag) {
  Obj o1;
  Obj o2;
  if (flag) {
    return o1;
  }
  return o2;
}

int main() {
  Obj obj = fun(true);
  return 0;
}

程序输出如下:

in Obj()  0x7ffd8cbf7c00 // 构造 o1 对象
in Obj()  0x7ffd8cbf7bf0 // 构造 o2 对象
in Obj(const Obj &obj) 0x7ffd8cbf7c00 0x7ffd8cbf7c30 // 通过拷贝构造, 将 o1 赋值给 obj(main 函数中的局部变量)
in ~Obj() 0x7ffd8cbf7bf0 // 析构 o2 对象
in ~Obj() 0x7ffd8cbf7c00 // 析构 o1 对象
in ~Obj() 0x7ffd8cbf7c30 // 析构 obj

但是, 下面这种情况例外, 虽然其仍然依赖于具体的条件判断:

Obj fun(bool flag) {
  Obj obj;
  if (flag) {
    return obj;
  }
  obj.n = 10;
  return obj;
}

int main() {
  Obj obj = fun(true);
  return 0;
}

输出如下:

in Obj() 0x7ffd8cbf7bf0
in ~Obj() 0x7ffd8cbf7bf0

这是因为, 对于单个对象以及多个函数出口的情况, 编译器将多个出口优化为一个.

返回全局变量

当返回的对象不是在函数内创建的时候, 是无法执行返回值优化的.

Obj g_obj;

Obj fun() {
  return g_obj;
}

int main() {
  Obj obj = fun();
  std::cout << &obj << std::endl;
  return 0;
}

编译并运行, 结果如下:

in Obj()  0x6013b4 // 构造全局变量
in Obj(const Obj &obj) 0x6013b4 0x7ffc8abe14e0 // 构造 main 中的局部对象
0x7ffc8abe14e0
in ~Obj() 0x7ffc8abe14e0 // 析构 main 中的局部对象
in ~Obj() 0x6013b4 // 析构全局变量

返回函数参数

与返回全局变量类似, 当返回的对象不是在函数内创建的时候, 是无法执行返回值优化的.

代码如下:

Obj fun(Obj obj) {
  return obj;
}

int main() {
  Obj o;
  Obj obj = fun(o);
  std::cout << "in main " << &obj << std::endl;
  return 0;
}

编译并运行之后, 输出:

in Obj() 0x7ffdbb43da00
in Obj(const Obj &obj) 0x7ffdbb43da00 0x7ffdbb43da10
in Obj(const Obj &obj) 0x7ffdbb43da10 0x7ffdbb43d9f0
in ~Obj() 0x7ffdbb43da10
in main 0x7ffdbb43d9f0
in ~Obj() 0x7ffdbb43d9f0
in ~Obj() 0x7ffdbb43da00

返回成员变量

在某些特殊情况下, 即使是未具名变量, 也不能 RVO.

代码如下:

struct Wraper {
   Obj obj;
 };

Obj fun() {
  return Wraper().obj;
}

int main() {
  Obj obj = fun();
  std::cout << &obj << std::endl;
  return 0;
}

编译并运行, 结果如下:

in Obj()  0x7ffed7f85290 // 构造 Wraper 中的 obj 对象
in Obj(const Obj &obj) 0x7ffed7f85290 0x7ffed7f852c0 // 通过拷贝赋值给 main 函数中的局部变量
in ~Obj() 0x7ffed7f85290 // 析构 Wraper 中的 obj 对象
0x7ffed7f852c0
in ~Obj() 0x7ffed7f852c0 // 析构 main 中的局部对象

存在赋值行为

(N)RVO 只能在从返回值创建对象时发送, 在现有对象上使用 operator=而不是拷贝/移动构造函数, 这样是不会进行 RVO 操作的.

代码如下:

Obj fun() {
  return Obj();
}

int main() {
  Obj obj;
  obj = fun();
  return 0;
}

编译并运行之后, 输出:

in Obj()  0x7ffd7a100cf0 // 构造 main() 函数中的 obj 对象
in Obj()  0x7ffd7a100d00 // 生成临时对象
in operator=(const Obj &obj) 0x7ffd7a100d00 0x7ffd7a100cf0 // 调用赋值函数, 将临时变量赋值给目标 (main 函数中的局部对象 obj)
in ~Obj() 0x7ffd7a100d00 // 析构临时对象
in ~Obj() 0x7ffd7a100cf0 // 析构 main 中的局部对象

使用 std::move() 返回

在返回值上调用 std::move() 进行返回是一种错误的方式. 它会尝试强制调用移动构造函数, 但这样会导致 RVO 失效. 因为即使没有显示调用 std::move(), 编译器优化中也会执行 move 操作.

代码如下:

Obj fun() {
  Obj obj;
  return std::move(obj);
}

int main() {
  Obj obj = fun();
  return 0;
}

输出如下:

in Obj()  0x7ffe7d4d1720
in Obj(const Obj &&obj)
in ~Obj() 0x7ffe7d4d1720
0x7ffe7d4d1750
in ~Obj() 0x7ffe7d4d1750

从上面输出可以看出, 与不使用 std::move() 返回相比, 使用 std::move() 返回增加了一次拷贝构造调用和一次析构调用.

经验之谈
之前看过一篇文章, 也是 (N)RVO 相关的, 当时作者观点是优化依赖于编译器, 而作者同事的观点则是依赖代码实现来实现优化, 当时给出的例子如下:

class BigObject {
// 定义
};

BigObject fun() {
  BigObject obj;
  // do sth
  return obj;
}

是不是似曾相识? 类似于我们前面 NVRO 中的代码示例.

作者的观点是此种代码满足编译器 NVRO 优化的条件, 所以不需要优化; 而作者同事的观点则是, 不能严格依赖编译器, 所以作者同事的优化建议如下:

void fun(BigObject &obj) {
  // do sth with obj
}

在此, 我说下自己的观点吧:

代码优化不应该依赖编译器, 因为无法保证在其他编译器下就能得出跟当前类似的优化效果.

依赖编译器优化的前提是开发人员了解编译器的优化机制或者说开发人员知道写怎样的代码能达到编译器优化的标准, 但是, 如果部门入职的新人接手了这块工作, 在开发过程中, 发现老代码都是直接返回 (也就是作者口中的编译器优化), 然后新人也直接返回 (即使编译器不会进行优化, 例如前面的返回全局变量等), 这样恰恰得到相反的结果.

所以, 总的来说, 我倾向于作者同事的优化方案. 对于 char, int, double 等元类型, 在函数中直接返回; 而对于需要返回 struct, class 类型的函数, 则直接作为函数入参, 在函数内部进行初始化.

当然了, 上面仅仅是我的个人观点, 至于使用编译器优化还是上述引用传参的方式, 则依赖于开发者的个人喜好或者团队的代码风格. 但是需要注意的是, 如果使用编译器优化, 则需要小心小心再小心, 否则就会导致事倍功半的效果, 进而导致程序性能损失.

结语

(N)RVO 是编译器对于函数返回值的一种优化技术, 旨在消除临时对象的创建. 了解编译器的优化, 可以提升我们的程序运行效率, 但是需要注意的是, 如果单纯依赖编译器优化, 可能会导致某些我们意想不到的情况发生. 所以, 在使用编译器优化方式之前, 我们需要保证代码的实现方式能够启用 RVO 优化.

什么时候应当依靠返回值优化 (RVO)? - 高性能架构探索的回答 - 知乎
https://www.zhihu.com/question/27000013/answer/2401047421

file

rainbow

这个人很懒,什么都没留下

文章评论