在前面的文章《深入理解C++中的move和forward》中,我们提到了为了避免对数据进行复制,在C++11中增加了move语义;
本文继续这个话题,来聊聊关于编译器优化中的RVO;
关于前一篇文章:
源代码:
C++编译器优化中的RVO和NRVO 简述 RVO 即 “Return Value Optimization”,是一种编译器优化技术,通过该技术编译器可以减少函数返回时生成临时值(对象)的个数,从某种程度上可以提高程序的运行效率,对需要分配大量内存的类对象其值复制过程十分友好;
NRVO 全称为 “Named Return Value Optimization”,该优化的大致流程与 RVO 类似;
只是单纯这么说显得比较空洞,下面来看一个具体的例子;
一个RVO优化的例子 来看下面这个例子:
rvo.cc
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 #include <iostream> class A {public : A () { std::cout << "[C] constructor fired." << std::endl; } A (const A &a) { std::cout << "[C] copying constructor fired." << std::endl; } A (A &&a) noexcept { std::cout << "[C] moving copying constructor fired." << std::endl; } ~A () { std::cout << "[C] destructor fired." << std::endl; } }; A getTempA () { return A{}; } int main (int argc, char **argv) { auto x = getTempA (); return 0 ; }
我们声明了一个类 A,并重载了它的:
构造函数;
复制构造函数;
移动构造函数;
析构函数;
在 main 函数中,我们调用 getTempA(),返回一个类A的对象;
我们首先使用下面的命令编译并执行代码:
1 2 g++ rvo.cc -o rvo --std=c++11 && ./rvo
输出如下所示:
1 2 # [C] constructor fired. # [C] destructor fired.
可以看到:这里一共只执行了一次构造函数和一次析构函数,对于临时对象的拷贝构造过程并没有被进行!
这是因为:在正常情况下,编译器一般会默认启用 RVO 优化;
接下来使用下面命令重新编译并执行上述代码:
1 2 g++ rvo.cc -o rvo --std=c++11 -fno-elide-constructors && ./rvo
这里我们关闭了 RVO 优化,对应使用的编译器参数是 “-fno-elide-constructors” :
1 2 g++ rvo.cc -o rvo --std=c++11 -fno-elide-constructors && ./rvo
不仅仅是 g++ 编译器,对于其他主流编译器(比如,clang等)都是类似的!
输出如下所示:
1 2 3 4 5 6 [C] constructor fired. [C] moving copying constructor fired. [C] destructor fired. [C] moving copying constructor fired. [C] destructor fired. [C] destructor fired.
可以看到,这里一共执行了两次移动构造的过程,分别发生于:
函数 “getTempA” 的返回值移动构造给临时值对象;
以及,临时值对象移动构造给变量 “x” 这两个时刻;
实际上,RVO 以及 NRVO 两种优化技术又被统称为 “copy_elision(复制消除)” 优化;
C++11 标准中规定,在以下两种场景下要求,编译器省略类对象的复制和移动构造,即使复制/移动构造函数和析构函数拥有可观察副作用!而直接将对象构造到它们本来要复制/移动到的存储中;
只要语言确保不发生复制/移动操作,复制/移动构造函数就不必存在或可访问!
这两种场景分别是:
在 “return” 语句中,当操作数为与函数返回类型为同一类类型的纯右值时;
在变量的初始化中,当初始化器表达式为与变量类型为同一类类型的纯右值时:
场景一即为我们之前示例代码中的场景;
下面展示第二种场景;
实际上,我们只需要修改 main 函数中变量 x 的初始化表达式即可:
1 2 3 4 5 6 int main(int argc, char **argv) { -- auto x = getTempA(); ++ auto x = A(A(A(getTempA()))); return 0; }
当编译器开启 RVO 优化时,程序同样只调用了一次构造函数和一次析构函数,此时输出为:
1 2 [C] constructor fired. [C] destructor fired.
当关闭了 RVO 优化时的输出则如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 [C] constructor fired. [C] moving copying constructor fired. [C] destructor fired. [C] moving copying constructor fired. [C] moving copying constructor fired. [C] moving copying constructor fired. [C] moving copying constructor fired. [C] destructor fired. [C] destructor fired. [C] destructor fired. [C] destructor fired. [C] destructor fired.
可以看到,移动构造和析构函数被疯狂的调用;
这时产生的五次拷贝构造过程分别是:
函数 “getTempA” 返回值拷贝构造给临时值对象;
临时值对象作为引用参数被类 A 的拷贝构造函数调用,生成一个 A 的临时值对象;
临时值对象作为引用参数被类 A 的拷贝构造函数调用,生成一个 A 的临时值对象;
临时值对象作为引用参数被类 A 的拷贝构造函数调用,生成一个 A 的临时值对象;
临时值对象最后拷贝构造给变量 “x”;
需要注意的是:RVO(和下面的NRVO)可能导致优化和非优化程序之间的不同行为!
毕竟某些代码被优化掉了,因此应当尽量避免在这些被优化的构造函数中增加tricky的逻辑!
再来看看NRVO优化 NRVO 与 RVO 的不同之处在于:函数返回的临时值是否是具名的;
更加官方的定义为:
当操作数是拥有自动存储期的非 volatile 对象的字段,并且非函数形参或 catch 子句形参,且其具有与函数返回类型相同的类型时,此时仍然可以避免对象复制;同时,这种复制消除的变体被称为 NRVO;
同样的,来看下面这个例子:
nrvo.cc
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 #include <iostream> class RVO {public : RVO () { printf ("I am in constructor\n" ); } RVO (const RVO &c_RVO) { printf ("I am in copy constructor\n" ); } ~RVO () { printf ("I am in destructor\n" ); } int mem_var{}; }; RVO MyMethod (int i) { RVO rvo; rvo.mem_var = i; return (rvo); } int main () { RVO rvo; rvo = MyMethod (5 ); }
上面的代码在没有进行 NRVO 的情况下输出为:
1 2 3 4 5 6 I am in constructor I am in constructor I am in copy constructor I am in destructor I am in destructor I am in destructor
使用 NRVO 优化后的输出将是:
1 2 3 4 I am in constructor I am in constructor I am in destructor I am in destructor
下面来分析 NRVO 到底做了什么;
当我们未开启 NRVO 时,上面 MyMethod 函数会执行类似于下面的代码:
1 2 3 4 5 6 7 RVO&& MyMethod (int i) { RVO rvo = RVO (); rvo.mem_var = i; _rvo = RVO (&rvo); return _rvo; rvo.~RVO () }
从上面的代码可以看出,有一些优化的机会;
其基本思想是消除基于栈的临时值并使用隐藏的参数;因此,这将消除基于栈的值的拷贝构造函数和析构函数;
当开启了 NRVO 后,代码如下:
1 2 3 4 5 RVO&& MyMethod (int i) { RVO rvo = RVO (); rvo.mem_var = i; return rvo; }
一个更复杂的例子 下面给出了一个更复杂的例子:
nrvo_2.cc
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 #include <iostream> class A {public : A () { printf ("A: I am in constructor\n" ); i = 1 ; } ~A () { printf ("A: I am in destructor\n" ); i = 0 ; } A (const A &a) { printf ("A: I am in copy constructor\n" ); i = a.i; } int i, x{}, w{}; }; class B {public : A a; B () { printf ("B: I am in constructor\n" ); } ~B () { printf ("B: I am in destructor\n" ); } B (const B &b) { printf ("B: I am in copy constructor\n" ); } }; A MyMethod () { B *b = new B (); A a = b->a; delete b; return (a); } int main () { A a; a = MyMethod (); }
无 NRVO 时输出如下:
1 2 3 4 5 6 7 8 9 10 A: I am in constructor A: I am in constructor B: I am in constructor A: I am in copy constructor B: I am in destructor A: I am in destructor A: I am in copy constructor A: I am in destructor A: I am in destructor A: I am in destructor
当 NRVO 启动时,输出是:
1 2 3 4 5 6 7 8 A: I am in constructor A: I am in constructor B: I am in constructor A: I am in copy constructor B: I am in destructor A: I am in destructor A: I am in destructor A: I am in destructor
优化限制 根据之前所述,在某些情况下即使我们启用了 NRVO 优化,优化也不会真正启动;
异常示例 在遇到异常时,隐藏的参数必须在它正在替换的临时范围内被破坏;
nrvo_with_exception.cc
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 #include <cstdio> class RVO {public : RVO () { printf ("I am in constructor\n" ); } RVO (const RVO &c_RVO) { printf ("I am in copy constructor\n" ); } ~RVO () { printf ("I am in destructor\n" ); } int mem_var{}; }; RVO MyMethod (int i) { RVO rvo; rvo.mem_var = i; throw "I am throwing an exception!" ; return (rvo); } int main () { RVO rvo; try { rvo = MyMethod (5 ); } catch (const char *str) { printf ("I caught the exception\n" ); } return 0 ; }
如果没有NRVO,输出是:
1 2 3 4 5 I am in constructor I am in constructor I am in destructor I caught the exception I am in destructor
如果“抛出异常代码”被注释掉,输出将是:
1 2 3 4 5 6 I am in constructor I am in constructor I am in copy constructor I am in destructor I am in destructor I am in destructor
现在,如果“抛出异常代码”被注释掉,并且开启了NRVO,输出将如下所示:
1 2 3 4 I am in constructor I am in constructor I am in destructor I am in destructor
也就是说在没有NRVO的情况下,会表现出相同的行为;
不同的命名对象示例 若要使用 NRVO,要求所有退出路径必须返回同一个命名对象!
来看下面的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 #include <cstdio> class RVO {public : RVO () { printf ("I am in constructor\n" ); } RVO (const RVO &c_RVO) { printf ("I am in copy constructor\n" ); } int mem_var{}; }; RVO MyMethod (int i) { RVO rvo; rvo.mem_var = i; if (rvo.mem_var == 10 ) return (RVO ()); return (rvo); } int main () { RVO rvo; rvo = MyMethod (5 ); }
无论是否启用 NRVO 结果都是相同的:
1 2 3 I am in constructor I am in constructor I am in copy constructor
这是因为并非所有返回都返回相同的对象;
如果将上面的示例更改为直接返回 rvo 对象,则在返回时,优化将消除复制构造函数:
1 2 3 4 5 6 7 8 RVO MyMethod(int i) { RVO rvo; rvo.mem_var = i; if (rvo.mem_var == 10) -- return (RVO()); ++ return rvo; return rvo; }
此时输出为:
1 2 I am in constructor I am in constructor
优化副作用 前面提到,由于我们在编译优化时,会将构造函数直接优化掉,因此:
如果在构造函数中存在一些逻辑,则会直接被优化掉!
以下面的代码为例:
nrvo_side_effects.cc
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 #include <cstdio> int NumConsCalls = 0 ;int NumCpyConsCalls = 0 ;class RVO {public : RVO () { NumConsCalls++; } RVO (const RVO &c_RVO) { NumCpyConsCalls++; } }; RVO MyMethod () { RVO rvo; return (rvo); } int main () { RVO rvo; rvo = MyMethod (); int Division = NumConsCalls / NumCpyConsCalls; printf ("Constructor calls / Copy constructor calls = %d\n" , Division); return 0 ; }
当编译未启用优化时,代码如我们所期望的,“构造函数”被调用两次,“拷贝构造函数”被调用一次;
因此输出结果为:
1 Constructor calls / Copy constructor calls = 2
但是,如果上面的代码启用 NRVO,则“拷贝构造函数”调用将被删除;
因此,此时 NumCpyConsCalls 为零,将导致异常:
1 Constructor calls / Copy constructor calls = 0
Appendix 关于前一篇文章:
源代码:
文章参考: