深入理解C++中的RVO
前言
考虑存在这样一个类如HeavyObject,其拷贝赋值操作比较耗时,通常你在使用函数返回这个类的一个对象时会习惯使用哪一种方式?或者会根据具体场景选择某一种方式?
// style 1 HeavyObject func(Args param); // style 2 bool func(HeavyObject* ptr, Args param);
上面的两种方式都能过到同样的目的,但直观上的使用体验的差别也是非常明显的:
style 1只需要一行代码,而style 2需要两行代码
// style 1 HeavyObject obj = func(params); // style 2 HeavyObject obj; func(&obj, params);
但是,能达到同样的目的,消耗的成本却未必是一样的,这取决于多个因素,比如编译器支持的特性、C++语言标准的规范强制性、多团队多环境开发等等。
看起来style 2虽然使用时需要写两行代码,但函数内部的成本却是确定的,只会取决于你当前的编译器,外部即使采用不同的编译器进行函数调用,也并不会有多余的时间开销和稳定性问题。比如func内部使用clang+libc++编译,外部调用的编译环境为gcc+gnustl或者vc++,除了函数调用开销,不用担心其它性能开销以及由于编译环境不同会崩溃问题。
因此这里我主要剖析一下style 1背后开发者需要关注的点。
RVO
RVO是Return Value Optimization的缩写,即返回值优化,NRVO就是具名的返回值优化,为RVO的一个变种,此特性从C++11开始支持,也就是说C++98、C++03都是没有将此优化特性写到标准中的,不过少量编译器在开发过程中也会支持RVO优化(如IBM Compiler?),比如微软是从Visual Studio 2010才开始支持的。
仍然以上述的HeavyObject类为例,为了更清晰的了解编译器的行为,这里实现了构造/析构及拷贝构造、赋值操作、右值构造函数,如下
class HeavyObject { public: HeavyObject() { cout << "Constructor\n"; } ~HeavyObject() { cout << "Destructor\n"; } HeavyObject(HeavyObject const&) { cout << "Copy Constructor\n"; } HeavyObject& operator=(HeavyObject const&) { cout << "Assignment Operator\n"; return *this; } HeavyObject(HeavyObject&&) { cout << "Move Constructor\n"; } private: // many members omitted... };
编译环境:
AppleClang 10.0.1.10010046
* 第一种使用方式
HeavyObject func() { return HeavyObject(); } // call HeavyObject o = func();
按照以往对C++的理解,HeavyObject类的构造析构顺序应该为
Constructor
Copy Constructor
Destructor
Destructor
但是实际运行后的输出结果却为
Constructor
Destructor
实际运行中少了一次拷贝构造和析构的开销,编译器帮助我们作了优化。
于是我反汇编了一下:
0000000100000f60 <__Z4funcv>: 100000f60: 55 push %rbp 100000f61: 48 89 e5 mov %rsp,%rbp 100000f64: 48 83 ec 10 sub $0x10,%rsp 100000f68: 48 89 f8 mov %rdi,%rax 100000f6b: 48 89 45 f8 mov %rax,-0x8(%rbp) 100000f6f: e8 0c 00 00 00 callq 100000f80 <__ZN11HeavyObjectC1Ev> 100000f74: 48 8b 45 f8 mov -0x8(%rbp),%rax 100000f78: 48 83 c4 10 add $0x10,%rsp 100000f7c: 5d pop %rbp 100000f7d: c3 retq 100000f7e: 66 90 xchg %ax,%ax
上述汇编代码中的__Z4funcv即func()函数,__ZN11HeavyObjectC1Ev即HeavyObject::HeavyObject()。
不同编译器的C++修饰规则略有不同。
实际上这里就是先创建外部的对象,再将外部对象的地址作为参数传给函数func,类似style 2方式。
* 第二种使用方式
HeavyObject func() { HeavyObject o; return o; } // call HeavyObject o = func();
运行上述调用代码的结果为
Constructor
Destructor
与第一种使用方式的结果相同,这里编译器实际做了NRVO,来看一下反汇编
0000000100000f40 <__Z4funcv>: // func() 100000f40: 55 push %rbp 100000f41: 48 89 e5 mov %rsp,%rbp 100000f44: 48 83 ec 20 sub $0x20,%rsp 100000f48: 48 89 f8 mov %rdi,%rax 100000f4b: c6 45 ff 00 movb $0x0,-0x1(%rbp) 100000f4f: 48 89 7d f0 mov %rdi,-0x10(%rbp) 100000f53: 48 89 45 e8 mov %rax,-0x18(%rbp) 100000f57: e8 24 00 00 00 callq 100000f80 <__ZN11HeavyObjectC1Ev> // HeavyObject::HeavyObject() 100000f5c: c6 45 ff 01 movb $0x1,-0x1(%rbp) 100000f60: f6 45 ff 01 testb $0x1,-0x1(%rbp) 100000f64: 0f 85 09 00 00 00 jne 100000f73 <__Z4funcv+0x33> 100000f6a: 48 8b 7d f0 mov -0x10(%rbp),%rdi 100000f6e: e8 2d 00 00 00 callq 100000fa0 <__ZN11HeavyObjectD1Ev> // HeavyObject::~HeavyObject() 100000f73: 48 8b 45 e8 mov -0x18(%rbp),%rax 100000f77: 48 83 c4 20 add $0x20,%rsp 100000f7b: 5d pop %rbp 100000f7c: c3 retq 100000f7d: 0f 1f 00 nopl (%rax)
从上面的汇编代码可以看到返回一个具名的本地对象时,编译器优化操作如第一种使用方式一样直接在外部对象的指针上执行构造函数,只是如果构造失败时还会再调用析构函数。
以上两种使用方式编译器所做的优化非常相近,两种方式的共同点都是返回本地的一个对象,那么当本地存在多个对象且需要根据条件选择返回某个对象时结果会是如何呢?
* 第三种使用方式
HeavyObject dummy(int index) { HeavyObject o[2]; return o[index]; } // call HeavyObject o = dummy(1);
运行后的结果为
Constructor
Constructor
Copy Constructor
Destructor
Destructor
Destructor
从运行的结果可以看到没有做RVO优化,此时调用了拷贝构造函数。
从上述三种实现方式可以看到,如果你的函数实现功能比较单一,比如只会对一个对象进行操作并返回时,编译器会进行RVO优化;如果函数实现比较复杂,可能会涉及操作多个对象并不确定返回哪个对象时,编译器将不做RVO优化,此时函数返回时会调用类的拷贝构造函数。
但是,当只存在一个本地对象时,编译器一定会做RVO优化吗?
* 第四种使用方式
HeavyObject func() { return std::move(HeavyObject()); } // call HeavyObject o = func();
实际运行输出的结果是
Constructor
Move Constructor
Destructor
Destructor
上述的函数实现直接返回临时对象的右值引用,从实际的运行结果来看调用了Move构造函数,与第一种使用方式运行的结果明显不同,并不是我期望的只调用一次构造函数和析构函数,也就是说编译器没有做RVO。
* 第五种使用方式
HeavyObject func() { HeavyObject o; return static_cast<HeavyObject&>(o); } // call HeavyObject o = func();
实际运行输出的结果是
Constructor
Copy Constructor
Destructor
Destructor
上述的函数实现直接返回本地对象的引用,实际运行结果仍然调用了拷贝构造函数,并不是期望的只调用一次构造和析构函数,也就是说编译器并没有做RVO。
从上述两种使用方式可以看到,当返回一个对象时且对象类型与返回类型不一致时,编译器将不做RVO。实际上C++标准文档中有如下描述:
in a return statement in a function with a class return type, when the expression is the name of a non-volatile automatic object (other than a function or catch-clause parameter) with the same cv-unqualified type as the function return type, the copy/move operation can be omitted by constructing the automatic object directly into the function’s return value
总结
- 两种style代码的性能可能会不一样,当你非常确定你的代码的开发环境及编译器的支持特性如RVO,以及使用者的接入环境时,建议使用style 1,否则建议使用style 2
- RVO的编译器优化特性需要相对比较严格的限制,使用style 1时,较复杂的函数实现可能并不会如你期望的使用RVO优化

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
java建造者模式(builder)
建造者模式(Builder)用以自由构建对象,主要功能就是代替对象的构造函数,更加自由化。 案例: /** * @author Gjing **/ class Custom { private Integer age; private String name; private String address; static Custom.Builder builder(){ return new Custom.Builder(); } private Custom(Builder builder) { this.age = builder.age; this.name = builder.name; this.addr
- 下一篇
Java软件开发常出现哪些异常?要怎么处理?
异常处理是Java软件开发中的一个重要部分,它是关乎每个应用的一个非功能性需求,是为了处理任何错误状况,比如资源不可访问,非法输入,空输入等等,Java提供了几个异常处理特性,以try,catch和finally关键字的形式内建于语言自身之中,Java编程语言也允许你创建新的异常,并通过使用throw和throws关键字抛出它们,事实上,在Java编程中,Java的异常处理不单单是知道语法这么简单,它必须遵循标准的JDK库,和几个处理错误和异常的开源代码,这里我们将讨论一些关于异常处理的Java最佳实践。 Java软件开发常出现哪些异常?要怎么处理?.jpg 为可恢复的错误使用检查型异常,为编程错误使用非检查型错误 选择检查型还是非检查型异常,对于Java编程人员来说,总是让人感到困惑。检查型异常保证你对错误条件提供异常处理代码,这是一种从语言到强制你编写健壮的代码的一种方式,但同时会引入大量杂乱的代码并导致其不可读。当然,如果你有替代品和恢复策略的话,捕捉异常并做些什么看起来似乎也在理,在Java编程中选择检查型异常还是运行时异常。 在finally程序块中关闭或者释放资源 这在Ja...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- CentOS8安装Docker,最新的服务器搭配容器使用
- Linux系统CentOS6、CentOS7手动修改IP地址
- CentOS7安装Docker,走上虚拟化容器引擎之路
- Docker快速安装Oracle11G,搭建oracle11g学习环境
- Docker安装Oracle12C,快速搭建Oracle学习环境
- Docker使用Oracle官方镜像安装(12C,18C,19C)
- CentOS7,8上快速安装Gitea,搭建Git服务器
- MySQL8.0.19开启GTID主从同步CentOS8
- SpringBoot2配置默认Tomcat设置,开启更多高级功能
- CentOS8编译安装MySQL8.0.19