C++函数中"return"的确切时刻

69

这似乎是一个愚蠢的问题,但在函数中执行return xxx;的确切时刻是否明确定义?

请看以下示例以了解我的意思(此处可以实时查看):

#include <iostream>
#include <string>
#include <utility>

//changes the value of the underlying buffer
//when destructed
class Writer{
public:
    std::string &s;
    Writer(std::string &s_):s(s_){}
    ~Writer(){
        s+="B";
    }
};

std::string make_string_ok(){
    std::string res("A");
    Writer w(res);
    return res;
}


int main() {
    std::cout<<make_string_ok()<<std::endl;
} 

我天真地期望在调用make_string_ok函数时会发生以下情况:

  1. 构造函数被调用,初始化res的值为"A"
  2. 构造函数被调用,初始化w
  3. 执行return res语句,此时应该返回res的当前值(通过复制res的当前值),即"A"
  4. 析构函数被调用,w被销毁,res的值变为"AB"
  5. 析构函数被调用,res被销毁

因此,我期望的结果是"A",但控制台输出却是"AB"

另一方面,对于稍微不同版本的make_string函数:

std::string make_string_fail(){
    std::pair<std::string, int> res{"A",0};
    Writer w(res.first);
    return res.first;
}

结果如预期 - "A"查看实时演示)。

标准是否规定了上述示例应返回哪个值,还是未指定?


3
方便阅读:什么是复制省略和返回值优化?复制省略(Copy Elision)和返回值优化(Return Value Optimization)是 C++ 中的优化技术,它们可以减少不必要的对象拷贝和构造函数调用,提高程序性能。复制省略发生在对象被创建时,而返回值优化则发生在函数返回值的传递过程中。这两种技术都可以使代码更加高效,并且对于某些场景下的代码优化非常有帮助。 - user4581301
8
析构函数中的副作用应该非常谨慎使用,如果必须使用的话,就要当心这些副作用会带来的问题。 - Matthew Read
1
关于 return 何时发生,直到 C++14 为止,return 的措辞并未说明本地临时变量的寿命足够长以用于构造返回值。 - Davis Herring
1
@MichałŁoś 不是的。RAII概念实际上忽略了副作用。完美的RAII代码在构造函数中除了初始化之外没有任何代码。副作用是指由构造函数在对象之外进行更改的内容。但生活从来不是完美的。 - Swift - Friday Pie
如果不是由于复制省略,那么就会因为移动语义而不被销毁(新移动到对象将获取指针,并且旧对象的析构函数将忽略它的空指针,但在所有操作之后将被调用)。我的意思是,在RAII处理程序中,析构函数所做的比构造函数更重要。 - Michał Łoś
显示剩余6条评论
3个回答

38
由于返回值优化 (RVO),在make_string_okstd::string res的析构函数可能不会被调用。string对象可以在调用方的一侧构造,函数只能初始化该值。
代码将等效于:
void make_string_ok(std::string& res){
    Writer w(res);
}

int main() {
    std::string res("A");
    make_string_ok(res);
}

因此,返回值应该是“AB”。 在第二个示例中,RVO不适用,该值将在调用return时被精确地复制到返回值中,并且Writer的析构函数将在复制发生后在res.first上运行。
6.6 跳转语句 无论以何种方式退出作用域,都会为该作用域中声明的所有自动存储期(3.7.2)的构造对象(命名对象或临时对象)调用其析构函数(12.4),顺序与它们的声明相反。从循环、块中跳出或在初始化的自动存储期变量之前返回,都涉及到在传输点处范围内的具有自动存储期的变量的销毁。
6.6.3 返回语句 返回实体的复制初始化在 return 语句操作数所建立的完整表达式结尾处的临时对象的销毁之前进行定序,而这又定序于包含返回语句的块的局部变量(6.6)的销毁。
12.8 类对象的复制和移动 当满足某些标准时,即使对象的复制/移动构造函数和/或析构函数具有副作用,实现也允许省略类对象的复制/移动构造。在这种情况下,实现将省略复制/移动操作的源和目标视为仅是指向同一对象的两种不同方式,并且该对象的销毁发生在省略优化时两个对象将被销毁的时间之后。(123)这种复制省略操作,称为复制省略,在以下情况下是允许的(可以组合这些情况以消除多个复制):
- 在函数中具有类返回类型的 return 语句中,当表达式是与函数返回类型具有相同 cv 非限定类型的非易失性自动对象(不是函数或 catch 子句参数)的名称时,可以通过直接将自动对象构造到函数的返回值中来省略复制/移动操作。
123)因为只有一个对象被销毁而不是两个对象,并且没有执行一次复制/移动构造函数,因此仍然对每个构造的对象进行了一次销毁。

1
我也考虑过这个问题,但令我惊讶的是 - 结果是不同的,因此不仅仅是剪切复制的问题。想知道标准对此有何规定。 - ead
1
添加了标准的引用 - Shloim
3
如果调用了复制构造函数,那么就会留下一些实例,必须在某个时候调用析构函数进行清理。如果没有调用复制构造函数,那么也不会调用任何析构函数。因此,实际上这两个语句(一个带有“析构函数”,一个带有“复制构造函数”)是等价的(如果我没有弄错的话)。 - tomsmeding
3
它们不是等价的,因为我们正在讨论不同的对象:std::string 的析构函数不会修改对象,而 Writer 的析构函数会修改对象,因此关键是要确定传递给 Writer 构造函数的字符串是否已经被复制(但不需要确定它是否后来被销毁)。 - Konrad Rudolph
1
@ead 是的。优化是可选的,并且可能会改变可观测的行为。它是“总体上好像”的规则的例外,该规则允许编译器进行任何不改变可观测行为的转换。不仅问题涉及省略对象构造函数和析构函数的副作用,还涉及其他副作用的目标变化,例如在您的情况下,“Writer”的析构函数在应用优化时会对不同的对象起作用。这取决于编译器的决定。 - luk32
显示剩余10条评论

29

这是RVO(返回值优化),其中一种允许更改可见行为的优化,因为返回副本会混淆视图。

10.9.5 复制/移动省略 (重点在于我)

当符合某些条件时,实现可以省略类对象的复制/移动构造,即使所选的构造函数和/或对象的析构函数具有副作用**。 在这种情况下,实现将省略的复制/移动操作的源对象和目标对象视为只是指向同一对象的两种不同方式

以下情况允许复制/移动操作的省略(可能合并以消除多个复制):

  • 在具有类返回类型的函数的返回语句中,当表达式是非易失性自动对象的名称(不是函数参数或由处理程序的异常声明引入的变量)与函数返回类型相同(忽略 cv-qualification)时,可以通过直接在函数调用的返回对象中构造自动对象来省略复制/移动操作
  • [...]

基于是否应用它,您的整体前提都是错误的。在情况1中,将调用res的构造函数,但对象可能存在于make_string_ok内部或外部。

情况1。

第二点和第三点可能根本不会发生,但这是一个次要的问题。Target受到了Writer的dtor影响,它在make_string_ok之外。这恰好是通过在评估operator<<(ostream,std :: string)的上下文中使用make_string_ok创建的临时值。编译器创建了一个临时值,然后执行函数。这很重要,因为临时对象存在于其外部,因此Writer的目标不是局部于make_string_ok而是局部于operator<<。
与此同时,您的第二个示例不符合标准(也不符合缩略的标准),因为类型不同。所以写入者死了。即使它是pair的一部分,它也会死亡。因此,在此处,将res.first的副本作为临时对象返回,然后Writer的dtor影响即将自行死亡的原始res.first
显然,在调用析构函数之前进行复制,因为复制返回的对象也被销毁,否则您将无法复制它。
归根结底,这归结为RVO,因为Writer的dtor要么在外部对象上工作,要么在本地对象上工作,具体取决于是否应用优化。
标准是否规定了上述示例应返回哪个值,还是未指定?
不,优化是可选的,尽管它可能会改变可观察的行为。编译器有权决定是否应用它。它是“一般as-if”规则的例外,该规则允许编译器进行任何不改变可观察行为的转换。
在C++17中,它成为了必需品,但不是你的情况。必需品是返回值为匿名临时对象的情况。

1
这里稍有不同 - 它是另一个对象Writer)的析构函数具有可能影响返回值的副作用。 - Toby Speight
@TobySpeight 理解了。我对答案进行了扩展,并加粗了“实现将省略的复制/移动操作的源和目标视为引用同一对象的两种不同方式”。Writer析构函数的工作方式完全相同,只是目标对象不同。此外,在返回时为本地值执行复制以及应用dtors的顺序似乎非常明显...您不能销毁即将被复制以返回的对象。 - luk32
@TobySpeight,我注意到强调析构函数省略可能只是幌子。重要的是省略的副本位于何处以及Writer的目标是什么。 - luk32

17

C++中有一个叫做省略的概念。

省略将两个看似不同的对象合并为一个,它们的身份和生命周期也随之合并。

之前,省略可能发生在以下情况下:

  1. 当你在函数中有一个非参数变量Foo f;,该函数返回Foo,且返回语句是一个简单的return f;

  2. 当你有一个匿名对象用于构造几乎任何其他对象时。

中,通过新的prvalue规则消除了所有(几乎所有?)#2的情况;省略不再发生,因为曾经创建临时对象的操作不再这样做。相反,"临时"的构造直接绑定到永久对象位置。

现在,由于编译器编译的ABI限制,省略并不总是可行的。RVO(Return Value Optimization)和NRVO(Named Return Value Optimization)是两种常见的可行情况之一。
RVO是如下情况:
Foo func() {
  return Foo(7);
}
Foo foo = func();

这里有一个返回值Foo(7),它被省略成为返回的值,然后被省略到外部变量foo中。看起来像是3个对象(foo()的返回值,return行上的值和Foo foo),但实际上在运行时只有1个。

之前,必须存在复制/移动构造函数,而省略是可选的;在中,由于新的prvalue规则,不需要存在复制/移动构造函数,并且编译器没有选项,这里必须有1个值。

另一个著名的情况是命名返回值优化(NRVO)。这是上述省略情况(1)。

Foo func() {
  Foo local;
  return local;
}
Foo foo = func();

再次强调,省略可以合并Foo localfunc的返回值和func外部的Foo foo的生命周期和身份。

即使是在中,第二个合并(在func的返回值和Foo foo之间)也是不可选的(技术上来说,从func返回的prvalue永远不是一个对象,只是一个表达式,然后绑定到构造Foo foo),但第一个仍然是可选的,并且需要存在移动或复制构造函数。

省略是一条规则,即使消除这些复制、销毁和构造会产生可观察的副作用,它也可能发生;它不是一个“as-if”优化。相反,它是对一个天真的人可能认为C++代码意味着什么的微妙改变。称其为“优化”有点不准确。

事实上,它是可选的,而且微妙的事情可能会破坏它,这是一个问题。

Foo func(bool b) {
  Foo long_lived;
  long_lived.futz();
  if (b)
  {
    Foo short_lived;
    return short_lived;
  }
  return long_lived;
}

在上面的情况下,编译器可以省略Foo long_livedFoo short_lived,但实现问题使得这基本上是不可能的,因为两个对象都不能将其生命周期与func的返回值合并;省略short_livedlong_lived是不合法的,它们的生命周期会重叠。您仍然可以在as-if下执行此操作,但只有在您能够检查和理解析构函数、构造函数和.futz()的所有副作用时才能这样做。

我理解得对吗:在我的情况下,这是NRVO,因此c++17不能保证复制省略。这意味着返回的值实际上是未指定的,因为编译器可以选择应用或不应用NRVO? - ead
@ead 是的,省略并非保证成功。编译器可能无法进行省略;只有在您向编译器传递一个标志要求不进行省略时,才会不进行省略。然而,它是脆弱的;如果添加了另一个返回命名对象的分支与重叠的生命周期,则您代码的结果将会发生变化。 - Yakk - Adam Nevraumont
当您说“second merge”时,我有一瞬间感到困惑。也许考虑重新排列段落。 - Passer By

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