一、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.