第三次尝试。第二个在解释情况的每一个角落和缝隙的过程中变得很长。但是,嘿,在这个过程中我也学到了很多东西,我想这就是重点,不是吗?:) 反正。我将重新解决这个问题,保留我更长的答案,因为它本身就是一个有用的参考,但没有一个“明确的解释”。
我们在这里处理什么?
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() );
让我们考虑一下这里可能发生的最长事件序列。
- 一个新
A
的被建造在x
- 返回的临时值
x()
是一个新的A
,使用对前一个的引用进行初始化
- 使用临时值初始化一个新的
A
- -y
在可能的情况下,编译器应该重新安排事情,以便在可以安全地假设中间件不可访问或不必要A
的地方构建尽可能少的中间件。问题是我们可以没有哪些对象?
案例#1 是一个显式的新对象。如果我们要避免创建它,我们需要引用一个已经存在的对象。这是最直接的一个,无需多说。
在#2 中,我们无法避免构建一些结果。毕竟,我们是按值返回的。但是,有两个重要的异常(不包括在抛出时也会受到影响的异常本身):NRVO和RVO。这些会影响 #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
,它可以被回收并且现在可以是任何东西。
我们不能的原因是因为我们不能将地址传递给result
out 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&&
。
重新审查f
和g
我已经注释了两个函数中的情况以使事情顺利进行,这是编译器在为每个函数生成(可重用)代码时会注意的假设的候选清单。
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 区分我们调用的三种情况中的哪一种,因此编译器假定任何情况下它需要的最长存储持续时间,并且发现不假设任何关于存储持续时间的事情是最安全的。a
a
f
f
f
a
的数据。
不同于中的情况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 中,我们不是在构造新对象,而是将结果的生命周期延长到 的范围,或者说相同:使用就地构造,因此临时返回的临时对象的生命周期延长而不是被移动或复制。r
f
s
s
f
f
主要区别是 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
.
为什么没有temp
像a
in g
/那样延长生命周期f
?由于我们返回的内容:我们不能指定一个函数来就地构造事物,我们可以指定一个要就地构造的变量。它还表明编译器不会跨越函数/调用边界来确定生命周期,它只会查看哪些变量在调用端或本地,它们是如何分配的,如果是本地的,它们是如何初始化的。
如果您想清除所有疑虑,请尝试将其作为右值引用传递:std::move(*(new A))
- 应该发生的事情是任何东西都不应该破坏它,因为它不在堆栈上并且因为右值引用不会改变除了临时对象之外的任何东西的生命周期(即,中间体/表达式)。xvalues 是移动构造/移动分配的候选对象,不能被省略(已经构造),但理论上所有其他移动/复制操作都可以在编译器的突发奇想中被省略;当使用右值引用时,编译器别无选择,只能省略或传递地址。