7

如果我有一个类A和函数

A f(A &&a)
{
  doSomething(a);
  return a;
}
A g(A a)
{
  doSomething(a);
  return a;
}

a从返回时调用复制构造函数f,但从 返回时使用移动构造函数g。但是,据我了解,f只能传递一个可以安全移动的对象(临时对象或标记为可移动的对象,例如使用std::move)。从返回时使用移动构造函数不安全时,是否有任何示例f?为什么我们需要a自动存储期限?

我在这里阅读了答案,但最重要的答案仅表明规范在传递a给函数体中的其他函数时不应允许移动;它没有解释为什么返回时搬家是安全的,g但不适合f。一旦我们到达 return 语句,我们将不再需要ainside f

更新 0

所以我知道在完整表达结束之前可以访问临时对象。但是,从f仍然返回时的行为似乎违背了语言中根深蒂固的语义,即移动临时值或 xvalue 是安全的。例如,如果您调用,即使可能有对存储在某处的临时对象的引用,也会g(A())将临时对象移到参数中。g如果我们g使用 xvalue 调用,也会发生同样的情况。a由于只有临时变量和 xvalues 绑定到右值引用,因此在从 返回时我们仍然应该移动的语义似乎是一致的f,因为我们知道a传递的是临时变量或 xvalue。

4

3 回答 3

7

第二次尝试。希望这更简洁明了。

在本次讨论中,我将几乎完全忽略 RVO。这使得在没有优化的情况下应该发生什么非常令人困惑——这只是关于移动与复制语义。

为了帮助这一点,参考将在此处对c++11 中的各种值类型非常有帮助。

什么时候搬家?

左值

这些永远不会移动。它们引用可能在其他地方引用的变量或存储位置,因此不应将其内容转移到另一个实例。

公允价值

上面将它们定义为“没有身份的表达式”。显然,没有其他东西可以引用无名值,因此可以移动这些值。

右值

“右手”值的一般情况,唯一可以确定的是它们可以被移动。他们可能有也可能没有命名的引用,但如果他们这样做,这是最后一次这样的用法。

极值

这些是两者的混合-它们具有身份(是参考)并且可以从中移出。它们不需要有命名变量。原因?它们是即将被销毁的过期值。将它们视为“最终参考”。xvalues 只能从 rvalues 生成,这就是为什么/如何std::move将 lvalues 转换为 xvalues(通过函数调用的结果)。

左值

另一种具有右值表亲的突变类型,它可以是 xvalue 或 lvalue - 它具有标识但不清楚这是否是对变量/存储的最后引用,因此不清楚它是否可以或不能从.

决议令

如果存在可以接受 aconst lvalue ref或的重载rvalue ref,并且传递了右值,则绑定右值,否则使用左值版本。(移动右值,否则复制)。

可能发生的地方

(假设所有类型都A没有提到)

它仅发生在对象“从相同类型的 xvalue 初始化”的情况下。xvalues 绑定到 rvalues 但不像纯表达式那样受限制。换句话说,可移动的东西不仅仅是未命名的引用,它们也可以是对编译器感知的对象的“最后”引用。

初始化

A a = std::move(b); // assign-move
A a( std::move(b) ); // construct-move

函数参数传递

void f( A a );
f( std::move(b) );

函数返回

A f() {
    // A a exists, will discuss shortly
    return a;
}

为什么它不会发生在f

考虑 f 的这种变化:

void action1(A & a) {
    // alter a somehow
}

void action2(A & a) {
    // alter a somehow
}

A f(A && a) {
    action1( a );
    action2( a );
    return a;
}

a将其视为 内的左值并不违法f。因为它是一个lvalue它必须是一个引用,无论是否明确。从技术上讲,每个普通变量都是对自身的引用。

那就是我们绊倒的地方。因为a是一个左值f,我们实际上是在返回一个左值。

要显式生成右值,我们必须使用std::move(或以A&&其他方式生成结果)。

为什么会发生在g

有了我们的腰带,考虑g

A g(A a) {
    action1( a ); // as above
    action2( a ); // as above
    return a;
}

是的,对于anda来说是一个左值。但是,由于所有对 的引用都只存在于其中(它是副本或移入副本),因此可以将其视为返回中的 xvalue。action1action2ag

但是为什么不在f呢?

没有特定的魔法&&。确实,您首先应该将其视为参考。我们要求右值引用f不是左值引用这一事实并A&没有改变这样一个事实,即作为一个引用,它必须是一个左值,因为存储位置a外部f的,并且任何编译器都会这样做关注。

这同样不适用于g,很明显a' 的存储是临时的,并且仅在g被调用时才存在,而在其他时间则不存在。在这种情况下,它显然是一个 xvalue 并且可以移动。


rvalue reflvalue ref参考传递的安全性

假设我们重载一个函数来接受这两种类型的引用。会发生什么?

void v( A  & lref );
void v( A && rref );

唯一的时间void v( A&& )将按照上述使用(“可能发生的地方”),否则void v( A& )。也就是说,在尝试左值引用重载之前,右值引用将始终尝试绑定到右值引用签名。左值引用不应该绑定到右值引用,除非它可以被视为一个 xvalue(保证在当前范围内被销毁,无论我们是否想要它)。

很有可能会说,在右值情况下,我们确定传递的对象是临时的。事实并非如此。它是一个签名,用于绑定对看似临时对象的引用。

打个比方,这就像做int * x = 23;- 它可能是错误的,但如果你运行它,你可以(最终)强制它编译,结果不好。编译器不能肯定地说你是认真的还是拉扯它的腿。

关于安全性,必须考虑执行此操作的函数(以及为什么不执行此操作 - 如果它仍然可以编译):

A & make_A(void) {
    A new_a;
    return new_a;
}

虽然语言方面表面上没有任何问题 - 类型有效,我们将获得对某个地方的引用- 因为new_a的存储位置在函数内部,当函数返回时,内存将被回收/无效。因此,任何使用此函数结果的东西都将处理已释放的内存。

同样,A f( A && a )如果我们真的想强制其他东西通过,则旨在但不限于接受prvalues或xvalues。这就是std::move进来的地方,让我们这样做。

之所以会出现这种情况,是因为它与A f( A & a ) rvalue 重载相比,仅在首选上下文方面有所不同。在所有其他方面,a编译器如何处理它是相同的。

我们知道A&&是为移动保留的签名这一事实尚无定论。它用于确定A我们要绑定到哪个版本的“对 -type 参数的引用”,我们应该获得所有权的排序(右值)或我们不应该获得底层数据所有权(左值)的排序(即,将其移至别处并擦除我们给出的实例/引用)。在这两种情况下,我们正在使用的是对不受控制的内存的引用f

我们是否这样做不是编译器可以判断的;它属于编程的“常识”领域,例如不使用没有意义但有效的内存位置的内存位置。

编译器知道的A f( A && a )是不要为 . 创建新的存储a,因为我们将获得一个地址(引用)来使用。我们可以选择保持源地址不变,但这里的整个想法是,通过声明A&&我们是在告诉编译器“嘿!给我对即将消失的对象的引用,这样我就可以在那之前用它做点什么发生”。这里的关键词是可能,而且我们可以明确地错误地定位这个函数签名这一事实。

考虑一下,如果我们有一个版本A,在移动构造时,不会删除旧实例的数据,出于某种原因,我们是故意这样做的(假设我们有自己的内存分配函数,并且确切地知道我们的内存模型将如何保持超出对象生命周期的数据)。

编译器无法知道这一点,因为它需要进行代码分析来确定在右值绑定中处理对象时会发生什么——此时这是一个人工判断问题。编译器最多只能看到“引用,是的,这里没有分配额外的内存”并遵循引用传递规则。

可以安全地假设编译器正在考虑:“它是一个引用,我不需要在里面处理它的内存生命周期f,它是一个临时的,将在f完成后被删除”。

在这种情况下,当一个临时对象被传递给 时f,该临时对象的存储将在我们离开后立即消失f,然后我们可能处于与A & make_A(void)- 非常糟糕的情况相同的情况。

语义问题...

std::move

的目的std::move是创建右值引用。总的来说,它所做的(如果没有别的)是强制结果值绑定到右值而不是左值。这样做的原因是A&在右值引用可用之前的返回签名,对于诸如运算符重载(当然还有其他用途)之类的事情是模棱两可的。

运算符 - 一个例子

class A {
    // ...
  public:
    A & operator= (A & rhs); // what is the lifetime of rhs? move or copy intended?
    A & operator+ (A & rhs); // ditto
    // ...
};

int main() {
    A result = A() + A(); // wont compile!
}

请注意,这不会接受任一操作员的临时对象!在对象复制操作的情况下这样做也没有意义 - 为什么我们需要修改我们正在复制的原始对象,可能是为了有一个我们可以稍后修改的副本。这就是我们必须const A &为复制操作符声明参数的原因,以及在任何需要复制引用的情况下,以保证我们不会更改原始对象。

自然这是移动的问题,我们必须修改原始对象以避免新容器的数据被过早释放。(因此“移动”操作)。

为了解决这个混乱,T&&声明出现了,它是上面示例代码的替代品,特别是在上面无法编译的情况下对对象的引用。但是,我们不需要修改operator+为移动操作,而且您很难找到这样做的理由(尽管我认为您可以)。同样,由于加法不应该修改原始对象的假设,只修改表达式中的左操作数对象。所以我们可以这样做:

class A {
    // ...
  public:
    A & operator= (const A & rhs); // copy-assign
    A & operator= (A && rhs); // move-assign
    A & operator+ (const A & rhs); // don't modify rhs operand
    // ...
};

int main() {
    A result = A() + A(); // const A& in addition, and A&& for assign
    A result2 = A().operator+(A()); // literally the same thing
}

您在这里应该注意的是,尽管返回A()一个临时值,但它不仅能够绑定,const A&而且由于加法的预期语义(它不会修改其正确的操作数),它应该绑定。赋值的第二个版本更清楚地说明了为什么只需要修改其中一个参数。

同样清楚的是,分配将发生移动,而rhsin不会发生移动operator+

返回值语义和参数绑定语义的分离

从函数(好吧,运算符)定义中可以清楚地看出上面只有一步的原因。重要的是,我们确实将显然是 xvalue / rvalue 的内容绑定到operator+.

我必须强调这一点:在这个例子中没有有效的区别在于他们的论点operator+operator=引用的方式。就编译器而言,在任一函数体内,参数实际上是const A&for+A&for =。区别纯粹在于constness。唯一不同的方法是区分签名,而不是类型A&A&&

不同的签名具有不同的语义,它是编译器的工具包,用于区分与代码没有明显区别的某些情况。函数本身的行为 - 代码体 - 也可能无法区分这些情况!

另一个例子是operator++(void)vs operator++(int)。前者期望在增量操作之前返回其基础值,而后者则在之后。没有int被传递,只是编译器有两个签名可以使用 - 没有其他方法可以指定两个具有相同名称的相同函数,并且您可能知道也可能不知道,重载函数是非法的出于类似的模糊原因,仅在返回类型上。

右值变量和其他奇怪的情况 - 详尽的测试

为了明确地理解发生了什么,f我整理了一大堆“不应该尝试但看起来可以工作”的东西,这迫使编译器几乎详尽无遗地处理这个问题:

void bad (int && x, int && y) {
  x += y;
}
int & worse (int && z) {
  return z++, z + 1, 1 + z;
}
int && justno (int & no) {
  return worse( no );
}
int num () {
  return 1;
}
int main () {
  int && a = num();
  ++a = 0;
  a++ = 0;
  bad( a, a );
  int && b = worse( a );
  int && c = justno( b );
  ++c = (int) 'y';
  c++ = (int) 'y';
  return 0;
}

g++ -std=gnu++11 -O0 -Wall -c -fmessage-length=0 -o "src\\basictest.o" "..\\src\\basictest.cpp"

..\src\basictest.cpp: In function 'int& worse(int&&)':
..\src\basictest.cpp:5:17: warning: right operand of comma operator has no effect [-Wunused-value]
   return z++, z + 1, 1 + z;
                 ^
..\src\basictest.cpp:5:26: error: invalid initialization of non-const reference of type 'int&' from an rvalue of type 'int'
   return z++, z + 1, 1 + z;
                          ^
..\src\basictest.cpp: In function 'int&& justno(int&)':
..\src\basictest.cpp:8:20: error: cannot bind 'int' lvalue to 'int&&'
   return worse( no );
                    ^
..\src\basictest.cpp:4:7: error:   initializing argument 1 of 'int& worse(int&&)'
 int & worse (int && z) {
       ^
..\src\basictest.cpp: In function 'int main()':
..\src\basictest.cpp:16:13: error: cannot bind 'int' lvalue to 'int&&'
   bad( a, a );
             ^
..\src\basictest.cpp:1:6: error:   initializing argument 1 of 'void bad(int&&, int&&)'
 void bad (int && x, int && y) {
      ^
..\src\basictest.cpp:17:23: error: cannot bind 'int' lvalue to 'int&&'
   int && b = worse( a );
                       ^
..\src\basictest.cpp:4:7: error:   initializing argument 1 of 'int& worse(int&&)'
 int & worse (int && z) {
       ^
..\src\basictest.cpp:21:7: error: lvalue required as left operand of assignment
   c++ = (int) 'y';
       ^
..\src\basictest.cpp: In function 'int& worse(int&&)':
..\src\basictest.cpp:6:1: warning: control reaches end of non-void function [-Wreturn-type]
 }
 ^
..\src\basictest.cpp: In function 'int&& justno(int&)':
..\src\basictest.cpp:9:1: warning: control reaches end of non-void function [-Wreturn-type]
 }
 ^

01:31:46 Build Finished (took 72ms)

这是您不需要看到的未更改的输出 sans build header :) 我将把它作为一个练习来理解发现的错误,但重新阅读我自己的解释(特别是以下内容)应该很明显每个错误无论如何,是由以及为什么造成的。

结论 - 我们可以从中学到什么?

首先,请注意编译器将函数体视为单独的代码单元。这基本上是这里的关键。无论编译器对函数体做什么,它都不能对需要改变函数体的函数行为做出假设。为了处理这些情况,有模板,但这超出了本讨论的范围——只需注意模板会生成多个函数体来处理不同的情况,否则相同的函数体必须在可以使用函数的每种情况下都可重用。

其次,右值类型主要是为移动操作设想的——这是一种非常特殊的情况,预计会在对象的分配和构造中发生。使用右值引用绑定的其他语义超出了任何编译器的处理范围。换句话说,将右值引用视为语法糖比实际代码更好。A&&vs的签名不同,A&但函数体的参数类型不同,它总是被视为意图A&以某种方式修改传递的对象,因为虽然在语法上是正确的,但不会允许所需的行为.const A&

在这一点上,当我说编译器将生成代码体时,我可以非常确定,f就好像它已被声明一样f(A&)。如上所述,A&&帮助编译器选择何时允许绑定可变引用,f否则编译器不会考虑的语义f(A&)返回f(A&&)的内容不同。f

说来话长: 的返回方法f不依赖于它接收的参数类型。

混淆是省略。实际上,返回值有两个副本。首先创建一个副本作为临时副本,然后将此临时副本分配给某些东西(或者它不是并且仍然纯粹是临时的)。第二副本很可能通过返回优化被删除。第一个副本可以移入,不能g移入f。我希望在f无法省略的情况下,将有一个副本,然后从f原始代码中移动。

要覆盖它,必须使用 显式构造临时变量std::move,即在 中的 return 语句中f。然而,在g我们返回的东西是已知的临时函数体g,因此它要么被移动两次,要么被移动一次然后被删除。

我建议在禁用所有优化的情况下编译原始代码,并添加诊断消息以复制和移动构造函数,以便在省略成为一个因素之前密切关注值在何时何地被移动或复制。即使我弄错了,使用的构造函数/操作的未优化跟踪也会清楚地描绘编译器所做的事情,希望它会很明显为什么它也会这样做......

于 2016-06-17T07:10:33.803 回答
0

短篇小说:它只取决于doSomething.

中等故事:如果doSomething 永不改变a,那么f是安全的。它接收一个右值引用并返回一个从那里移动的新临时值。

doSomething长话短说:一旦在移动操作中使用,事情就会变糟a,因为 a 在用于 return 语句之前可能处于未定义状态 - 它在g但至少转换为右值引用应该是相同的明确的

TL/DR:只要里面没有移动操作,两者都是安全f的。不同之处在于移动将在 中静默执行,而它需要在 g 中显式转换为右值引用(例如 with )。gdoSomethingfstd::move

于 2016-06-23T07:44:36.497 回答
0

第三次尝试。第二个在解释情况的每一个角落和缝隙的过程中变得很长。但是,嘿,在这个过程中我也学到了很多东西,我想这就是重点,不是吗?:) 反正。我将重新解决这个问题,保留我更长的答案,因为它本身就是一个有用的参考,但没有一个“明确的解释”。

我们在这里处理什么?

f并且g不是微不足道的情况。他们需要时间来理解和欣赏你遇到他们的最初几次。起作用的问题是对象的生命周期返回值优化返回对象值的混淆以及引用类型重载的混淆。我将解决每个问题并解释它们的相关性。

参考

第一件事是第一件事。什么是参考?它们不只是没有语法的指针吗?

它们是,但在一个重要的方面,它们远不止于此。指针字面意思是,它们通常指的是内存位置。对于位于指针设置位置的值,几乎没有任何保证。另一方面,引用绑定到实际值的地址——这些值保证在它们可以被访问的持续时间内存在,但可能没有可以以任何其他方式访问的名称(例如临时值)。

根据经验,如果您可以“获取其地址”,那么您正在处理一个引用,一个相当特殊的引用,称为lvalue. 您可以分配给左值。这就是为什么*pointer = 3有效,操作员*创建对所指向地址的引用。

这不会使引用比它指向的地址或多或少有效,但是,您在 C++ 中自然找到的引用确实有这个保证(就像编写良好的 C++ 代码一样)——它们指的是实际值在我们与他们互动的过程中,我们不需要知道它的生命周期。

对象的生命周期

我们现在都应该知道什么时候会调用 c'tors 和 d'tors 来做这样的事情:

{
  A temp;
  temp.property = value;
}

temp的范围已设置。我们确切地知道它何时被创建和销毁。我们可以确定它被销毁的一种方法是因为这是不可能的:

A & ref_to_temp = temp; // nope
A * ptr_to_temp = &temp; // double nope

编译器阻止我们这样做,因为明显我们不应该期望该对象仍然存在。每当使用引用时,这可能会微妙地出现,这就是为什么有时会发现人们建议避免引用,直到你知道你在用它们做什么(或者完全如果他们已经放弃理解它们并且只想继续他们的生活)。

表达范围

另一方面,我们还必须注意临时变量的存在直到找到它们的最外层表达式完成为止。这意味着直到分号。例如,逗号运算符的 LHS 中存在的表达式直到分号才会被破坏。IE:

struct scopetester {
    static int counter = 0;
    scopetester(){++counter;}
    ~scopetester(){--counter;}
};

scopetester(), std::cout << scopetester::counter; // prints 1
scopetester(), scopetester(), std::cout << scopetester::counter; // prints 2

这仍然不能避免执行顺序的问题,您仍然必须处理其他事情 - 运算符优先级和在强制模棱两可的情况下可能导致++i++的可怕的未定义行为i++ = ++i(例如)。重要的是创建的所有临时对象都存在直到分号,并且不再存在。

有两个例外 - elision / in-place-construction (aka RVO) 和reference-assignment-from-temporary

按值返回和省略

什么是省略?为什么要使用 RVO 和类似的东西?所有这些都归结为一个更容易理解的术语——“就地建设”。假设我们正在使用函数调用的结果来初始化或设置一个对象。例如:

A x (void) {return A();}
A y( x() );

让我们考虑一下这里可能发生的最长事件序列。

  1. 一个新A的被建造在x
  2. 返回的临时值x()是一个新的A,使用对前一个的引用进行初始化
  3. 使用临时值初始化一个新的A- -y

在可能的情况下,编译器应该重新安排事情,以便在可以安全地假设中间件不可访问或不必要A的地方构建尽可能少的中间件。问题是我们可以没有哪些对象?

案例#1 是一个显式的新对象。如果我们要避免创建它,我们需要引用一个已经存在的对象。这是最直接的一个,无需多说。

在#2 中,我们无法避免构建一些结果。毕竟,我们是按值返回的。但是,有两个重要的异常(不包括在抛出时也会受到影响的异常本身):NRVORVO。这些会影响 #3 中发生的事情,但关于 #2 有重要的后果和规则......

这是由于一个有趣的 elision 怪癖

笔记

复制省略是唯一允许的可以改变可观察到的副作用的优化形式。因为有些编译器不会在所有允许的情况下执行复制省略(例如,在调试模式下),所以依赖于复制/移动构造函数和析构函数的副作用的程序是不可移植的。

即使发生复制省略并且未调用复制/移动构造函数,它也必须存在且可访问(就好像根本没有发生优化一样),否则程序是错误的。

(C++11 起)

在 return 语句或 throw 表达式中,如果编译器无法执行复制省略,但满足或将满足复制省略的条件,除了源是函数参数,编译器将尝试使用移动构造函数,即使对象由左值指定;有关详细信息,请参阅退货声明。

更多关于退货声明的说明

笔记

按值返回可能涉及临时对象的构造和复制/移动,除非使用复制省略。

(C++11 起)

Ifexpression是一个左值表达式,并且满足或将满足复制省略的条件,除了expression命名函数参数,然后执行两次重载决策以选择用于初始化返回值的构造函数:首先就像expression是右值表达式(因此它可以选择移动构造函数或引用 const 的复制构造函数),如果没有合适的转换可用,则使用左值表达式第二次执行重载决议(因此它可以选择引用的复制构造函数到非常量)。

即使函数返回类型与类型不同,上述规则也适用expression(复制省略需要相同类型)

编译器甚至可以将多个省略号链接在一起。这意味着,涉及中间对象的移动/复制的两侧,可能会直接相互引用,甚至可以成为同一个对象。我们不知道也不应该知道编译器何时选择这样做 - 这是一种优化,但重要的是,您应该将移动和复制构造函数等视为“最后的手段”使用。

我们可以同意目标是减少任何优化中不必要的操作的数量,前提是可观察的行为是相同的。移动和复制构造函数用于发生移动和复制操作的任何地方,那么当编译器认为适合删除移动/复制操作本身作为优化时呢?功能上不必要的中间对象是否应该仅出于副作用的目的而存在于最终程序中?现在标准和编译器的方式似乎是:不 - 移动和复制构造函数满足这些操作的方式,而不是何时为什么

简短的版本:你有更少的临时对象,你应该不关心开始,所以你为什么要错过它们。如果您确实错过了它们,那可能只是您的代码依赖于中间副本,并且会超出其既定目的和上下文来做事情。

最后,您需要注意被省略的对象始终存储(和构造)在接收位置,而不是其初始位置。

引用这个参考-

命名返回值优化

如果函数按值返回类类型,并且return语句的表达式是具有自动存储持续时间的非易失性对象的名称,该对象不是函数参数,也不是catch子句参数,并且具有相同的类型(忽略顶级 cv 限定)作为函数的返回类型,然后复制/移动被省略。当构造该本地对象时,它直接在存储中构造,否则函数的返回值将被移动或复制到该存储中。这种复制省略的变体被称为 NRVO,“命名返回值优化”。

返回值优化

当未绑定到任何引用的无名临时对象将被移动或复制到相同类型的对象中(忽略顶级 cv 限定)时,将省略复制/移动。当构建该临时文件时,它会直接在存储中构建,否则它将被移动或复制到。当无名临时变量是 return 语句的参数时,这种复制省略的变体称为 RVO,“返回值优化”。

参考文献的生命周期

我们不应该做的一件事是:

A & func() {
    A result;
    return result;
}

虽然很诱人,因为它可以避免任何内容的隐式复制(我们只是传递一个地址,对吗?)它也是一种短视的方法。还记得上面的编译器阻止了这样的事情temp吗?同样的东西 -result一旦我们完成了它就消失了func,它可以被回收并且现在可以是任何东西。

我们不能的原因是因为我们不能将地址传递给resultout func- 无论是作为引用还是作为指针 - 并认为它是有效内存。我们不会再昏倒A*了。

在这种情况下,最好使用对象复制返回类型,并在编译器认为合适的情况下依赖移动、省略或两者兼而有之。始终将复制和移动构造函数视为“最后手段”——您不应依赖编译器来使用它们,因为编译器可以找到完全避免复制和移动操作的方法,并且允许这样做,即使这意味着这些构造函数的副作用不会再发生。

然而,有一个特例,前面提到过。

回想一下,引用是对真实值的保证。这意味着引用的第一次出现会初始化对象,而最后一次(就编译时所知)在超出范围时会破坏它。

广义上这包括两种情况:当我们从一个函数返回一个临时值时。当我们从函数结果中赋值时。第一个,返回一个临时的,基本上是 elision 所做的,但实际上您可以通过引用传递显式地省略 - 就像在调用链中传递一个指针一样。它在返回时构造对象,但改变的是对象在离开作用域后不再被销毁(返回语句)。而在另一端发生了第二种情况——存储函数调用结果的变量现在有幸在超出范围时销毁该值。

这里重要的一点是省略和引用传递是相关的概念。您可以通过使用指向未初始化变量的存储位置(已知类型)的指针来模拟省略,例如,您可以使用引用传递语义(基本上它们的用途)。

引用类型的重载

引用允许我们将非局部变量视为局部变量 - 获取它们的地址,写入该地址,从该地址读取,重要的是,能够在正确的时间销毁对象 - 当地址不能不再被任何事物所触及。

常规变量离开作用域时,对它们的唯一引用消失,并在那时被立即销毁。引用变量可以引用常规变量,但除了省略/ RVO 情况外,它们不会影响原始对象的范围 - 即使它们引用的对象提前超出范围也不会影响,如果您引用动态内存可能会发生这种情况并且不小心自己管理这些引用。

这意味着您可以通过引用显式捕获表达式的结果。如何?好吧,一开始这可能看起来很奇怪,但是如果您阅读了上面的内容,就会明白为什么会这样:

class A {
    /* assume rule-of-5 (inc const-overloads) has been followed but unless
     * otherwise noted the members are private */
  public:
    A (void) { /* ... */ }
    A operator+ ( const A & rhs ) {
        A res;
        // do something with `res`
        return res;
    }
};

A x = A() + A(); // doesn't compile
A & y = A() + A(); // doesn't compile
A && z = A() + A(); // compiles

为什么?这是怎么回事?

A x = ...- 我们不能因为构造函数和赋值是私有的。

A & y = ...- 我们不能,因为我们正在返回一个值,而不是对范围大于或等于我们当前范围的值的引用。

A && z = ...- 我们可以,因为我们能够引用 xvalues。作为这个赋值的结果,临时值的生命周期被延长到这个捕获的左值,因为它实际上已经成为一个左值引用。听起来有点熟?如果我称它为任何东西,那就是明确的省略。当您认为此语法必须涉及新值并且必须涉及将该值分配给引用时,这一点会更加明显。

在所有构造函数和赋值都公开的所有三种情况下,始终只构造三个对象,其地址res始终与存储结果的变量匹配。(无论如何,在我的编译器上,优化被禁用,-std=gnu++11,g++ 4.9.3)。

这意味着差异确实归结为函数参数本身的存储持续时间。除了纯表达式、过期值或“过期值”引用重载的显式目标之外,删除和移动操作不会发生在任何事情上Type&&

重新审查fg

我已经注释了两个函数中的情况以使事情顺利进行,这是编译器在为每个函数生成(可重用)代码时会注意的假设的候选清单。

A f( A && a ) {
    // has storage duration exceeding f's scope.
    // already constructed.

    return a;
    // can be elided.
    // must be copy-constructed, a exceeds f's scope.
}

A g( A a ) {
    // has storage duration limited to this function's scope.
    // was just constructed somehow, whether by elision, move or copy.

    return a;
    // elision may occur.
    // can move-construct if can't elide.
    // can copy-construct if can't move.
}

我们可以肯定地说f'sa是它期望捕获移动或表达式类型的值。因为f可以接受表达式引用(prvalues)或即将消失的左值引用(xvalues)或移动的左值引用(通过 转换为xvalues std::move),并且因为在处理所有三种情况时f必须是同质的,所以被视为首先引用一个内存区域,该区域的生命周期比调用. 也就是说,无法从 inside 区分我们调用的三种情况中的哪一种,因此编译器假定任何情况下它需要的最长存储持续时间,并且发现不假设任何关于存储持续时间的事情是最安全的。aafffa的数据。

不同于中的情况g。在这里,a——不管它发生在它的价值上——将不再可以通过调用g. 因此,返回它就等于移动它,因为在这种情况下它被视为一个 xvalue。我们仍然可以复制它,甚至更可能忽略它,这取决于当时允许/定义A的内容。

与问题f

// we can't tell these apart.
// `f` when compiled cannot assume either will always happen.
// case-by-case optimizations can only happen if `f` is
// inlined into the final code and then re-arranged, or if `f`
// is made into a template to specifically behave differently
// against differing types.

A case_1() {
    // prvalues
    return f( A() + A() );
}

A make_case_2() {
    // xvalues
    A temp;
    return temp;
}
A case_2 = f( make_case_2() )

A case_3(A & other) {
    // lvalues
    return f( std::move( other ) );
}

由于使用的模糊性,编译器和标准被设计成f在所有情况下都可以始终如一地使用。不能假设A&&永远是一个新的表达式,或者你只会将它std::move用于它的参数等。一旦f在你的代码之外,只留下它的调用签名,那就不能再成为借口了。函数签名 - 引用重载到目标 - 是函数应该用它做什么以及它可以对上下文假设多少(或很少)的线索。

右值引用不是仅针对“移动值”的灵丹妙药,它们可以针对更多的东西,甚至如果您认为这就是它们所做的一切,它们甚至会被错误地或意外地定位。一般来说,对任何事物的引用都应该比引用存在的时间更长,但一个例外是右值引用变量。

右值引用变量本质上是省略运算符。无论它们存在于何处,都会进行某种描述的就地建设。

作为常规变量,它们扩展了它们接收到的任何 xvalue 或 rvalue 的范围——它们保存表达式的结果,因为它是构造的,而不是通过移动或复制,并且从那里等同于使用中的常规引用变量。

作为函数变量,它们也可以就地省略和构造对象,但这之间有一个非常重要的区别:

A c = f( A() );

和这个:

A && r = f( A() );

不同之处在于不能保证c会被移动构造还是被删除,但r由于我们绑定的性质,肯定会在某个时候被删除/就地构造。出于这个原因,我们只能r在将创建新的临时值的情况下分配给。

但是为什么A&&a被捕获不被销毁呢?

考虑一下:

void bad_free(A && a) {
    A && clever = std::move( a );
    // 'clever' should be the last reference to a?
}

这行不通。原因很微妙。a的作用域更长,右值引用赋值只能延长生命周期,不能控制它。clever存在的时间少于a,因此它本身不是一个 xvalue(除非std::move再次使用,但随后您又回到了相同的情况,并且它继续等等)。

延长寿命

请记住,左值与右值的不同之处在于它们不能绑定到生命周期比自身短的对象。所有左值引用要么是原始变量,要么是生命周期比原始变量短的引用。

rvalues 允许绑定到生命周期比原始值更长的引用变量- 这是一半。考虑:

A r = f( A() ); // v1
A && s = f( A() ); // v2

怎么了?在这两种情况下f,都给出了一个比调用更有效的临时值,并且以某种方式f构造了一个结果对象(因为按值返回)(这并不重要,正如您将看到的那样)。在 v1 中,我们正在使用临时结果构造一个新对象——我们可以通过三种方式做到这一点:移动、复制、删除。在 v2 中,我们不是在构造新对象,而是将结果的生命周期延长到 的范围,或者说相同:使用就地构造,因此临时返回的临时对象的生命周期延长而不是被移动或复制。rfssff

主要区别是 v1需要定义移动和复制构造函数(至少一个),即使过程被省略。对于 v2,您没有调用构造函数,而是明确表示要引用和/或延长临时值的生命周期,并且因为您不调用移动或复制构造函数,编译器只能就地省略/构造!

请记住,这与给出的论点无关f。它与以下内容相同g

A r = g( A() ); // v1
A && s = g( A() ); // v2

g将为它的参数创建一个临时的,并A()为这两种情况使用移动构造它。它还f为它的返回值构造了一个临时值,但它可以使用一个 xvalue,因为结果是使用一个临时值(临时到g)构造的。同样,这无关紧要,因为在 v1 中我们有一个可以复制构造或移动构造的新对象(两者都是必需的,但不是两者都需要),而在 v2 中,我们要求引用已构造但如果我们不这样做就会消失的东西'抓住它。

显式 xvalue 捕获

证明这一点的示例在理论上是可能的(但无用):

A && x (void) { 
    A temp;
    // return temp; // even though xvalue, can't do this
    return std::move(temp);
}
A && y = x(); // y now refers to temp, which is destroyed

y指的是哪个对象?我们让编译器别无选择:y必须引用某个函数或表达式的结果,并且我们已经给它temp提供了基于类型的工作。但是没有发生任何移动,并且temp将在我们使用它时通过y.

为什么没有tempain g/那样延长生命周期f?由于我们返回的内容:我们不能指定一个函数来就地构造事物,我们可以指定一个要就地构造的变量。它还表明编译器不会跨越函数/调用边界来确定生命周期,它只会查看哪些变量在调用端或本地,它们是如何分配的,如果是本地的,它们是如何初始化的。

如果您想清除所有疑虑,请尝试将其作为右值引用传递:std::move(*(new A))- 应该发生的事情是任何东西都不应该破坏它,因为它不在堆栈上并且因为右值引用不会改变除了临时对象之外的任何东西的生命周期(即,中间体/表达式)。xvalues 是移动构造/移动分配的候选对象,不能被省略(已经构造),但理论上所有其他移动/复制操作都可以在编译器的突发奇想中被省略;当使用右值引用时,编译器别无选择,只能省略或传递地址。

于 2016-06-26T16:31:21.373 回答