复制Elision为返回的Temporaries

时间:2018-05-25 12:12:27

标签: c++ standards c++17

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

std::string make_tmp();

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

我不知道发生了什么make_tmp创建一个临时的string我们会调用t; foo会返回(不必要地创建)临时(t' s c_str的副本)。 标准(甚至是前C ++ 17)保证t的生命周期是评估完整返回表达式之前的时间。 因此它是安全的,因此创建t的临时副本(将被退回)。

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

  

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

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

后续问题:

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

  2. 考虑下面给出的foo变体。 我假设,不再需要复制省略(但很可能)。 如果副本不会被删除,那么标准就会被我们覆盖(通过上述论点)。 如果副本被省略,尽管t ed-expression的类型与return的返回类型不同,标准是否仍能保证foo的足够生命周期?

  3. foo - 变体:

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

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

3 个答案:

答案 0 :(得分:8)

我认为这里存在一些混淆,因为哪些副本被删除了。让我们采取最远的观点:

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来保持一致性,即使所有这些都是移动。希望这不会令人困惑)。

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

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

这两个系列都是在C ++ 17的“保证副本省略”中强制执行的 - 因为我们是从prvalue初始化的(这个术语有点令人困惑,因为我们实际上并没有执行重载决策来确定我们需要执行复制构建然后跳过它,我们只是直接初始化)。代码与:

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

这不能删除 - 我们仍在构建string来自make_tmp(),提取其内容,然后从中构建新的string。没办法。

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

答案 1 :(得分:1)

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

是的,保证临时在[stmt.return] / 2的返回对象的复制初始化期间保持不变:

  

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

答案 2 :(得分:1)

  

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

t将出现在foo的身体上,并且省略在make_tmp的身体中进行。因此,t的生命周期不会受到foo身体的任何影响,无论是暂时的,静态的,动态的等等。

  

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

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

我认为要理解elision的保证,更好地理解返回逻辑如何在汇编级别上工作。这将使您了解编译器如何制作调用的返回机制,这里的标准只是试图跟上实际的编译器实现,给出了清晰度,而是介绍了一些新的语言语法概念。

简单示例:

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标准正在澄清这一事实。