C++复制消除与RVO/NRVO

Share on:
800 Words | Read in about 4 Min | View times

Overview

C++11标准明确提出了复制消除(copy elision)优化技术,复制消除要求编译器在满足一些特定条件时,省略类对象的拷贝和移动构造,以达到优化效果。复制消除主要发生在两种条件下,即函数参数是值语义以及函数返回值是值语义。而RVO和NRVO是函数返回值优化的两种技术,NRVO从C++11开始加入C++标准。

本文我们将讨论的主题围绕函数返回值优化,先了解RVO和NRVO在不同的现代C++版本中,启用和关闭编译器优化之后各自的表现;接着我们将分析RVO和NRVO的实现原理;最后聊一下优化失效的情形。

本系列文章将包括以下领域:

本章其他内容请见 《现代C++》

《C++移动语义、万能引用、引用折叠、完美转发》这篇文章中,我们有提到过RVO和NRVO优化技术。它们都是复制消除技术的两种方式。那么什么是复制消除呢?

什么是复制消除?

复制消除是一种编译器的优化技术,使编译器省略掉类对象的拷贝和移动构造函数,编译器代码会自己创建一个临时对象来处理。

复制消除主要发生在两个场景:

  • 函数参数是值语义

  • 函数返回值是值语义

目的是减少函数返回过程或函数传参时的拷贝或移动的次数。在C++17之前,复制消除是编译器的可选选项,默认是开启的,但可以关闭优化;而C++17之后,复制消除是编译器标准设置,始终开启无法关闭优化。

函数值传递复制消除

函数值传递和函数返回是发生复制消除的两个重要场景。本文的重点是函数返回优化技术,对于函数值传递的优化,我们只是简单地看个例子熟悉一下:

当函数参数传入右值时,也会发生复制消除。

 1void pass_by_value(Foo foo) {
 2    //...
 3}
 4
 5int main() {
 6    //...
 7    pass_by_value(Foo{});
 8    pass_by_value(std::move(foo));
 9    return 0;
10}

以上情况值传递的参数都不会发生拷贝构造。

函数返回机制

了解函数返回机制可以帮助我们更好地理解本文所讲的内容。

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

  • 当返回的对象大小不超过8字节,通过寄存器(eax/edx)返回

  • 当返回的对象大小超过8字节,通过栈返回

另外如果返回值是structclass,不管返回对象的大小,一律通过栈返回。

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

RVO

RVO,Return Value Optimization,返回值优化,通过该技术编译器可以减少函数返回时生成临时对象的个数,从某种程度上可以提高程序的运行效率。

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

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

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

接下来我们将使用以下例子来说明开启优化与关闭优化的效果:

 1class A {
 2public:
 3    A() { std::cout << "this(" << this << ") A::A()" << std::endl; }
 4    A(const A& a) { std::cout << "&a(" << &a << ") -> this(" << this << ") A::A(const A&)" << std::endl; }
 5    ~A() { std::cout << "this(" << this << ") A::~A()" << std::endl; }
 6};
 7
 8//RVO
 9A get_a_rvo() {
10    return A();
11}
12
13//NRVO
14A get_a_nrvo() {
15    A a;
16    return a;
17}

我们先看RVO的情况:

1int main() {
2    A a = get_a_rvo();
3    return 0;
4}

我们在《C++移动语义、万能引用、引用折叠、完美转发》这篇文章中提到过可以使用gcc的编译选项来关闭编译器优化:-fno-elide-constructors

C++17之前的RVO

以C++11为例,验证RVO是编译器的可选选项,可以关闭优化。

  • 默认情况下开启RVO优化
1g++ -std=c++11 main.cpp -o main

看一下运行效果:

1this(0x7ffe2da44d77) A::A()
2this(0x7ffe2da44d77) A::~A()

全程只有一次构造函数的调用,全程都使用同一个对象地址。

  • 关闭RVO优化
1g++ -std=c++11 main.cpp -o main -fno-elide-constructors

看一下运行效果:

1this(0x7fff79d9a7a7) A::A() #---> 先构造第一个对象
2&a(0x7fff79d9a7a7) -> this(0x7fff79d9a7d7) A::A(const A&) #---> return语句,将第一个对象拷贝构造给第二个对象
3this(0x7fff79d9a7a7) A::~A() #---> 函数返回,销毁第一个对象
4&a(0x7fff79d9a7d7) -> this(0x7fff79d9a7d6) A::A(const A&) #---> main函数初始化A,将第二个对象拷贝构造给第三个对象
5this(0x7fff79d9a7d7) A::~A() #---> A初始化完成,销毁第二个对象
6this(0x7fff79d9a7d6) A::~A() #---> main结束,销毁第三个对象

实验证明,对于C++11,编译器默认是开启RVO优化的,并且可以关闭。关闭后,在函数返回时,需要先将对象拷贝给函数返回临时对象,调用了一次拷贝构造函数;函数返回到main后,又需要调用一次拷贝构造函数初始化对象a

C++17之后的RVO

以C++17为例,验证RVO是编译器的必选选项,不可以关闭优化。

  • 默认情况下开启RVO优化
1g++ -std=c++17 main.cpp -o main

看一下运行效果:

1this(0x7ffc14e25977) A::A()
2this(0x7ffc14e25977) A::~A()
  • 关闭RVO优化
1g++ -std=c++17 main.cpp -o main -fno-elide-constructors

看一下运行效果:

1this(0x7ffd5b94e417) A::A()
2this(0x7ffd5b94e417) A::~A()

实验证明,对于C++17,编译器是强制开启RVO优化的,关闭编译选项也不会关闭RVO优化。

NRVO

NRVO,Named Return Value Optimization,具名返回值优化,是C++11引入的一种复制消除技术,它是RVO的一种变体,因为在之前编译器还不支持具名返回值的优化。

当操作数拥有自动存储期的非volatile对象的名字,且并非函数形参或catch子句的形参,且其具有与函数返回类型相同的类类型时,这种复制消除的变体被称为NRVO。

既然函数返回的是具名对象,说明对象在return之前已经构造完成。

还是同样的例子,我们来看NRVO的情况:

1int main() {
2    A a = get_a_nrvo();
3    return 0;
4}

C++17之前的NRVO

以C++11为例,验证NRVO是编译器的可选选项,可以关闭优化。

  • 默认情况下开启NRVO优化
1g++ -std=c++11 main.cpp -o main

看一下运行效果:

1this(0x7fffffcac657) A::A()
2this(0x7fffffcac657) A::~A()
  • 关闭RVO优化
1g++ -std=c++11 main.cpp -o main -fno-elide-constructors

看一下运行效果:

1this(0x7ffd62541737) A::A()
2&a(0x7ffd62541737) -> this(0x7ffd62541767) A::A(const A&)
3this(0x7ffd62541737) A::~A()
4&a(0x7ffd62541767) -> this(0x7ffd62541766) A::A(const A&)
5this(0x7ffd62541767) A::~A()
6this(0x7ffd62541766) A::~A()

实验证明,对于C++11,编译器默认是开启NRVO优化的,可以关闭。

C++17之后的NRVO

以C++17为例,验证NRVO是编译器的必选选项,不可以关闭优化。

  • 默认情况下开启NRVO优化
1g++ -std=c++17 main.cpp -o main

看一下运行效果:

1this(0x7fffa1916397) A::A()
2this(0x7fffa1916397) A::~A()
  • 关闭RVO优化
1g++ -std=c++17 main.cpp -o main -fno-elide-constructors

看一下运行效果:

1this(0x7ffdeb4c06a7) A::A() #---> 构造第一个对象
2&a(0x7ffdeb4c06a7) -> this(0x7ffdeb4c06d7) A::A(const A&) #---> return语句,构造第二个对象
3this(0x7ffdeb4c06a7) A::~A() #---> 函数返回,销毁第一个对象
4this(0x7ffdeb4c06d7) A::~A() #---> 从函数返回main的初始化是也发生了强制复制消除,一直用第二个对象地址直到main函数结束

实验证明,对于C++17,编译器并不是强制NRVO优化的,默认是开启的,但是可以关闭。

RVO和NRVO的实现原理

细心的读者可能发现了C++17 NRVO的与C++17 RVO关闭优化时的区别了。一段代码可以进行NRVO,同时也可以进行RVO,两种技术并不互斥。NRVO是有前提的,就是返回值是具名的。

接下来我们开始RVO和NRVO实现原理的分析。

RVO原理

根据《深度探索C++对象模型》一书所述,启用RVO时,编译器会对返回值函数的原型进行调整重写:

1A get_a_rvo() {
2    return A();
3}
4
5int main() {
6    A a = get_a_rvo();
7    return 0;
8}

将会被调整重写为:

 1void get_a_rvo(A& a) {
 2    A temp;     //先构造一个对象
 3    a.A::A(temp); //再执行拷贝构造函数
 4    return;
 5}
 6
 7int main() {
 8    A a; //仅定义而不构造
 9    get_a_rvo(a);
10    return 0;
11}

经过调整重写后的get_a_rvo()函数不再有返回值,而是通过一个传引用参数来实现。这样一来编译器只调用一次构造函数和一次拷贝构造函数。

经过这一步优化之后,消除了为保存返回值而创建的临时对象,以及该局部变量拷贝给该临时对象的一次拷贝构造调用。

有的读者会问,你胡说啥呢,明明前面的实验结果表明RVO优化之后只剩一次构造函数调用了,哪有拷贝构造函数的调用?

别着急,这一步优化还不够彻底,RVO还可以进一步优化:

 1void get_a_rvo(A& a) {
 2    a.A::A(); //直接在传入的参数基础上执行构造对象
 3    return;
 4}
 5
 6int main() {
 7    A a; //仅定义而不构造
 8    get_a_rvo(a);
 9    return 0;
10}

如此一来,这样就只剩下一次构造函数调用了,拷贝构造再次被消除。

在现代编译器的实现中,在这种情形下,RVO几乎都会使用后者进行彻底的优化。在一些特定场景下,RVO可能还会使用第一种优化方式。

NRVO原理

NRVO优化时编译器也会对返回值函数进行调整重写:

1A get_a_nrvo() {
2    A a;
3    return a;
4}
5
6int main() {
7    A a = get_a_rvo();
8    return 0;
9}

将会被调整重写为:

 1void get_a_nrvo(A& a) {
 2    a.A::A(); //直接在传入的参数基础上执行构造对象
 3    return;
 4}
 5
 6int main() {
 7    A a; //仅定义而不构造
 8    get_a_rvo(a);
 9    return 0;
10}

如此一来,也只剩下一次构造函数调用了,彻底消除了拷贝构造函数。

优化失效

编译器优化也不是万能,有一些特殊场景下,是不会进行RVO/NRVO优化的。接下来我们来盘点一下可能导致编译器优化失效的情况。

运行时根据不同分支返回不同对象

当函数返回值有多个分支,且返回的对象不同时,RVO/NRVO优化失效,无法消除拷贝构造或移动构造:

1A foo(bool flag) {
2    A a1, a2;
3    return flag ? a1 : a2;
4}
5
6int main() {
7    A a = foo(true);
8    return 0;
9}

运行结果:

1this(0x7fff2fa4cae6) A::A() #---> 构造a1
2this(0x7fff2fa4cae7) A::A() #---> 构造a2
3&a(0x7fff2fa4cae6) -> this(0x7fff2fa4cb17) A::A(const A&) #---> RVO优化失效,拷贝a1给一个新对象3
4this(0x7fff2fa4cae7) A::~A() #---> 函数结束,销毁a2
5this(0x7fff2fa4cae6) A::~A() #---> 函数结束,销毁a1
6this(0x7fff2fa4cb17) A::~A() #---> 从函数返回main后的初始化使用了复制消除,直到main结束才销毁对象3

返回值是全局变量

函数的返回值是个全局变量,则RVO失效:

1A global_a;
2A bar() {
3    return global_a;
4}
5
6int main() {
7    A a = bar();
8    return 0;
9}

运行结果:

1this(0x560a1713e151) A::A() #---> 全局变量global_a构造
2&a(0x560a1713e151) -> this(0x7fffc5607b77) A::A(const A&) #---> 函数返回值发生拷贝构造,拷贝构造对象2,RVO失效
3this(0x7fffc5607b77) A::~A() #---> 从函数返回main后的初始化使用了复制消除,a直接使用对象2的地址,直到main结束销毁
4this(0x560a1713e151) A::~A() #---> main结束,销毁global_a

返回值是函数的参数

函数的返回值是在外部传入的一个参数,则RVO失效:

1A baz(A a) {
2    return a;
3}
4
5int main() {
6    A a = baz(A{});
7    return 0;
8}

运行结果:

1this(0x7ffe01bf0197) A::A() #---> main中构造的临时对象1
2&a(0x7ffe01bf0197) -> this(0x7ffe01bf0196) A::A(const A&) #--->注意这里是return语句发生了拷贝构造出对象2
3this(0x7ffe01bf0197) A::~A() #---> 函数返回,对象1销毁
4this(0x7ffe01bf0196) A::~A() #---> 从函数返回main后的初始化使用了复制消除,a直接使用对象2的地址,直到main结束销毁

注意上述结果的对象2并不是从main按值传参进函数时发生的拷贝,我们前面提过复制消除还有一种情况是发生在函数参数的值传递语义,说的就是这种情况。

返回值是一个对象的成员变量

 1class B {
 2public:
 3    A a;
 4};
 5
 6A qux() {
 7    return B().a;
 8}
 9
10int main() {
11    A a = qux();
12    return 0;
13}

运行结果:

1this(0x7ffeb6da2987) A::A() #---> 函数中构造B对象时的构造内部成员A的对象1
2&a(0x7ffeb6da2987) -> this(0x7ffeb6da29b7) A::A(const A&) #---> 函数返回时发生拷贝构造出对象2
3this(0x7ffeb6da2987) A::~A() #---> 函数返回后对象B销毁的同时销毁内部成员对象1
4this(0x7ffeb6da29b7) A::~A() #---> 从函数返回main后的初始化使用了复制消除,a直接使用对象2的地址,直到main结束销毁

返回后是赋值而不是初始化

在类A中重载一个赋值操作符operator=()

 1class A {
 2public:
 3    //...
 4    void operator=(const A& a) { std::cout << "&a(" << &a << ") -> this(" << this << ") A::operator=(const A&)" << std::endl; }
 5};
 6
 7//...
 8
 9int main() {
10    A a;
11    a = get_a_rvo();
12    return 0;
13}

运行结果:

1this(0x7ffe6b9c04b6) A::A() #---> main中构造对象1
2this(0x7ffe6b9c04b7) A::A() #---> 函数return语句中构造对象2
3&a(0x7ffe6b9c04b7) -> this(0x7ffe6b9c04b6) A::operator=(const A&) #--->函数返回main后进行拷贝赋值,而不是初始化构造,所以RVO失效
4this(0x7ffe6b9c04b7) A::~A() #---> 赋值语句结束,销毁对象2
5this(0x7ffe6b9c04b6) A::~A() #---> main结束,销毁对象1

返回值使用std::move()

我们在《C++移动语义、万能引用、引用折叠、完美转发》这篇文章中也举过一个例子,在return语句中加上std::move(),非但没有起到优化作用,反而会增加一次移动构造函数的调用,RVO失效。

1A quxx() {
2    A a;
3    return std::move(a);
4}
5
6int main() {
7    A a = quxx();
8    return 0;
9}

运行结果:

1this(0x7fff8c611dc7) A::A() #---> 函数中构造对象1
2&a(0x7fff8c611dc7) -> this(0x7fff8c611df7) A::A(const A&) #--->函数return语句调用了std::move(),编译器尝试找移动构造函数,找不到则调用了拷贝构造函数构造出对象2,RVO失效
3this(0x7fff8c611dc7) A::~A() #---> 函数返回后,销毁对象1
4this(0x7fff8c611df7) A::~A() #---> 从函数返回main后的初始化使用了复制消除,a直接使用对象2的地址,直到main结束销毁

总结

  • 复制消除是一种编译器的优化技术,使编译器省略掉类对象的拷贝和移动构造函数。

  • 复制消除发生在函数参数是值语义,以及函数返回值是值语义两种情况下。

  • RVO和NRVO是函数返回值优化的两种技术。

  • RVO在C++17之后是强制开启的,无法关闭,而NRVO在C++17之后仍是可以关闭的。

  • RVO和NRVO在C++17之前都可以关闭。

  • RVO和NRVO的原理是编译器会对代码进行调整重写,改写为按引用传递参数的函数原型,从而消除拷贝构造和移动构造。

  • RVO和NRVO在一些特殊情况下会优化失效。

Prev Post: 『C++引用包装』
Next Post: 『C++闭包』