共计 3338 个字符,预计需要花费 9 分钟才能阅读完成。
导读 | 当 C++ 函数中的 return 关键字后跟非内置类型的表达式时,执行该 return 语句会将表达式的结果复制到调用函数的返回槽 (Return Slot) 中。为此,将调用非内置类型的复制或移动构造函数。然后,作为退出函数的一部分,将调用函数局部变量的析构函数,可能包括 return 关键字后面的表达式中命名的任何变量。 |
为了能发文,标题中的复制 / 移动省略是 Copy/Move Elision 的硬翻译,请各位大大海涵。下文中我会同时使用这两种术语。
在 Visual Studio 2022 版本 17.4 预览版 3 中,我们显著增加了适用于 Copy/Move Elision 情况的数量,并让用户能够更好地控制是否启用这些转换。
当 C++ 函数中的 return 关键字后跟非内置类型的表达式时,执行该 return 语句会将表达式的结果复制到调用函数的返回槽 (Return Slot) 中。为此,将调用非内置类型的复制或移动构造函数。然后,作为退出函数的一部分,将调用函数局部变量的析构函数,可能包括 return 关键字后面的表达式中命名的任何变量。
C++ 规范允许编译器直接在调用函数的返回槽中构造返回的对象,从而省略作为返回的一部分执行的复制或移动构造函数。与大多数其他优化不同,这种转换允许对程序的输出产生可观察的影响 – 即复制或移动构造函数以及关联的析构函数可以少调用一次。
C++ 标准要求在将返回值初始化为 return 语句的一部分时(例如,当返回类型为 Foo 的函数返回返回 Foo()时),编译器需要执行 Copy/Move Elision。Microsoft Visual C++ 编译器始终根据需要对返回语句执行 Copy/Move Elision,而不管传递给编译器的标志如何。此行为保持不变。
当返回的值为命名变量时,编译器可能会省略复制或移动,但不是必需的。C++ 标准仍要求为命名的返回变量定义复制或移动构造函数,即使编译器在所有情况下都省略了构造函数。在 Visual Studio 2022 版本 17.4 预览版 3 之前,当禁用优化(例如使用 /Od 编译器标志或使用了 #pragma optimize(“”,off))时,编译器将仅执行强制 Copy/Move Elision。使用 /O2 标志,编译器将通过简单的控制流为优化的函数执行可选的 Copy/Move Elision。
从 Visual Studio 2022 版本 17.4 预览版 3 开始,我们为开发人员提供了与新的 /Zc:nrvo 编译器标志保持一致的选项。默认情况下,当使用 /O2 标志、/permissive- 编译代码时,或者在为 /std:c++20 或更高版本进行编译时,将传递 /Zc:nrvo 标志。通过此标志后,将尽可能执行复制和移动省略。我们希望在将来的版本中默认启用 /Zc:nrvo。另外,开发者还可以使用 /Zc:nrvo- 标志显式禁用可选的 Copy/Move Elision。请注意,无法禁用强制型的 Copy/Move Elision。
在 Visual Studio 2022 版本 17.4 预览版 3 中,当使用 /Zc:nrvo、/O2、/permissive- 或 /std:c++20 或更高版本的标志启用可选复制 / 移动省略时,我们还增加了 Copy/Move Elision 的位置。
可选 Copy/Move Elision 的最简单示例是以下函数:Foo SimpleReturn() {Foo result;return result;}
在这种情况下,如果传递了 /O2 标志,则早期版本的 MSVC 编译器已将结果的复制或移动到返回槽中。在 Visual Studio 2022 版本 17.4 预览版 3 中,如果传递了 /permissive-、/std:c++20 或更高版本或 /Zc:nrvo 标志,也会省略复制或移动,如果传递了 /Zc:nrvo- 标志,则保留复制或移动。
从 Visual Studio 2022 版本 17.4 预览版 3 开始,如果将 /O2、/permissive-、/std:c++20 或更高版本或 /Zc:nrvo 标志传递给编译器,而 /Zc:nrvo- 标志未传递到编译器,我们现在在以下其他情况下执行复制 / 移动省略。
Foo ReturnInALoop(int iterations) {for (int i = 0; i
Foo ReturnInTryCatch() {
try {
Foo result;
return result;
} catch (…) {}
}
如果传递了 /O2、/permissive-、/std:c++20 或更高版本,或者传递了 /Zc:nrvo 标志,而 /Zc:nrvo- 标志未传递,则结果对象的复制或移动现在将被省略。我们现在还可以妥善处理更复杂的情况,例如:
int n;
void throwFirstThreeIterations() {
++n;
if (n
结果对象将在调用方函数的返回槽中构造,并且在成功返回时不会为其调用复制 / 移动构造函数或析构函数。引发异常时,是否析构结果对象取决于向编译器传递哪些异常处理标志。默认情况下,不会发生堆栈展开,因此不会调用析构函数。但是,如果使用 /EHs、/EHa 或 /EHr 标志启用了堆栈展开异常处理,则 goto Label1 将导致调用结果的析构函数,因为它跳转到初始化结果之前。无论哪种方式,当再次到达表达式 Foo 结果时,将在返回槽中再次构造对象。
复制具有默认参数的构造函数
现在,我们可以正确检测到具有默认参数的复制或移动构造函数仍然是复制或移动构造函数,因此可以在上述情况下被省略。具有默认参数的复制构造函数如下所示:structStructWithCopyConstructorDefaultParam {int X;
struct
StructWithCopyConstructorDefaultParam {
int X;
StructWithCopyConstructorDefaultParam(int x) : X(x) {}
StructWithCopyConstructorDefaultParam(StructWithCopyConstructorDefaultParam const& original, int defaultParam = 0) :
X(original.X + defaultParam) {printf(“Copy constructor called.\n”);
}
};
尽管 MSVC 编译器现在在更多情况下执行 Copy/Move Elision,但并不总是能够执行它。若要了解为什么会这样,请考虑以下函数:
Foo WhichShouldIReturn(bool condition) {
Foo resultA;
if (condition) {
Foo resultB;
return resultB;
}
return resultA;
}
复制省略构造要在返回槽中返回的对象,但在这种情况下,应在返回槽中构造哪个对象?为了在返回结果 A 时省略结果 A 的副本,必须在返回槽中构造它。但是,如果条件为真,则需要在销毁结果 A 之前在返回槽中构造结果 B。无法对两个路径执行复制省略。
我们目前选择避免在函数中的所有路径上执行可选的 Copy/Move Elision,如果在任何路径上它是不可能的的话。但是,对内联决策、死代码消除和其他优化的更改可能会更改 Copy/Move Elision 的可能性。因此,编写依赖于命名变量的 Copy/Move Elision 的某些行为的代码是不安全的,除非使用 /Zc:nrvo- 禁用了所有可选的 Copy/Move Elision。
只要启用了堆栈展开异常处理或未引发异常,仍然可以安全地假定每个构造函数调用都有匹配的析构函数调用。
写着旧时代的 C++,一直都为如何高性能地返回一个对象发愁。没错,正是在下。