本文参考自 Arthur O’Dwyer 的 CppNow 演讲:https://www.youtube.com/watch?v=OGKAJD7bmr8
本文只是对 RVO 做一个简单的介绍,因为相关的标准文书工作太过庞大了解起来会相当吃力。
1. x86_64 调用约定
x86_64 的函数调用,视返回值类型而有不同的存储方法。当返回值为平凡的
32 位对象时,会存储在 eax
寄存器中,64 位则会存储在 rax
寄存器中。
struct Test {
int arr[2];
};
Test f() {
Test t;
t.arr[0] = 1;
t.arr[1] = 2;
return t;
}
Test test() {
auto res = f();
res.arr[0] = 11;
res.arr[1] = 21;
return res;
}
这段代码的汇编大概长这样:
f():
push rbp
mov rbp, rsp
mov dword ptr [rbp - 8], 1
mov dword ptr [rbp - 4], 2
mov rax, qword ptr [rbp - 8]
pop rbp
ret
test():
push rbp
mov rbp, rsp
sub rsp, 16
call f()
mov qword ptr [rbp - 8], rax
mov dword ptr [rbp - 8], 11
mov dword ptr [rbp - 4], 21
mov rax, qword ptr [rbp - 8]
add rsp, 16
pop rbp
ret
当返回值类型继续增大,视具体编译器情况可能会存入多个寄存器,比如 int[3]
会用 eax
+ rax,
int[4]
会用 rax
+ rdx
。但是用寄存器存储返回值的能力仅限于此。
当返回类型太大而塞不进寄存器时,返回值就会被放到栈上了。
struct Test {
int arr[5];
};
Test f() {
Test t;
return t;
}
Test test() {
Test res = f();
return res;
}
此时的 f()
和 test()
函数,它们的形参个数是多少呢?C++ 程序员会说是 0,而汇编程序员会说是 1。
这是因为调用约定要求调用者分配出一块栈空间用来存放返回值,这块空间被称为 return slot
,而调用函数时需要将这块空间的地址传递给函数,一般存入 rdi
寄存器。
f():
push rbp
mov rbp, rsp
mov rax, rdi
pop rbp
ret
test():
push rbp
mov rbp, rsp
sub rsp, 16
mov rax, rdi
mov qword ptr [rbp - 8], rax
call f()
mov rax, qword ptr [rbp - 8]
add rsp, 16
pop rbp
ret
这里非常重要的一点是:return slot
是调用者分配的栈空间。
2. 函数调用栈视图与优化行为
对上节的场景,我们可以画一个调用栈的图(在应用 RVO 等优化前):
=========== f() ================
Test t;
================================
=========== test() =============
return slot (存 f() 返回值)
====
Test res;
================================
(1) 第一步
首先第一步,f()
返回值存入 return slot
以后,我们紧接着就会用它来构造 res
,而 return slot
和 res
都是 test()
函数内部分配的空间,而且 test()
也知道 return slot
被用来构造 res
后就没用了,那么 test()
完全可以将它俩作为同一个个体,来消除这一次构造的过程。
此时,我们直接将为 res
分配的空间作为 return slot
传其地址给 f()
。
=========== f() ================
Test t;
================================
=========== test() =============
return slot (存 f() 返回值)
同时也为 Test res;
================================
一般编译器对程序做优化的前提都是要求优化前后程序的行为完全一致,也就是说如果有用户定义的可观察副作用,比如说构造函数里打印文字,那优化是不能消除它的执行的。
但是对于本文所探讨的 RVO,这是一个例外。C++98 标准允许编译器优化掉这种带有副作用的构造函数。
而到了 C++17 标准,我们又有了一种更好的视角来看待这一行为:我们可以压根不把 f()
(的返回值)看作一个实在的个体,而是把它看作一系列构造 Test
个体的指令,而构造的这个 Test
个体就是 res
。
(2) 第二步
接着我们再看 f()
,它控制着 t 的空间,同时也有 rdi
传入的地址,所以它知道 return slot
的位置。所以 t 可以直接在 return slot
中构造,这样又消除了一次实体的构造。
=========== f() ================
================================
=========== test() =============
return slot (存 f() 返回值)
同时也为 Test res;
同时也为 f() 中的 Test t;
================================
(3) 优化结果的观察
在 C++17 标准规定编译器必须做到优化,在此前的态度则是推荐但不强制,而编译器都会实行。
我们在 C++17 前对 gcc 或者 clang 显式加上 -fno-elide-constructors
编译选项就可以观察到不开优化的行为。
struct Test {
int arr[5];
Test() noexcept = default;
Test(Test&&) noexcept { std::cout << "Test(Test&&)\n"; }
};
Test f() {
Test t;
std::cout << &t << '\n';
return t;
}
int main() {
Test res = f();
std::cout << &res << '\n';
}
对于这样的代码,在 --std=c++11
-fno-elide-constructors
下的输出为:
0x7ffc00a77fa0
Test(Test&&)
Test(Test&&)
0x7ffc00a77ff8
去掉 -fno-elide-constructors
则输出为:
0x7ffdad2fb188
0x7ffdad2fb188
所以 t
与 res
确实为同一实体。
3. NRVO 条件与反例
(1) 必须存在 return slot
如果返回值是存在寄存器中的,那么就没有这些优化前提。
(2) return x
中的 x
一定要是函数自己控制的
// 三种反例:
Test global;
Test f1() { return global; } // 全局变量
Test f2() { static Test sta; return sta; } // 静态变量
Test f3(Test s) { return s; } // s 是调用者分配的
(3) 返回值类型与 return slot
类型必须一致(可以有 CV 限定符的区别)
// 反例:
struct Base {
int arr[5];
};
struct Derived : Base {
int i;
};
// `return slot` 是为 Base 分配的,Derived 放不进去。
Base f() { Derived res; return res; }
(4) return
的表达式要足够简单
// 反例 1:
Test f() {
Test t;
return std::move(t);
}
// 等价于 return Test{std::move(t)};
// 反例 2:
std::string f2() {
std::string s = "Hello ";
return s += "world!\n";
}
// 由于 operator+= 是 std::string 的运算符重载函数,
// 其返回值类型为 std::string&,
// 这会影响返回值类型的正确推导和抑制优化的进行。
(5) 返回值唯一
// 反例:
Test f(bool b) {
Test t1;
Test t2;
if (b) {
return t1;
} else {
return t2;
}
}
// `return slot` 只能塞下一个 Test 个体,而 t1 与 t2 都有可能成为返回值且在运行时才能确定。
4. 关于 return x
的其它细节
std::unique_ptr<Test> f() {
std::unique_ptr<Test> res;
return res;
}
有移动资格的表达式:虽然由任何变量的名字构成的表达式是左值表达式(比如上例的 res
),但若它作为 return
语句(或者 co_return
语句和 throw
表达式)的操作数出现,则表达式具有移动资格。
如果表达式有移动资格,那么将为其进行两次重载决议,第一次视其为右值,如果重载决议失败则第二次视其为左值。
所以这是上例可以正常通过编译的原因。