为返回的临时人员复制 Elision

Copy Elision for Returned Temporaries

本文关键字:复制 Elision 返回      更新时间:2023-10-16

我试图理解 C++17 标准保证的生命周期,特别是对于保证的副本省略。 让我们从一个例子开始

std::string make_tmp();
std::string foo() {
return std::string{make_tmp().c_str()};
}

我对正在发生的事情的不满make_tmp创建了一个临时string我们称之为t;foo返回一个(不必要的创建)临时的(tc_str的副本)。 标准(甚至早于 C++17)保证t的生存期是计算完整返回表达式之前的时间。 因此,这是安全的,因此创建t的临时副本(要返回)。

现在复制省略号开始了;更具体地说,第一个 C++17 块中的第二个项目符号:

在函数调用中,如果 return 语句的操作数是 prvalue 并且函数的返回类型与该 prvalue 的类型相同。

因此,甚至根本不会创建临时副本。

后续问题:

  1. 返回的临时副本是否仍然意味着t的足够延长的生存期 - 即使它保证被省略?

  2. 考虑下面给出的foo变体。 我假设,不再需要复制省略(但很有可能)。 如果副本不会被省略,那么标准就让我们(通过上面的论点)覆盖了我们。 如果副本被省略,尽管returned表达式的类型与foo的返回类型不同,标准是否仍然保证足够的t生存期?

foo-变体:

std::string foo() {
return make_tmp().c_str();
}

我想了解标准纯粹隐含的保证。 请注意,我知道两个foo版本都"工作"(即,即使在各种编译器下使用自定义类进行测试时,也没有涉及悬空指针)。

我认为这里对哪些副本被省略有些困惑。让我们看最远的视图:

std::string make_tmp();
std::string foo() {
return std::string{make_tmp().c_str()};
}
std::string s = foo();

在这里,可能创建了四个std::stringmake_tmp()、由它构造的临时std::string{...}foo()的返回对象和s。这意味着三个副本(为了保持一致性,我只是要使用单词 copy,即使所有这些都是移动。希望这不会令人困惑)。

Copy elision 允许删除其中两个副本:

  • std::string{...}复制到foo()的返回对象。
  • foo()s的副本

这两个省略都是在 C++17 的"保证副本省略"中强制要求的 - 因为我们是从 prvalue 初始化的(这个术语有点令人困惑,因为我们实际上并没有执行重载解析来确定我们需要执行复制构造然后跳过它,我们只是直接初始化)。代码与以下内容相同:

std::string s{make_tmp().c_str()};

但这不能删除 - 我们仍然通过make_tmp()构建一个string,提取其内容,然后从中构建一个新的string。没办法。

提供的变体具有完全相同的行为。

这个答案直接回答了OP中提出的生命周期问题(你可以看到它与复制省略无关)。如果你不熟悉在执行 return 语句期间发生的整个故事,你可以参考 Barry 的答案。


是的,根据 [stmt.return]/2 对返回的对象进行复制初始化期间,临时保证会持续存在:

调用结果的复制初始化在由 return 语句的操作数建立的完整表达式的末尾销毁临时表达式之前进行排序,而 return 语句又在包含 return 语句的块的局部变量 ([stmt.jump]) 销毁之前进行排序。

返回的临时副本是否仍然意味着 t 的足够延长的生存期 - 即使它被保证被省略?

t会在foo的身体上,而make_tmp的身体里会发生。因此,t的寿命不受foo身体的影响,无论是暂时的、静态的、动态的还是其他什么。

考虑下面给出的 foo 的变体。我假设,不再需要复制省略(但很有可能)。如果副本不会被省略,那么标准就让我们(通过上面的论点)覆盖了我们。如果副本被省略,尽管返回表达式的类型与 foo 的返回类型不同,但标准是否仍然保证 t 有足够的生存期?

make_tmp().c_str()等效于原始代码段中的std::string(make_tmp().c_str()),则std::string构造函数调用是隐式发生的。正如你在帖子开头提到的,省略确实发生了。

我认为要理解省略的保证,最好遵循对返回逻辑在程序集级别如何工作的理解。这会让你了解编译器是如何做出调用的返回机制的,这里的标准只是试图跟上实际的编译器实现,给出清晰度,而不是引入一些新的语言语法概念。

简单的例子:

std::string foo();
int main() {
auto t = foo();
}

在组装相关部件时,main体将如下所示:

0000000000400987 <main>:
....
; Allocate 32-byte space (the size of `std::string` on x64) on the stack
; for the return value
40098b:   48 83 ec 20             sub    $0x20,%rsp
; Put the pointer of the stack allocated chunk to RAX
40098f:   48 8d 45 e0             lea    -0x20(%rbp),%rax
; Move the pointer from RAX to RDI
; RDI - is a first argument location for a callee by the calling convention
; By calling convention, the return of not trivial types (`std::string` in our case)
; must be taken care on the caller side, it must allocate the space for the return type
; and give the pointer as a first argument (what of course, is hidden by the compiler
; for C/C++)
400993:   48 89 c7                mov    %rax,%rdi
; make a call
400996:   e8 5b ff ff ff          callq  4008f6 <foo()>
; At this point you have the return value at the allocated address on the main's stack
; at RBP - 32 location. Do whatever further.
....

实际上,t空间已经在调用方(main)的堆栈上,并且该堆栈内存的地址被传递给被调用方,foofoo只需要通过其中的任何逻辑将东西放入其中,仅此而已。foo可能会分配一些内存来构建std::string然后将此内存复制到给定内存,但它也可能(在许多情况下是一种简单的优化)直接在给定内存上工作而不分配任何内容。在后者中,编译器可能会调用复制构造函数,但没有意义。C++17标准只是澄清了这一事实。