C ++函数中“返回”的确切时刻


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称为(值为resis "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"请参见live)。

标准是否规定了上面示例中应返回的值,或者未指定?



8
为什么要非常小心地使用析构函数中的副作用的一个很好的例子。
马修(Matthew)阅读

1
关于何时return发生的问题,直到C ++ 14(!)之前的措辞都return没有说本地临时对象持续的时间足够长,无法用于构造返回值。
戴维斯·鲱鱼

@MatthewRead:您实际上想说的是是否需要避免整个RAII概念?
MichałŁoś

1
@MichałŁoś不。RAII概念实际上忽略了副作用。完善的RAII代码在构造函数中除初始化外没有任何代码。副作用是由对象外部的构造函数更改的。但是生活永远不会完美
斯威夫特-星期五派

Answers:


29

这是RVO(+将副本作为临时副本返回,使图片模糊不清),这是允许更改可见行为的一种优化方法:

10.9.5复制/移动省略号 (重点是我的)

当满足某些条件时,即使为复制/移动操作选择的构造函数和/或对象的析构函数具有副作用**,也允许实现忽略类对象的复制/移动构造。在这种情况下,实现将忽略的复制/移动操作的源和目标视为引用同一对象的两种不同方式

在以下情况下允许复制/移动操作的这种省略,称为复制删除(可以合并以消除多个副本):

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

根据是否应用,您的整个前提都会出错。在1.res调用了c'tor ,但是对象可能位于内部make_string_ok或外部。

情况1。

项目符号2和3.可能根本不会发生,但这是一个附带的观点。目标受到Writersdtor的影响,在之外make_string_ok。这恰好是make_string_ok在评估环境中使用临时创建的operator<<(ostream, std::string)。编译器创建一个临时值,然后执行该函数。这很重要,因为临时性生活不在其内,因此目标Writer不是本地的make_string_ok而是本地的operator<<

情况二

同时,您的第二个示例不符合该标准(为简洁起见,也未符合该标准),因为类型不同。于是作家死了。如果它是的一部分,它甚至会死pair。因此,这里的的副本res.first作为临时对象返回,然后的dtorWriter影响原始res.first要死亡。

很明显,复制是在调用析构函数之前完成的,因为复制返回的对象也已销毁,因此您将无法以其他方式复制它。

归根结底是RVO,因为 Writer根据是否应用优化,要么作用于外部对象要么作用于本地对象。

标准是否规定了上面示例中应返回的值,或者未指定?

不,优化是可选的,尽管它可以更改可观察的行为。是否应用它是由编译器决定的。它是免于“常规假设”规则的,该规则说,允许编译器进行任何不会改变可观察到的行为的转换。

在c ++ 17中,它成为强制性的一个案例,但您的不是。强制性的是返回值是未命名的临时值。


1
这稍有不同-它是另一个对象Writer)的析构函数,副作用可能会影响返回值。
Toby Speight,

@TobySpeight点已采取。我扩大了答案。粗体显示“该实现将忽略的复制/移动操作的源和目标视为引用同一objec的两种不同方式Writerd'tor的工作原理完全相同,只是目标对象不同。同样,返回返回复制和在返回时将dtor应用于局部值的顺序似乎很明显……您无法销毁将要复制以返回的对象。
luk32

@TobySpeight另外,我注意到强调d'tor省略可能是烟雾和屏幕可能很重要。重要的是被淘汰的副本所在的位置以及目标是什么Writer
luk32

37

由于返回值优化(RVO),可能不会调用std::string resin的析构函数make_string_okstring可以在调用者方构造该对象,并且该函数只能初始化该值。

该代码将等效于:

void make_string_ok(std::string& res){
    Writer w(res);
}

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

这就是为什么返回值应为“ AB”的原因。

在第二个示例中,RVO不适用,并且值将在调用return时精确地复制到返回的值,并且在复制发生Writer后将运行destructor res.first

6.6跳转语句

在退出范围(无论如何完成)时,将针对在该范围中声明的具有自动存储持续时间(3.7.2)的所有构造对象(命名对象或临时对象)以与声明相反的顺序调用析构函数(12.4)。循环外,块外或带自动存储持续时间的初始化变量的转移涉及销毁具有自动存储持续时间的变量,这些变量在从...传输的时间点范围内。

...

6.6.3退货声明

返回实体的复制初始化在返回语句操作数建立的全表达式结束时在临时变量销毁之前进行排序,而该操作数又在销毁局部变量(6.6)之前进行排序。包含return语句的块。

...

12.8复制和移动类对象

31当满足某些条件时,即使该对象的复制/移动构造函数和/或析构函数具有副作用,也允许实现省略类对象的复制/移动构造。在这种情况下,实现将忽略的复制/移动操作的源和目标视为引用同一对象的两种不同方式,并且该对象的销毁发生在两个对象本来应该以较晚的时间发生。在没有优化的情况下销毁。(123)在以下情况下允许使用此复制/移动操作的省略,称为复制删除(可以合并以消除多个副本):

—在具有类返回类型的函数的返回语句中,当表达式是具有与函数返回类型相同的cvunqualified类型的非易失性自动对象(函数或catch子句参数除外)的名称时,通过将自动对象直接构造到函数的返回值中,可以省略复制/移动操作

123)因为仅销毁了一个对象而不是两个,并且没有执行一个复制/移动构造函数,所以对于每个构造的对象,仍然销毁了一个对象。


1
我也想到了这一点,但令我惊讶的是-结果有所不同,因此,这不仅涉及复制副本。想知道,标准对此有何评论。
ead

1
添加了标准的报价
Shloim '18

3
@KonradRudolph好吧,如果将调用复制构造函数,那么将留下一些实例,必须在该实例上调用析构函数。如果不会调用复制构造函数,则也不能调用任何析构函数。因此,实际上,这两个语句(一个带有“析构函数”,一个带有“复制构造函数”)是等效的(如果我没记错的话)。
tomsmeding

3
@tomsmeding它们是不相等的,因为我们在谈论不同的对象:的析构函数std::string不会修改对象,而的析构函数Writer则会修改对象,为此,是否要Writer复制传递给构造函数的字符串是很重要的(但不是随后是否销毁了)。
康拉德·鲁道夫

1
@ead是的。优化是可选的,可以更改可观察的行为。它是免于“常规假设”规则的,该规则规定编译器可以进行任何不会改变可观察到的行为的转换。不仅关注的是消隐对象c'tors和d'tor的副作用,其他副作用的目标也发生了变化,在您的情况下,d'tor的Writer作用是否在其他对象上,而优化是否应用。它是由编译器酌情应用的。
luk32

17

C ++中有一个称为Elision的概念。

Elision接受了两个看似不同的对象,并合并了它们的标识和生存期。

之前 可能发生省略:

  1. 当您Foo f;在返回的函数中具有非参数变量Foo并且return语句很简单时return f;

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

新的prvalue规则消除了所有#2的情况(几乎?);删除不再发生,因为用于创建临时对象的操作不再这样做。而是,“临时”的构造直接绑定到永久对象的位置。

现在,考虑到编译器要编译到的ABI,省略并不总是可能的。可能的两种常见情况称为返回值优化和命名返回值优化。

RVO是这样的情况:

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

在这里,我们有一个返回值Foo(7),该返回值被隐藏在返回的值中,然后该值被隐藏在外部变量中foo。在运行时看来是3个对象(返回值foo(),行中的值returnFoo foo)实际上是1。

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

另一个著名的案例称为返回值优化NRVO。这是上面的(1)省略情况。

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

再次,省音可以合并的寿命和身份Foo local,从返回值funcFoo foo外面的func

甚至 ,第二个合并(在func返回值和之间Foo foo)是非可选的(从技术上讲,从返回的prvaluefunc绝不是一个对象,只是一个表达式,然后绑定到Construct Foo foo),但是第一个合并仍然是可选的,并且需要移动或复制构造函数以存在。

即使删除这些副本,破坏和构造会产生明显的副作用,清除也是一个规则。这不是“假设”优化。取而代之的是,与天真的人可能认为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;elidingshort_livedlong_lived在一起是不合法的,他们的寿命重叠。

您仍然可以按原样进行操作,但前提是您可以检查并了解析构函数,构造函数和的所有副作用.futz()


我是否正确理解:我的情况是NRVO,因此c ++ 17不能保证复制省略。这意味着,返回值实际上是未指定的,因为编译器可以自由应用或不应用NRVO?
ead

@ead是的,不能保证省略。编译器无法做到;如果您要求不做(只有将标志传递给编译器),它只会在您的情况下不这样做。但是,它很脆弱。添加另一个分支,并返回另一个在生命周期内重叠的命名对象,代码的结果将更改。
Yakk-Adam Nevraumont

你说了一会儿我感到很困惑second merge。可能需要考虑对段落重新排序。
过客
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.