C++编译器中的 Copy elision 和 RVO 优化

发布时间 2023-12-27 21:14:55作者: 橙皮^-^

一、Copy elision简介

在 C++ 计算机编程中,复制省略(Copy elision)是指一种编译器优化技术,它消除了不必要的对象复制。
常见的俩种场景下复制省略
1、纯右值参数复制构造
2、函数返回值优化(Return value optimization RVO)

1.1 纯右值参数复制构造

#include <iostream>

int num = 0;

class X{
public:
  explicit X(int) {
    std::cout << "Call X(int)" << std::endl;
  }
  X(const X&) {
    num++;
    std::cout << "Call X(const x&)" << std::endl;
  }
};

int main() {
  X x1(42);//直接调用构造函数初始化
  X x2 = X(42); //同类型纯右值复制构造,复制省略,直接在x2位置上进行构造
  //即使在复制构造函数中存在副作用,也不会进行调用
  X x3 = x1; //左值复制构造
  std::cout << num << std::endl;//1,只在左值复制调用了一次
}

二、返回值优化(Return value optimization RVO)

2.1 RVO(Return value optimization)

RVO是一种编译器优化技术,在接收对象的位置构造返回值,避免从函数返回时创建临时对象。到了C++17标准保证了函数返回临时对象不会被复制,而不再是依赖于编译器优化[1]

RVO 例子

#include <cstdio>
#include <atomic>

struct MyType {
  char buffer[100000];
};

MyType return_unknow_value() {
	return MyType();
}

int main()
{
    auto x = return_unknow_value();
    return 0;
}

相对应的汇编代码 编译器 gcc C++17

return_unknow_value():
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     QWORD PTR [rbp-8], rdi
        mov     rax, QWORD PTR [rbp-8]
        mov     edx, 100000
        mov     esi, 0
        mov     rdi, rax	//获取返回值的地址,进行构造初始化。
        call    memset
        mov     rax, QWORD PTR [rbp-8]
        leave
        ret
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 100000 
        lea     rax, [rbp-100000]
        mov     rdi, rax 	//在调用函数前先分配返回值的内存空间,在保存地址值。
        call    return_unknow_value()
        mov     eax, 0
        leave
        ret

从汇编代码中可以看到,编译器遇到可以RVO优化的时候,会在调用函数前先为返回值分配好地址,后调用函数,在分配好的地址上初始化构造返回值。写成C++类似的代码如下,只会对返回值构造一次,避免了临时对象的拷贝消耗。

#include <cstdio>
#include <atomic>

struct MyType {
  char buffer[100000];
};

void return_unknow_value(void* ptr) {
  //适用replace new在预先分配内上构造返回值
  MyType *x = new(ptr) MyType{};
  //对返回值进行操作
}

int main() {
  // auto x = return_unknow_value();
  void * ptr = malloc(sizeof(MyType));
  //进入函数前先分配好内存地址,将地址作为参数传入函数中
  return_unknow_value(ptr);
  return 0;
}

2.2 RVO 相对于不同版本编译器和C++标准区别

1、C++17标准保证返回临时对象不会发生拷贝,使用C++17标准无需担心可能出现拷贝的情况[1:1]
2、C++17之前标准,返回类型对象是可移动的,存在移动构造函数(定义或默认生成),否则编译会报错,而C++17标准无论移动构造和移动赋值是否被删除都会进行RVO优化。[2]
3、C++17之前标准 gcc 可以添加-fno-elide-constructors 编译参数来禁止编译进行RVO优化。

2.3 NRVO(Name Return value optimization)

NRVO与RVO类似,但适用于返回函数内部已命名的局部变量。编译器优化这个过程,允许在调用者的栈帧上直接构造局部变量,避免了将局部变量拷贝到返回值的过程,但是NRVO并不能保证每次都会进行优化,在有一些情况不会发生,不同编译器情况也不太一样,依赖于编译器实现。

NRVO的简单例子

MyType return_name_value() {
	MyType x; //返回值具有名
	return x;
}

2.4 在以下情况不会进行NRVO优化

  • 运行时依赖(根据不同的条件分支,返回不同变量)
    例子
#include <cstdio>
#include <atomic>

struct MyType {
  char buffer[100000];
};


MyType return_name_value(bool test) {
    if (test) {
        MyType x;
        x.buffer[0] = '\0';
        return x;
    } else {
        MyType y;
        return y;
    }
}

int main()
{
    auto x = return_name_value(true);
    return 0;
}

能否对俩个分支都进行NRVO优化,依赖于编译器的实现
上面代码gcc -O3 的汇编指令

return_name_value(bool) [clone .part.0]:
        sub     rsp, 100008
        mov     edx, 100000
        mov     rsi, rsp
        call    memcpy
        add     rsp, 100008
        ret
return_name_value(bool):
        push    rbx
        mov     rbx, rdi
        test    sil, sil
        je      .L5
        mov     rax, rbx
        mov     BYTE PTR [rdi], 0
        pop     rbx
        ret
.L5:
        call    return_name_value(bool) [clone .part.0]
        mov     rax, rbx
        pop     rbx
        ret
main:
        xor     eax, eax
        ret

当test为true时,gcc会在栈上重新分配内存,然后在将内存拷贝到之前的返回值地址上。
当test为false时,发生NRVO优化

而使用clang编译 -O3参数的产生的汇编

return_name_value(bool):                 # @return_name_value(bool)
        mov     rax, rdi
        test    esi, esi
        je      .LBB0_2
        mov     byte ptr [rax], 0
.LBB0_2:                                # %return
        ret
main:                                   # @main
        xor     eax, eax
        ret

可以看到 clang编译器能够处理分支并实现NRVO优化
总结:当返回值是具名局部变量时,是否能进行NRVO优化主要依赖于具体编译器的实现

  • 返回函数参数不会发生NRVO优化[3]

这里给出来的解释,在于函数参数的控制权和生命周期在函数内部,随着函数结束而结束.具体解释看引用文章

  • 返回值是全局变量也不会发生优化

这是因为全局变量的生命周期是随程序周期的,因此即使像RVO那样,预留返回值的内存空间,返回时依旧需要对全局变量进行拷贝.

  • 返回值使用move进行转换也不会发生优化[4]
MyType return_name_value() {
    MyType y;
    return std::move(y);
}

int main()
{
    auto x = return_name_value();
    return 0;
}

gcc 对应汇编 -O3 C++17

return_name_value():
        sub     rsp, 100008
        mov     edx, 100000
        mov     rsi, rsp
        call    memcpy
        add     rsp, 100008
        ret
main:
        xor     eax, eax
        ret

可以看到即使条件满足,编译器也不会进行RVO优化,这是因为move操作将返回值转换成右值,这里语义变成了返回一个对象的引用,而RVO实施的条件是返回一个对象值.在关于RVO标准中:当RVO的前提条件允许时,要么发生复制省略,要么std::move隐式地实施于返回的局部对象上.因此对于局部对象可用于RVO优化的,不必添加move操作.

五、总结

  • 纯右值参数复制构造,不会调用复制构造函数,而是会进行复制省略优化
  • 返回值优化分为RVO(返回临时变量情况) 和 NRVO(返回具名局部变量)
  • 当函数返回临时变量时,且使用C++17标准及以上了,保证了返回临时对象不会发生拷贝(RVO)
  • 当返回的是具名的局部变量时,具体优化依赖于编译器实现
  • 当返回值是函数参数,全局变量 不会进行NRVO优化
  • 若局部对象可能适用于返回值优化,则请勿针对其实施std::move操作和std::forward.

六、引用


  1. https://stackoverflow.com/questions/12953127/what-are-copy-elision-and-return-value-optimization/12953145#12953145 ↩︎ ↩︎

  2. https://en.cppreference.com/w/cpp/language/copy_elision ↩︎

  3. https://stackoverflow.com/questions/9444485/why-is-rvo-disallowed-when-returning-a-parameter ↩︎

  4. Effective Modern C ++ 条款25 ↩︎