C++17的值类别
几年前写的一篇C++11的值类别,有朋友指出了里面的一些问题
改完小问题以后,回头看感觉其实写的很烂,很多重要内容没有提到,当然这和C++11的值类别本身比较晦涩,我当时理解的也很烂有关系
刚好C++17简化了值类别,那么直接再写一篇关于值类别的文章
表达式的类型和值类别
表达式有两个属性
- 类型:描述计算产生的值的静态类型
- 值类别:描述值是如何产生的,以及表达式的行为如何被影响
表达式之间能够赋值,不只是需要判断类型,还需要判断值类别是否成立
例如表达式“7”的类型是int,表达式“5+2”的类型是int
7 = 5+2这个表达式无法编译,两个表达式的类型成立,但是值类别无法成立
C++98:传统的左值和右值
在=号左边的就是左值,右边的就是右值
在C++11以前,值类别几乎没有什么存在感
根据C++98的标准定义(3.10 Lvalues and rvalues)
Every expression is either an lvalue or an rvalue.
An lvalue refers to an object or function. Some rvalue expressions—those of class or cv-qualified class type—also refer to objects
这个定义很模糊,左值是指对象或函数,右值也是对象,基本上等于啥也没说
随后标准列举了一些表达式是左右值细则,其中有一条是
Whenever an lvalue appears in a context where an rvalue is expected, the lvalue is converted to an rvalue; see 4.1, 4.2, and 4.3.
然后在4.1 Lvalue-to-rvalue conversion中正式定义了左值到右值的转换:
An lvalue (3.10) of a non-function, non-array type T can be converted to an rvalue.
例如:
1 | int x = 7; |
表达式x本身是一个左值,因为它指代变量x这个对象。但是在初始化y的时候,需要的是x中保存的值,因此这里会发生左值到右值的隐式转换。
C++11:lvalue、xvalue和prvalue
到了C++11以后,值类别被重新进行了定义
- An lvalue (so called, historically, because lvalues could appear on the left-hand side of an assignment expression) designates a function or an object. [Example: If E is an expression of pointer type, then *E is an lvalue expression referring to the object or function to which E points. As another example, the result of calling a function whose return type is an lvalue reference is an lvalue. —end example ]
- An xvalue (an “eXpiring” value) also refers to an object, usually near the end of its lifetime (so that its resources may be moved, for example). An xvalue is the result of certain kinds of expressions involving rvalue references (8.3.2). [Example: The result of calling a function whose return type is an rvalue reference is an xvalue. —end example ]
- A glvalue (“generalized” lvalue) is an lvalue or an xvalue.
- An rvalue (so called, historically, because rvalues could appear on the right-hand side of an assignment expression) is an xvalue, a temporary object (12.2) or subobject thereof, or a value that is not associated with an object.
- A prvalue (“pure” rvalue) is an rvalue that is not an xvalue. [Example: The result of calling a function whose return type is not a reference is a prvalue. The value of a literal such as 12, 7.3e5, or true is also a prvalue. —end example ]
由于被分为了左值(lvalue),将亡值(xvalue)和纯右值(prvalue)三个最细分的类型,因此直接读标准显得非常难以理解
根据cppreference的定义
With the introduction of move semantics in C++11, value categories were redefined to characterize two independent properties of expressions[5]:
- has identity: it's possible to determine whether the expression refers to the same entity as another expression, such as by comparing addresses of the objects or the functions they identify (obtained directly or indirectly);
- can be moved from: move constructor, move assignment operator, or another function overload that implements move semantics can bind to the expression.
In C++11, expressions that:
- have identity and cannot be moved from are called lvalue expressions;
- have identity and can be moved from are called xvalue expressions;
- do not have identity and can be moved from are called prvalue ("pure rvalue") expressions;
- do not have identity and cannot be moved from are not used[6].
可以看到,cppreference的定义分为2个类型以后简单了一些
| 拥有身份(glvalue) | 不拥有身份 | |
|---|---|---|
| 可移动(rvalue) | xvalue | prvalue |
| 不可移动 | lvalue | 不存在 |
值类别被简化为了两个问题,什么是拥有身份?什么是可移动?
根据cppreference引用的文章https://www.stroustrup.com/terminology.pdf
“has identity” – i.e. and address, a pointer, the user can determine whether two copies are identical, etc.
“can be moved from” – i.e. we are allowed to leave to source of a “copy” in some indeterminate, but valid state
根据我的理解:
拥有身份
表达式求值以后,可以确定它指代的是哪一个具体实体。变量名只是最常见的情况,
*p、arr[i]、std::move(x)这样的表达式也可以拥有身份;能取地址可以作为直觉,但不是完整定义。可移动
对象的资源可以移动到别的对象中,移动是指一种使原表达式失效的“拷贝”
也可以根据身份来解释:表达式不拥有身份,或者拥有身份且生命周期即将结束的表达式
4.1 Lvalue-to-rvalue conversion也进行了修改
A glvalue (3.10) of a non-function, non-array type T can be converted to a prvalue.
扩大了左值转换到右值的范围,使得xvalue也可以进行转换
C++17:prvalue语义变化
C++11的值类别即使在简化理解以后,依然有点让人懵逼,看到一个表达式需要先思考身份和可移动,这非常晦涩,需要对值类别进行简化
另外在C++17中,为了解决RVO能干掉move/copy,但是还是要求得有move/copy构造函数的问题,需要对值类别进行修改
因此C++17迎来了值类别的优化
首先看下为什么需要对值类别进行修改
为什么要改:RVO和C++11语法冲突
看如下代码
1 | struct NonMovable |
这里编译是必然失败的,因为RVO虽然能干掉move/copy,但是这只是编译器优化
在C++11的语法上,Make函数需要move/copy构造函数之一,而x的赋值需要move构造函数
我在工作中经常因为这个问题踩坑,由于C++11语法无法绕过,因此我只能绕过RVO改为引用传参将结果返回,整个代码一点也不优雅,auto也用不了
怎么改:prvalue不再先产生临时对象
主要需要解决什么情况下省略拷贝和移动,但是如果加入一个新的值类别来描述,那我只能说真的学不动了
C++17没有引入新的值类别,而是重新定义了prvalue的含义。
在C++11中,prvalue更像一个已经产生出来的临时对象,所以语法上仍然需要考虑从这个临时对象copy/move到目标对象。
到了C++17,prvalue不再先表示一个临时对象,而是表示“如何初始化最终对象”。因此在T x = T{};或者return T{};这种同类型初始化场景里,中间没有临时对象,也没有copy/move这一步。
C++17标准定义
最后来看下C++17标准定义
- A glvalue(generalized lvalue) is an expression whose evaluation determines the identity of an object, bit-field, or function.
- A prvalue(pure rvalue) is an expression whose evaluation initializes an object or a bit-field, or computes the value of an operand of an operator, as specified by the context in which it appears, or an expression that has type cv void.
- An xvalue(eXpiring value) is a glvalue that denotes an object or bit-field whose resources can be reused(usually because it is near the end of its lifetime)。
- An lvalue is a glvalue that is not an xvalue.
- An rvalue is a prvalue or an xvalue.
可以简单地理解成 glvalue 表示能指代具体对象、位域或函数的表达式,prvalue 表示只有在初始化对象或计算操作数值时才发挥作用的值。
然后标准加入了7.4 Temporary materialization conversion
A prvalue of type T can be converted to an xvalue of type T
This conversion initializes a temporary object of type T from the prvalue by evaluating the prvalue with the temporary object as its result object, and produces an xvalue denoting the temporary object
T shall be a complete type
1
2 struct X { int n; };
int k = X().n; // OK, X() prvalue is converted to xvalue
当期待glvalue的地方出现了prvalue,会创建一个临时对象,用prvalue初始化,并产生一个表示该临时对象的xvalue表达式
这里有一个更细节的解释:how-does-guaranteed-copy-elision-work
Guaranteed copy elision redefines the meaning of a prvalue expression. Pre-C++17, prvalues are temporary objects. In C++17, a prvalue expression is merely something which can materialize a temporary, but it isn't a temporary yet.
在保证的复制省略(Guaranteed Copy Elision)下,对prvalue表达式的理解发生了变化。在C++17之前,prvalue(纯右值)被视为临时对象。但在C++17中,prvalue可被理解为一个能够产生临时对象的东西,但其本身并未成为临时对象。
If you use a prvalue to initialize an object of the prvalue's type, then no temporary is materialized. When you do
return T();, this initializes the return value of the function via a prvalue. Since that function returnsT, no temporary is created; the initialization of the prvalue simply directly initilaizes the return value.如果你使用prvalue来初始化prvalue类型的对象,那么不会生成临时对象。当你执行return T()时,这将通过prvalue来初始化函数的返回值。因为该函数返回了类型T,所以不会产生临时对象;直接利用prvalue初始化了返回值。
The thing to understand is that, since the return value is a prvalue, it is not an object yet. It is merely an initializer for an object, just like
T()is.需要理解的是,由于返回值是prvalue,它还未成为一个对象。它仅仅是一个对象的初始化器,就像
T()能作为一个对象的初始化器一样。When you do
T t = Func();, the prvalue of the return value directly initializes the objectt; there is no "create a temporary and copy/move" stage. SinceFunc()'s return value is a prvalue equivalent toT(),tis directly initialized byT(), exactly as if you had doneT t = T().当你执行T t = Func()时,函数返回值的prvalue会直接初始化对象t;这中间并无"创建一个临时对象并复制/移动"的步骤。由于Func()的返回值是与T()等效的prvalue,所以对象t直接被T()初始化,就如同你执行了 T t = T()一样。
If a prvalue is used in any other way, the prvalue will materialize a temporary object, which will be used in that expression (or discarded if there is no expression). So if you did
const T &rt = Func();, the prvalue would materialize a temporary (usingT()as the initializer), whose reference would be stored inrt, along with the usual temporary lifetime extension stuff.如果prvalue以任何其它方式被使用,prvalue将会实体化一个临时对象,这个临时对象将在该表达式中被使用(如果没有表达式,则会被舍弃)。所以,如果你执行了 const T &rt = Func();,prvalue实体化了一个临时对象(以T()作为初始化器),它的引用被存储在rt中,同时还有通常的临时对象生命周期的延长机制。
简而言之:
在C++17以后,prvalue只用来初始化对象。当后续表达式需要一个可指代的对象时,prvalue才会临时物化,例如绑定引用、访问成员、调用成员函数,或者传给引用参数。
因此在同类型初始化场景里,并不是移动prvalue,而是没有“先创建临时对象再移动”这一步。
理解C++17 prvalue变化的例子
以下代码来自于C++14中的设计:
希望不要复制使用NonCopyable,但是允许隐式转换来使用,但是在C++17这种设计失效了
1 | struct NonCopyable { |
- C++14
- 1可以,这是因为prvalue隐式转换成了int值,没有发生拷贝
- 2不行,这是因为prvalue作为临时值发生了拷贝
- C++17,都可以
- 2可以,是因为prvalue仅仅只是一个初始化器,不是对象,不会发生拷贝
C++17的这种变化也体现在RVO中,cppreference的Prvalue semantics ("guaranteed copy elision")章节
Since C++17, a prvalue is not materialized until needed, and then it is constructed directly into the storage of its final destination. This sometimes means that even when the language syntax visually suggests a copy/move (e.g. copy initialization), no copy/move is performed — which means the type need not have an accessible copy/move constructor at all. Examples include:
自C++17起,一个纯右值(prvalue)在需要时才实体化,并且会直接在其最终目的地的存储空间中构建。这有时意味着即使语言语法在视觉上暗示了复制/移动(例如,复制初始化),也不会执行复制/移动,这意味着该类型根本不需要有可访问的复制/移动构造函数。包括以下情况:
Initializing the returned object in a return statement, when the operand is a prvalue of the same class type (ignoring cv-qualification) as the function return type:
当操作数是与函数返回类型(忽视常量性/易变性限定)相同的类类型的纯右值时,在返回语句中初始化返回的对象。
In the initialization of an object, when the initializer expression is a prvalue of the same class type (ignoring cv-qualification) as the variable type:
在对象的初始化时,如果初始化器表达式是与变量类型(忽视常量性/易变性限定)相同的类类型的纯右值。
C++17保证了prvalue在RVO中不再需要拷贝和移动构造函数
虽然在C++17制定之前,所有编译器就都实现了RVO,但是在Non-mandatory copy/move(since C++11) elision章节可以看到
Under the following circumstances, the compilers are permitted, but not required to omit the copy and move(since C++11) construction of class objects even if the copy/move(since C++11) constructor and the destructor have observable side-effects. The objects are constructed directly into the storage where they would otherwise be copied/moved to. This is an optimization: even when it takes place and the copy/move(since C++11) constructor is not called, it still must be present and accessible (as if no optimization happened at all), otherwise the program is ill-formed: ...
在以下场景中,即使类对象的复制/移动构造函数及析构函数存在明显的副作用,编译器也有权利但非义务省略它们(自C++11起)。这些对象会直接在它们原本应被复制/移动至的内存中构造。这是一种优化方式:即使当此优化发生时并未实际调用复制/移动构造函数,这些构造函数仍然必须是存在且可访问的(如同根本没有进行优化一样),否则的话,程序就会格式错误
C++11的要求RVO或者NRVO也一定是需要有复制/移动构造函数的,C++17为prvalue彻底放开了这个限制
1 | NonCopyable Make() { |
在我的实测环境下:
- C++14
- 3可以,4不行
- C++17
- 都可以。这里C++17的语言保证来自同类型prvalue直接初始化最终对象;C++14的结果作为实测表现记录
NRVO:C++17仍然不保证的情况
从RVO的角度来说,返回的是一个纯右值,C++17定义了它的行为:复制消除
但是NRVO返回的是一个具名变量,这种情况下是否需要移动还是取决于编译器自己的实现
gcc和clang在NRVO上的区别
在测试NRVO之前,先给NonCopyable新增一个非const的构造函数
1 | struct NonCopyable { |
然后再进行NRVO的测试代码
1 | NonCopyable Make() { |
- gcc9.0以上
- C++14,3可以,4不行
- C++17,都可以
- clang10.0以上,msvc最新版,icx最新版
- 不管是C++14还是C++17,Make函数都无法编译通过,提示A禁止拷贝
分析
这两种行为都是基本符合标准的,根据上文中提到的Non-mandatory copy/move(since C++11) elision章节,NRVO本身不是强制保证
对gcc来说T(T&)构造函数确实存在的,那么就可以通过NRVO优化,将case3,4和case1,2视为完全一致
而clang等编译器在这个case下没有接受T(T&)这条路径,表现上类似于NRVO相关检查只认T(const T&),因此Make函数会编译失败。这里说的是实测现象,不推断编译器内部实现
解决办法
可以将代码如上文所述直接使用RVO,从而不再对拷贝和移动构造函数有任何要求
1 | NonCopyable Make() { |
从而不管在什么编译器下都和gcc有一致的行为:
- C++14,3可以,4不行
- C++17,都可以
C++17:怎么理解新的值类别
先看身份,再看资源是否可复用
由于在C++11中,使用可移动划分了rvalue这个分类,prvalue也被放在了“可移动”这一行里。
在C++17中,prvalue不再先表示一个已经存在、等待被移动的临时对象,因此“可移动”这个维度不再适合用来理解prvalue。
更适合的理解方式是:先看表达式是否拥有身份。
| 拥有身份 | 不拥有身份 | |
|---|---|---|
| 第一层分类 | glvalue | prvalue |
如果表达式拥有身份,也就是它是glvalue,再看它指代的对象资源是否可以被复用:
| 说明 | |
|---|---|
| xvalue | 拥有身份,并且表示资源可以被复用 |
| lvalue | 拥有身份,但不表示资源可以被复用 |

什么是资源可以被复用
“资源可以被复用”说白了就是:这个表达式指向的对象,已经被标记成“可以被别人拿走内部资源了”。
最典型是:
1 | std::string s = "hello"; |
这里表达式s是lvalue。它有名字、有身份,但普通使用s时,编译器不能假设你愿意让别人拿走它内部的buffer。
而表达式std::move(s)是xvalue。它仍然指向同一个s对象,所以它有身份;但它表达了一个额外意思:这个对象的资源可以被复用,比如t的移动构造函数可以把s内部的buffer接过去,而不是重新分配和拷贝。
所以“资源可以被复用”不是说对象马上死亡,也不是说对象已经无效,而是说:允许后续操作把这个对象内部可转移的东西拿走,只要被拿走后的对象仍然保持合法状态。
比如std::move(s)之后,s仍然是一个合法的std::string,可以析构、可以重新赋值,但它的内容变成什么不应该依赖。
判断的时候可以先用一个粗略规则:
- 普通变量名、
*p、arr[i]这类能稳定指向某个对象的表达式,一般是lvalue。 std::move(x)、static_cast<T&&>(x)、返回T&&的函数调用表达式,明确表示这个对象可以被当作“资源可复用”,所以是xvalue。T{}、返回非引用类型的函数调用这类表达式,先按prvalue理解;只有在需要对象身份的时候,才会临时物化成xvalue。
注意,判断值类别看的是表达式,不是类型。T&& r的类型是右值引用,但表达式r本身有名字,所以仍然是lvalue;只有std::move(r)这样的表达式才是xvalue。
用std::move理解值类别和生命周期
C++为什么纯右值能被延迟析构,将亡值却不行? - ZhiHuReader的回答 - 知乎
1 |
|
这个例子中result&& r = result();,这里希望将临时对象的生命周期绑定到r的生命周期,输出
1 | first line of main |
成功了
而result&& r = std::move(result());,这里同样希望将临时对象的生命周期绑定到r的生命周期,输出
1 | first line of main |
失败了
根据move的实现
1 | template<typename T> |
std::move(result())里有两步,先把result()绑定到引用参数T&& t,再返回static_cast<T&&>(t)产生的xvalue。
这里每一步的值类别是:
| 表达式 | 值类别 | 说明 |
|---|---|---|
result() |
prvalue | 表达式本身是prvalue |
result()临时物化后 |
xvalue | 绑定到T&& t时发生临时物化,得到一个临时对象,t绑定到它 |
t |
lvalue | t的类型是result&&,但是表达式t本身有名字 |
static_cast<result&&>(t) |
xvalue | std::move真正做的事就是把t转成xvalue |
std::move(result()) |
xvalue | 返回result&&的函数调用表达式是xvalue |
r |
lvalue | r的类型是result&&,但是表达式r本身有名字 |
这张表主要想说明两个容易混淆的点:第一,T&& t和result&& r只是类型是右值引用,表达式t和r本身仍然是lvalue;第二,std::move真正做的是static_cast<result&&>(t),这个cast表达式是xvalue,std::move(result())这个函数调用表达式也是xvalue。但它们表示的还是result()绑定到参数时物化出的那个临时对象,并不会产生新的临时对象。下面的标准规则主要解释这个临时对象能活多久。
根据标准的理解
在C++11标准中12.2 Temporary objects 第5小节说到
The second context is when a reference is bound to a temporary. The temporary to which the reference is bound or the temporary that is the complete object of a subobject to which the reference is bound persists for the lifetime of the reference except:
第二种情境是引用绑定到一个临时对象。绑定到引用的临时对象,或者是引用绑定的子对象的完整对象的临时对象,会持续存在直到引用的生命周期结束,除非:
— A temporary bound to a reference member in a constructor’s ctor-initializer (12.6.2) persists until the constructor exits.
- 在构造函数的成员初始化器中,绑定到引用成员的临时对象会持续存在,直到构造函数退出。
— A temporary bound to a reference parameter in a function call (5.2.2) persists until the completion of the full-expression containing the call.
- 在函数调用中,绑定到引用参数的临时对象会持续存在,直到包含该调用的完整表达式完成。
— The lifetime of a temporary bound to the returned value in a function return statement (6.6.3) is not extended; the temporary is destroyed at the end of the full-expression in the return statement.
- 在函数返回语句中,绑定到返回值的临时对象的生命周期不会被延长;临时对象会在返回语句的完整表达式结束时被销毁。
同样在C++17 标准中12.2 Temporary objects 第6小节说到
The third context is when a reference is bound to a temporary(116). The temporary to which the reference is bound or the temporary that is the complete object of a subobject to which the reference is bound persists for the lifetime of the reference except :
第三种情况是引用绑定到一个临时对象。引用绑定的临时对象,或者是引用绑定的子对象的完整对象的临时对象,会持续存在,直到引用的生命周期结束,除非:
— A temporary object bound to a reference parameter in a function call (8.2.2) persists until the completion of the full-expression containing the call.
- 在函数调用中,被绑定到引用参数的临时对象会持续存在,直到包含该调用的完整表达式完成。
— The lifetime of a temporary bound to the returned value in a function return statement (9.6.3) is not extended; the temporary is destroyed at the end of the full-expression in the return statement .
- 在函数返回语句中,绑定到返回值的临时对象的生命周期不会被延长;该临时对象会在返回语句的完整表达式结束时被销毁。
也就是说,临时对象绑定到引用时,一般会持续到这个引用的生命周期结束,这就是通常说的引用延长临时对象生命周期。
但这个规则有例外。这里先摘出和下面例子有关的两条:
- 规则1,在函数调用中,被绑定到引用参数的临时对象会持续存在,直到包含该调用的完整表达式完成。
- 规则2,在函数返回语句中,绑定到返回值的临时对象的生命周期不会被延长;该临时对象会在返回语句的完整表达式结束时被销毁。
move(result())这个表达式中result()作为一个prvalue被传给了T&&这个右值引用类型的变量
回顾前面所提到的,prvalue本身不是临时值。这里result()绑定到std::move的引用参数T&& t时,会临时物化出一个临时对象,并产生一个表示该临时对象的xvalue表达式,t绑定到这个临时对象
根据规则1,这个临时对象的生命周期只会持续到包含这次函数调用的完整表达式完成。std::move返回的是指向同一个对象的xvalue,不会产生新的临时对象,因此result&& r = std::move(result())无法把这个临时对象延长到r的生命周期
因此会产生上述现象
这里没有触发规则2。虽然std::move本身返回的是右值引用,但是它的return语句返回的是static_cast<result&&>(t)这个xvalue表达式,没有在return语句里临时物化出一个新对象再绑定到返回引用。
规则2的例子
规则2可以用直接返回临时值的情况来理解
1 | int&& xvalue() { |
return {};中,prvalue绑定到函数返回的右值引用;但根据规则2,这次引用绑定不会延长临时对象生命周期xvalue()这个返回int&&的函数调用表达式是xvalue,外层r只是绑定到这个返回引用指向的对象,不会产生新的临时对象,因此无法延长生命周期
和上面的std::move对比一下:
| 例子 | return语句 | return语句里是否新物化临时对象并绑定到返回引用 | 主要命中的规则 |
|---|---|---|---|
std::move(result()) |
return static_cast<result&&>(t); |
没有,返回的是表示参数t所绑定对象的xvalue表达式 |
规则1,临时对象先绑定到函数参数 |
xvalue() |
return {}; |
有,{}对应的prvalue在返回语句里绑定到返回的右值引用 |
规则2,返回语句中的引用绑定不延长生命周期 |
简化记忆
cppreference在Reference initialization里也提到,临时对象的生命周期一般不能通过“passing it on”的方式继续延长,也就是第二个引用不会影响它原本的生命周期。
因此可以把上面的规则1和规则2合起来理解:
临时对象可以通过引用延长生命周期,但这个延长不能通过函数参数或引用返回继续传递。绑定到函数参数时,它只活到包含这次函数调用的完整表达式结束;绑定到函数返回的引用时,生命周期也不会被带到调用方。
再猜一个强转的例子
按照这个简化记忆,再看一个容易猜错的情况。如果不用std::move这个函数,而是直接写static_cast:
1 | int main() { |
这个结果更像result&& r = result();,还是更像result&& r = std::move(result());?
答案是:更像第一种,临时对象的生命周期会绑定到r的生命周期,析构会发生在last line of main之后。
可以把这三个case放在一起看:
| 代码 | 中间是否经过函数参数或引用返回 | 外层r绑定到什么 |
临时对象活到哪里 |
|---|---|---|---|
result&& r = result(); |
没有 | result()直接物化出的临时对象 |
跟随r的生命周期,本例中到main结束 |
result&& r = std::move(result()); |
经过函数参数T&& t |
std::move返回的xvalue表达式,表示的还是参数t绑定的临时对象 |
只到包含这次函数调用的完整表达式结束,也就是这条声明语句结束 |
result&& r = static_cast<result&&>(result()); |
没有 | static_cast<result&&>(result())这个xvalue表达式表示的临时对象 |
跟随r的生命周期,本例中到main结束 |
原因是这里没有经过函数参数。result()先临时物化出一个临时对象,static_cast<result&&>(result())是表示这个临时对象的xvalue表达式,外层r直接绑定到这个表达式表示的临时对象。cppreference在Reference initialization的临时对象生命周期延长规则里,也把这种不经过用户自定义转换的static_cast列在可以延长生命周期的表达式范围内。
也就是说,问题不在于std::move(result())是xvalue,而在于std::move(result())里面先把result()绑定到了函数参数T&& t。这个函数参数中转触发了规则1,所以外层r接不住它的生命周期。
如何正确“延长函数返回临时值生命周期”
由于返回临时值的引用变量生命周期一定是会结束的,因此严格来说“延长了返回值的生命周期”是要打引号的
实际上是当返回引用的函数调用表达式是xvalue,并且这个xvalue表达式用来构造一个新的对象时,匹配到了拷贝/移动构造函数,在临时对象还活着的完整表达式内产生了一个新的对象,然后原本的临时对象才析构
如果不想拿到悬垂引用,就需要构造出这样一个新的对象
1 | class result { |
这里每一步的值类别是:
| 表达式 | 值类别 | 说明 |
|---|---|---|
result() |
prvalue | 表达式本身是prvalue |
result()临时物化后 |
xvalue | 绑定到std::move的引用参数T&& t时发生临时物化 |
std::move(result()) |
xvalue | std::move返回result&&,函数调用表达式是xvalue |
result r = std::move(result())中的初始化表达式 |
xvalue | 用这个xvalue调用result(result&&),构造一个新的result对象 |
result(result&&)里的形参 |
lvalue | 形参类型是result&&,但在构造函数体内这个形参表达式有名字,所以是lvalue |
r |
lvalue | r是新构造出来的对象,表达式r本身有名字,所以是lvalue |
关键点是:这里不是把std::move(result())返回的引用生命周期延长到r,而是在这条完整表达式结束前,用它构造了一个新的对象r。
输出
1 | first line of main |
附录:值类别检测和引用绑定速查
值类别的检测
可以通过标准库来检查当前的值类别
1 | if constexpr (std::is_lvalue_reference_v<decltype((e))>) { |
或者
1 | struct lvalue_tag { static constexpr const char* label = "lvalue"; }; |
这里decltype((e))的两层括号是必须的。decltype(v)得到变量声明类型;decltype((v))会按表达式规则保留值类别:普通变量表达式v得到T&,xvalue表达式得到T&&,prvalue表达式得到T。
引用类型的组合
在C++ Templates The Complete Guide (2nd Edition)的附录中有以下代码,觉得有点意思就post在这里
1 | int& lvalue(); //返回int& 的函数表达式是lvalue |
简单来说,非const左值引用只能绑定到lvalue,右值引用可以绑定到prvalue和xvalue。
用完美转发保留值类别
完美转发不是新的值类别规则,而是模板函数里保留值类别的一种常见写法:包装函数接到一个参数以后,希望继续传给下一个函数,并且尽量保持调用方传进来时的值类别。
问题:包装函数会丢失值类别
看一个包装函数:
1 | void target(int&); |
这个wrapper看起来像是把参数原样传给target,但其实不是。
因为v这个表达式有名字,所以v本身一定是lvalue。即使调用方传进来的是右值,target(v)看到的仍然是lvalue。
如果希望wrapper(x)继续按左值传,wrapper(1)继续按右值传,就需要完美转发:
1 | template<class T> |
接下来要解释的就是:T&& v为什么能同时接住左值和右值,以及std::forward<T>(v)为什么能把值类别恢复回来。
背景:引用折叠
C++允许在模板推导中出现“引用的引用”,然后再通过引用折叠规则把它变成普通引用。
规则可以简单记成一句:
只要出现一个
&,最后就是左值引用;只有两个都是&&,最后才是右值引用。
完整规则是:
| 组合 | 折叠结果 |
|---|---|
T& & |
T& |
T& && |
T& |
T&& & |
T& |
T&& && |
T&& |
有了这个规则,才能理解为什么T&&有时会变成左值引用,有时仍然是右值引用。
转发引用:把调用方的值类别记录到T里
在引用类型的组合里说过,普通右值引用不能绑定到lvalue。这里容易混淆的是,wrapper里的T&& v不是一个已经确定的右值引用,而是转发引用,以前也常被叫做万能引用;它会先根据实参推导T,再结合背景:引用折叠里提到的规则得到最终的参数类型。
它成立需要两个条件:
- 形式是
T&& T是当前函数模板正在推导的类型参数
所以它可以同时接左值和右值:
1 | int x = 1; |
这里先只看没有cv限定的int。直觉上可以先想最终参数类型:wrapper(x)要接住lvalue,所以最终参数类型应该是int&;wrapper(1)要接住prvalue,所以最终参数类型应该是int&&。
结合引用折叠来看:
| 调用 | 实参值类别 | T&&折叠后需要的参数类型 |
T的推导过程 |
T&&折叠后的实际参数类型 |
|---|---|---|---|---|
wrapper(x) |
lvalue | int& |
T = int& |
T&& = int& && = int& |
wrapper(1) |
prvalue | int&& |
T = int |
T&& = int&& |
严格来说,标准规则描述的是:对转发引用来说,如果实参是lvalue,模板推导会把T推导成左值引用。这里先从“最终参数类型要能接住实参”这个角度帮助理解。
也就是说,转发引用通过模板推导,把调用方传进来的是左值还是右值这件事记录到了T里。
std::forward:根据T恢复值类别
前面讲std::move的时候可以看到,std::move本质上就是一次static_cast,它会无条件把传入表达式转成xvalue。
std::forward也很像,它的核心也是static_cast。区别在于:std::move是不管原来是什么值类别,都转成xvalue;std::forward<T>则会根据模板参数T决定,这个表达式应该保持lvalue,还是转成xvalue。
可以把std::forward的实现简化成这样:
1 | template<class T> |
两个重载的核心都是static_cast<T&&>(t)。第一种重载接收左值表达式,这是完美转发里最常见的路径;第二种重载接收右值表达式,并用static_assert阻止类似std::forward<int&>(1)这种错误用法:把一个右值按左值引用转发出去。
和std::move对比一下:
| 工具 | 核心动作 | 结果 |
|---|---|---|
std::move(v) |
static_cast<remove_reference_t<T>&&>(v) |
无条件得到xvalue |
std::forward<T>(v) |
static_cast<T&&>(v) |
根据T决定得到lvalue还是xvalue |
放回wrapper里:
| 调用 | 实参值类别 | T推导结果 |
std::forward<T>(v) |
|---|---|---|---|
wrapper(x) |
lvalue | int& |
static_cast<int&>(v),仍然是lvalue |
wrapper(1) |
prvalue | int |
static_cast<int&&>(v),变成xvalue |
所以std::forward<T>(v)保留的不是v自己的值类别。v这个表达式本身永远是lvalue。它真正利用的是模板参数T里保存的信息:调用方当初传进来的是左值,还是右值。
参考资料
https://www.stroustrup.com/terminology.pdf
cppreference引用的资料,2010年的,应该是C++11值类别定义时的一些讨论
-
其中关于左右值的一部分讲的挺有意思,但是有点乱
如何评价 C++17 之后的 Value Categories(值类别)? - d41d8c的回答 - 知乎
大佬回答,非常的简单明了
-
都是对C++ Templates The Complete Guide (2nd Edition)的附录B的翻译,第二篇翻译的更好一些
https://oi-wiki.org/lang/value-category/