深入理解C++中的RVO

简介: ## 前言 考虑存在这样一个类如HeavyObject,其拷贝赋值操作比较耗时,通常你在使用函数返回这个类的一个对象时会习惯使用哪一种方式?或者会根据具体场景选择某一种方式? ```c // style 1 HeavyObject func(Args param); // style ...

前言

考虑存在这样一个类如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优化
目录
相关文章
|
2月前
|
安全 编译器 程序员
【C++篇】C++类与对象深度解析(六):全面剖析拷贝省略、RVO、NRVO优化策略
【C++篇】C++类与对象深度解析(六):全面剖析拷贝省略、RVO、NRVO优化策略
49 2
|
存储 编译器 程序员
C++之RVO返回值优化
C++进阶之RVO
148 0
|
编译器 C++ iOS开发
【C++ 语言】面向对象 ( 函数重载 | 运算符重载 | 运算符重载两种定义方式 | 拷贝构造方法 | RVO 优化 | NRVO 优化 )(二)
【C++ 语言】面向对象 ( 函数重载 | 运算符重载 | 运算符重载两种定义方式 | 拷贝构造方法 | RVO 优化 | NRVO 优化 )(二)
161 0
|
C++
【C++ 语言】面向对象 ( 函数重载 | 运算符重载 | 运算符重载两种定义方式 | 拷贝构造方法 | RVO 优化 | NRVO 优化 )(一)
【C++ 语言】面向对象 ( 函数重载 | 运算符重载 | 运算符重载两种定义方式 | 拷贝构造方法 | RVO 优化 | NRVO 优化 )(一)
199 0
C++返回值优化RVO
返回值优化,是一种属于编译器的技术,它通过转换源代码和对象的创建来加快源代码的执行速度。RVO = return value optimization。测试平台:STM32F103VG + Keil 5.
1554 0
|
15天前
|
存储 编译器 C语言
【c++丨STL】string类的使用
本文介绍了C++中`string`类的基本概念及其主要接口。`string`类在C++标准库中扮演着重要角色,它提供了比C语言中字符串处理函数更丰富、安全和便捷的功能。文章详细讲解了`string`类的构造函数、赋值运算符、容量管理接口、元素访问及遍历方法、字符串修改操作、字符串运算接口、常量成员和非成员函数等内容。通过实例演示了如何使用这些接口进行字符串的创建、修改、查找和比较等操作,帮助读者更好地理解和掌握`string`类的应用。
25 2
|
21天前
|
存储 编译器 C++
【c++】类和对象(下)(取地址运算符重载、深究构造函数、类型转换、static修饰成员、友元、内部类、匿名对象)
本文介绍了C++中类和对象的高级特性,包括取地址运算符重载、构造函数的初始化列表、类型转换、static修饰成员、友元、内部类及匿名对象等内容。文章详细解释了每个概念的使用方法和注意事项,帮助读者深入了解C++面向对象编程的核心机制。
54 5
|
27天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
56 4
|
28天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
66 4