返回值优化中的复制省略

8

我正在尝试理解C++17标准中的生存期保证,特别是针对保证复制省略。 让我们从一个例子开始

std::string make_tmp();

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

我对正在发生的事情的理解make_tmp创建一个临时字符串,我们将其称为tfoo返回(不必要地创建的)t的临时副本(即c_str)。 标准(包括C++17之前的版本)保证了t的寿命直到完整的返回表达式被评估完成。 因此,安全地创建t的临时副本(以便返回)。

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

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

因此,在这种情况下,临时副本甚至根本不会被创建。

跟进问题:

  1. 即使被保证省略,返回的临时副本仍然意味着t有足够长的寿命吗?

  2. 考虑下面给出的foo变种。 我假设不再需要复制省略(但是非常可能)。 如果不会省略复制,则标准通过上述论点保证了我们的权利。 在省略复制的情况下,尽管return表达式的类型与foo的返回类型不同,标准仍然保证t具有足够的生命周期吗?

foo变种:

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

我希望了解标准所纯粹暗示的保证。

请注意,我知道两个foo版本“工作”(即在使用各种编译器测试自定义类时都没有悬空指针问题)。


3
return make_tmp().c_str(); 即使在 C++17 之前也是有效的。 (该语句返回 make_tmp() 函数创建的临时对象的指针,表示该语句可以在旧版的 C++ 中正常运行。) - Jarod42
1
复制省略是一种已经存在了“大多数时间”的东西。唯一的新变化是现在“大多数时间”变成了“总是”。 - Bo Persson
2
@Jarod42 你能详细说明一下为什么吗? - m8mble
make_tmp().c_str() 等同于 std::string(make_tmp().c_str()),隐式地进行了复制省略。 - Yuki
3个回答

8

我认为这里有一些混淆,关于哪些副本被省略了。让我们来看最远的视角:

std::string make_tmp();

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

std::string s = foo();

这里可能会创建四个`std::string`:`make_tmp()`、从它构造的临时`std::string{...}`、`foo()`的返回对象和`s`。这意味着需要三个拷贝(为了保持一致性,我仍然使用“拷贝”这个词,即使这些都是移动操作。希望这不会让人困惑)。
复制省略允许消除其中两个拷贝:
- 从`std::string{...}`到`foo()`的返回对象的拷贝。 - 从`foo()`到`s`的拷贝。
这两个省略在C++17的“拷贝省略保证”中是强制实施的,因为我们是从一个prvalue进行初始化(这个术语有点困惑,因为我们实际上并没有执行重载解析来确定我们需要执行复制构造,然后跳过它,而是直接进行初始化)。这段代码与以下代码相同:
std::string s{make_tmp().c_str()};

然而,这是无法删除的 - 我们仍然通过 make_tmp() 构造一个字符串,提取其内容,然后从中构造一个新的字符串。没有其他方法。

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


1

即使保证了省略,返回的临时副本仍然意味着t具有足够长的生命周期吗?

t将位于foo的主体中,而省略发生在make_tmp的主体中。因此,在任何情况下,无论是临时的、静态的、动态的还是其他的,t的生命周期都不会受到省略对foo主体的影响。

考虑以下给出的foo变量。我假设,不再需要复制省略(但可能高度可能)。如果未省略复制,则标准已经覆盖了我们(通过上述参数)。如果省略复制,标准是否仍保证t具有足够的生命周期,尽管返回表达式的类型与foo的返回类型不同?

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标准只是对这一事实进行了澄清。

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

是的,根据[stmt.return]/2的规定,在返回对象的复制初始化期间,临时变量的持久性是得到保证的:

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


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接