Cpp 值的种类划分
-
本博文会介绍移动语义的形式术语和规则。并且会正式的介绍值的类别,如 lvalue、rvalue、prvalue和 xvalue,并讨论了在绑定对象引用时的作用。也会讨论移动语义不会自动传递的细节,以及decltype 在表达式调用时的微妙行为。
-
作为《Cpp Move Semantics》书中最复杂的一章。可能会看到一些事实和特征,可能很难相信或理解。当您再次阅读值类别、对象的绑定引用和 decltype 时,可以再回到这里看看。
1. 值的种类
要编译表达式或语句,所涉及的类型是否合适并不重要。例如,如果在赋值符的左边使用了 int 型字面值,则不能将 int 型赋值给 int 型字面值:
int i = 42;
i = 77; // OK
77 = i; // Obvious ERROR
因此,C++ 程序中的每个表达式都有值类别。除了类型之外,值类别对于决定表达式可以做什么也很重要。
然而,值类别在 C++ 中随着时间的推移而改变。
1.1 值类型的历史
从历史上看 (引用 Kernighan&Ritchie C, K&R C),最初只有 lvalue 和 rvalue:
- lvalue 可以出现在赋值的左边
- rvalue 只能出现在赋值的右侧
根据这个定义,当使用 int 对象/变量时,使用的是 lvalue,但当使用 int 字面值时,使用的是 rvalue:
int x; // x i s an lva lue when used in an expression
x = 42; // OK, because x i s an lva lue and the type matches
42 = x ; // ERROR: 42 i s an rvalue and can be only on the right−hand s ide o f an assignment
然而,这些类别不仅重要,并且通常用于指定表达式是否以及在何处可以使用。例如:
int x ; // x i s an lva lue when used in an expression
int ∗ p1 = &x ; // OK: & i s f in e for lva lues ( object has a sp e c i f i ed locat ion )
int ∗ p2 = &42; // ERROR: & i s not allowed for rvalues ( object has no sp e c i f i ed location)
然而,在 ANSI-C 中,事情变得更加复杂。,因为声明为 const int 的 x 不能放在赋值函数的左边,但仍然可以在其他只能使用左值的地方使用:
const int c = 42; // Is c an lva lue or rvalue?
c = 42; // now an Error (so that c should no longer be an lvalue)
const int *pl = &c; // still OK (so that c should be an lvalue)
C 语言中,声明为 const int 的 c 仍然是 lvalue,因为对于特定类型的 const 对象,仍然可以调用大多数 lvalue 操作。唯一不能做的就是在赋值函数的左边有一个 const 对象。
因此,在 ANSI-C 中,l 的含义变成了定位。lvalue 现在是程序中具有指定位置的对象 (例如,以便您可以获取地址)。以同样的方式,rvalue 现在只是一个可读的值。
C++98 采用了这些值类别的定义。然而,随着移动语义的引入,问题出现了: 用 std::move() 标记的对象应该有哪些值类别,用 std::move() 标记的类的对象应该遵循以下规则:
std::string s;
...
std::move(s) = "hello"; // OK (behaves like an lva lue )
auto ps = &std::move(s); // ERROR (behaves like an rvalue )
但是,请注意基本数据类型 (FDT) 的行为
int i;
...
std::move(i) = 42; // ERROR
auto pi = &std::move(i); // ERROR
除了基本数据类型之外,标记为 std::move() 的对象仍应该像 lvalue 一样,允许修改它的值。
另一方面,也存在一些限制,比如不能获取该地址。
因此,引入了一个新的类别 xvalue(“eXpire value”) 来为显式标记的对象指定规则,因为这里不再需要这个值 (主要是用 std::move() 标记的对象)。
大多数 C++11 前的 rvalue 的规则也适用于 xvalue。因此,以前的 rvalue 变成了一个复合值类别,现在表示新的主值类别 prvalue(对于以前的所有 rvalue) 和 xvalue。关于提出这些改变的论文,请参阅 http://wg21.link/n3055。
1.2 C++11 的值类别
C++11 的值类别如图所示。
有以下主要类别:
- lvalue ("定位值")
- prvalue("纯可读值")
- xvalue("过期值")
综合类别为
- gvalue ("广义 lvalue") 作为 "左 lvalue" 或 "xvalue" 的常用术语
- rvalue 作为 “xvalue” 和 “prvalue” 的常用术语
基本表达式的值分类
lvalue 的例子有:
- 仅为变量、函数或数据成员 (除右值的普通值成员外) 的名称的表达式
- 只是字符串字面量的表达式 (例如,"hello")
- 如果函数声明返回左值引用,则返回函数的值 (返回类型 type &)
- 任何对函数的引用,即使标记为 std::move()(参见下面)
- 内置的一元操作符 * 的结果 (即,对原始指针进行解引用所产生的结果)
prvalue 的例子有:
- 由非字符串字面量的内置字面量组成的表达式 (例如,42、true 或 nullptr)
- 如果函数声明为按值返回,则按类型返回值 (返回类型为 Type)。
- 内置的一元操作符 & 的结果 (即,获取表达式的地址所产生的结果)
- Lambda 表达式
xvalues 的示例如下:
- 用 std::move() 标记对象的结果
- 对对象类型 (不是函数类型) 的 rvalue 引用的强制转换
- 函数声明返回 rvalue 引用 (返回类型 type &&)
- 右值的非静态值成员 (见下面)
例如:
class X
{};
X v;
const X c;
f(v); // passes a modifiable lva lue
f(c); // passes a non−modifiable lva lue
f(X()); // passes a prvalue (old syntax o f creat ing a temporary)
f(X{}); // passes a prvalue (new syntax o f creat ing a temporary)
f(std::move(v)); // passes an xvalue
说些经验法则:
- 所有用作表达式的名称都是 lvalue。
- 所有用作表达式的字符串字面值都是 lvalue。
- 所有非字符串字面值 (4.2、true 或 nullptr) 都是 prvalue。
- 所有没有名称的临时对象 (特别是回的对象) 都是 prvalues。
- 所有标记为 std::move() 的对象,及其值成员都是 xvalues。
严格来说,glvalues、prvalues 和 xvalues 是表达式的术语,而不是值的术语 (这意味着这些术语用词不当)。例如,变量本身不是 lvalue,只有表示该变量为 lvalue 的表达式:
int x = 3 ; // here , x i s a variable , not an lva lue
int y = x ; // here , x i s an lva lue
第一个语句中,3 是一个初始化变量 x 的 prvalue(不是 lvalue)。第二个语句中,x 是一个 lvalue(它的计算值指定了一个包含值 3 的对象)。lvalue x 用作 rvalue,它初始化变量 y。
1.3 C++17 新加的值类别
C++17 具有相同的值类别,图 8.2 中描述了值类别的语义。
现在解释值类别有两种主要的表达方式:
-
glvalues: 用于长生命周期对象或函数位置的表达式
-
prvalues: 用于短生命周期对象的初始化表达式
然后,xvalue 是表示不再需要其资源/值的 (长期存在的) 对象。
通过值传递 prvalues
有了这个更改,即使没有定义有效的副本和有效的移动构造函数,现在也可以将 prvalue 作为未命名的初始值按值传递:
class C {
public :
C( . . . ) ;
C( const C&) = de lete ; // th is c l a s s i s neither copyable . . .
C(C&&) = de lete ; // . . . nor movable
} ;
C createC ( ) {
return C{ . . . } ; // Always creates a conceptual temporary pr ior to C++17.
} // In C++17, no temporary object i s created at th is point .
void takeC (C v al ) {
...
}
auto n = createC(); // OK s ince C++17 ( error pr ior to C++17)
takeC(createC()); // OK s ince C++17 ( error pr ior to C++17)
C++17 之前,如果没有复制或移动支持,传递 prvalue(例如:createC() 的创建和初始化返回值) 是不可能的。但是,从 C++17 开始,只要是不需要有地址的对象,就可以按值传递 prvalues。
具象化
C++17 随后引入了一个新术语,称为具象化 (未命名的临时对象),此时 prvalue 变成了临时对象。因此,临时物化转换是 prvalue 到 xvalue 转换 (通常是隐式的)。
需要 glvalue(左值或 xvalue) 的地方使用 prvalue,就会创建临时对象,并使用该 prvalue 初始化 (记住,prvalue 主要是“初始化值”),并且该 prvalue 会指定临时对象的 xvalue。因此,在上面的例子中:
void f ( const X& p ) ; // accepts an expression o f any value category but expects a glvalue
f (X{}) ; // creates a temporary prvalue and passes i t material ized as an xvalue
因为本例中的 f() 有一个引用形参,所以需要一个 glvalue 参数。然而,表达式 X{} 是 prvalue。
因此,“临时具象化”的规则开始起作用,表达式 X{} 被“转换”为 xvalue,该 xvalue 指定使用默认构造函数初始化的临时对象。
请注意,具象化并不意味着我们创建新的/不同的对象。左值引用 p 仍然绑定到 xvalue 和prvalue,尽管后者现在会涉及到向 xvalue 的转换。
2 值类别的特殊规则
对于影响移动语义的函数和成员的值类型,有特殊的规则。
2.1 函数的值类型
C++ 标准中的特殊规则是,所有引用函数的表达式都是 lvalue。
例如:
void f(int) {}
void (&fref1)(int) = f; // fref1 is an lvalue
void (&&fref2)(int) = f; // fref2 is also an lvalue
auto& ar = std::move(f); // OK: ar is lvalue of type void(&)(int)
如果使用对象的数据成员(例如,使用std::pair<>的第一个和第二个成员时),将使用特殊规则。
2.2 数据成员的值类型
如果使用对象的数据成员(例如,使用std::pair<>的第一个和第二个成员时),将使用特殊规则。
通常,数据成员的值类型如下:
- lvalue的数据成员是lvalue。
- rvalue的引用和静态数据成员是lvalue。
- rvalue的普通数据成员是xvalue。
这些规则反映了引用或静态成员实际上不是对象的一部分。如果不再需要对象的值,这也适用于对象的普通数据成员。但是,引用或静态的成员的值可能被其他对象所使用。
例如:
std::pair<std::string, std::string&> foo(); // note: member second is reference
std::vector<std::string> coll;
...
coll.push_back(foo().first); // moves because first is an xvalue here
coll.push_back(foo().second); // copies because second is an lvalue here
需要使用 \(std::move()\) 来移动第二个成员:
coll.push_back(std::move(foo().second)); // moves
如果有 lvalue(一个有名字的对象),就有两种使用\(std::move()\)的方式来移动成员:
- std::move(obj).member
- std::move(obj.member)
\(std::move()\) 的意思是“不再需要这个值”,所以不再需要对象的值,应该标记 \(obj\)。如果不再需要成员的值,应该标记 \(member\)。然而,实际情况会比较复杂
\(std::move()\) 用于普通数据成员
如果成员既不是静态也不是引用, \(std::move()\) 能将成员转换为 xvalue,以便能够使用移动语义。
考虑声明了以下内容
std::vector<std::string> coll;
std::pair<std::string, std::string> sp;
以下代码先将成员移动,然后再将成员移动到 coll 中:
sp = ... ;
coll.push_back(std::move(sp.first)); // move string first into coll
coll.push_back(std::move(sp.second)); // move string second into coll
但是,下面的代码具有相同的效果:
sp = ... ;
coll.push_back(std::move(sp).first); // move string first into coll
coll.push_back(std::move(sp).second); // move string second into coll
看起来有点奇怪,std::move() 标记对象之后仍然使用 obj。在本例中,知道对象的哪个部分可以移动,所以可以使用未移动的部分。因此,当必须实现移动构造函数时,我更喜欢用 std::move()标记成员。
**\(std::move()\) 用于引用或静态成员 **
如果成员是引用或静态的,则使用不同的规则:rvalue 的引用或静态成员是 lvalue。同样,这条规则反映了,成员的值并不是对象的一部分。“不再需要对象的值”并不意味着“不再需要不属于对象的值 (成员的值)”。
因此,如果有引用或静态成员,那么如何使用 std::move() 是有区别的
- 对对象使用 std::move() 不起作用:
struct S {
static std::string statString; // static member
std::string& refString; // reference member
};
S obj;
...
coll.push_back(std::move(obj).statString); // copies statString
coll.push_back(std::move(obj).refString); // copies refString
- 对成员使用 std::move() 具有的效果
struct S {
static std::string statString;
std::string& refString;
};
S obj;
...
coll.push_back(std::move(obj.statString); // moves statString
coll.push_back(std::move(obj.refString); // moves refString
这样的举措是否有用是另一个问题。窃取静态成员或引用成员的值意味着修改所使用对象外部的值,这还能说得通,但也可能是意外和危险的。通常,类型应该更好地保护对这些成员的访问。
泛型代码中,可能不知道成员是静态的还是引用的。因此,使用 std::move() 来标记对象是不那么危险的,就是看起来奇怪:
coll.push_back(std::move(obj).mem1); // move value, copy reference/static
coll.push_back(std::move(obj).mem2); // move value, copy reference/static
稍后将介绍的 std::forward<>() 可以用来完美地转发对象的成员。参见 basics/members.cpp
获取完整的示例。
3 绑定引用时值类别的影响
将引用绑定到对象时,值类型起着重要的作用。例如,在 C++98/C++03 中,定义了可以将 rvalue(没有名称的临时对象或标有 std::move() 的对象) 赋值或传递给 const lvalue 引用,但不能传递给非 const lvalue 引用:
std::string createString(); // forward declaration
const std::string& r1{createString()}; // OK
std::string& r2{createString()}; // ERROR
这里编译器打印的错误消息是“不能将非 const lvalue 引用绑定到 rvalue”。
调用 foo2() 时也会得到这个错误消息:
void foo1(const std::string&); // forward declaration
void foo2(std::string&); // forward declaration
foo1(std::string{"hello"}); // OK
foo2(std::string{"hello"}); // ERROR
3.1 解析rvalue引用的重载
让我们看看传递对象给引用时的规则。
假设类 X 中有一个非 const 变量 v 和一个 const 变量 c:
class X {
...
};
X v{ ... };
const X c{ ... };
如果提供了函数 f() 的所有引用重载,则绑定引用的规则表会列出了传递参数的绑定引用的规则:
void f(const X&); // read-only access
void f(X&); // OUT parameter (usually long-living object)
void f(X&&); // can steal value (object usually about to die)
void f(const X&&); // no clear semantic meaning
数字列出了重载解析的优先级,以便了解在提供多个重载时调用了哪个函数。数字越小,优先级越高(优先级1表示最优先)。
注意,只能将rvalue(prvalues,如没有名称的临时对象)或xvalues(用std::move()标记的对象)传递给rvalue引用。
通常可以忽略表的最后一列,因为\textit{const} rvalue引用在语义上没有多大意义,这意味着我们有以下规则:
如果向函数传递 rvalue(临时对象或标记为 std::move() 的对象),而移动语义没有特定的实现(通过接受 rvalue 引用声明),则使用通常的复制语义,const& 接受实参。
请注意,在介绍通用引用/转发引用时会扩展此表。
有时可以将 lvalue 传递给 rvalue 引用 (当使用模板形参时)。请注意,并非每个带有 && 的声明都遵循相同的规则。这里的规则适用于使用 && 声明类型 (或类型别名) 的情况。
3.2 通过引用和值进行重载
可以通过引用和值来声明函数:
void f(X); // call-by-value
void f(const X&); // call-by-reference
void f(X&);
void f(X&&);
void f(const X&&);
原则上,这些重载的声明没问题。但是,按值调用和按引用调用之间没有特定的优先级。如果函数声明以值作为参数(它可以接受任何值类别的任何参数),那么任何匹配声明以引用作为参数都会造成歧义。
因此,只能通过值或引用(使用认为有用的尽可能多的引用重载)接受参数,但永远不要两者都接受。
4 当 lvalue 变成 rvalue
当使用具体类型的rvalue引用形参声明函数时,只能将这些形参绑定到rvalue。例如:
void rvFunc(std::string&&); // forward declaration
std::string s{ ... };
rvFunc(s); // ERROR: passing an lvalue to an rvalue reference
rvFunc(std::move(s)); // OK, passing an xvalue
但请注意,有时传递lvalue是可行的。例如:
void rvFunc(std::string&&); // forward declaration
rvFunc("hello"); // OK, although "hello" is an lvalue
记住,字符串文字作为表达式使用时是lvalue。因此,不能传递给rvalue引用。但是,这里涉及到一个隐藏的操作,因为实参的类型(6个常量字符的数组)与形参的类型不匹配。隐式类型转换由string构造函数执行,创建了一个没有名称的临时对象。
因此,真正的使用方式如下:
void rvFunc(std::string&&); // forward declaration
rvFunc(std::string{"hello"}); // OK, "hello" converted to a string is a prvalue
5 当 rvalue 变成 lvalue
现在让了解一下将形参声明为 rvalue 引用的函数的实现:
void rvFunc(std::string&& str) {
...
}
只能传递rvalue:
std::string s{ ... };
rvFunc(s); // ERROR: passing an lvalue to an rvalue reference
rvFunc(std::move(s)); // OK, passing an xvalue
rvFunc(std::string{"hello"}); // OK, passing a prvalue
然而,当在函数内部使用 str 形参时,处理的是有名称的对象。这意味着使用 str 作为 lvalue。
不能直接递归地调用自己的函数:
void rvFunc(std::string&& str) {
rvFunc(str); // ERROR: passing an lvalue to an rvalue reference
}
必须再次用 std::move() 标记 str:
void rvFunc(std::string&& str) {
rvFunc(std::move(str)); // OK, passing an xvalue
}
这是没有传递移动语义规则的规范。这是特性,而不是bug。如果传递了移动语义,就不能使用两次传递了移动语义的对象,因为第一次使用后,就会失去它的值。或者,需要临时禁用移动语义的特性。
如果将rvalue引用参数绑定到rvalue(prvalue或xvalue),该对象将作为lvalue,必须再次将其转换为rvalue,以便传递给rvalue引用。
现在,请记住\textit{std::move()}只不过是对rvalue引用的\textit{static_cast}。也就是说,可以在递归调用中编写如下程序:
void rvFunc(std::string&& str) {
rvFunc(static_cast<std::string&&>(str)); // OK, passing an xvalue
}
将对象\textit{str}转换为string类型。通过强制转换,改变值的类型。根据规则,通过对rvalue引用的强制转换,lvalue变成了xvalue,因此允许将对象传递给rvalue引用。
这并不是什么新鲜事:即使在C++11之前,声明为lvalue引用的形参在使用时也遵循lvalue规则。关键是声明中的引用指定了可以传递给函数的内容。对于函数内部的行为,与引用无关。
困惑吗?这就是在C++标准中定义移动语义和值类型的规则。是否有足够的了解,其实并不重要,编译器明白这些规则其实就足够了。
这里需要了解的是移动语义没有传递。如果传递一个带有移动语义的对象,必须再次用\textit{std::move()}标记,将其语义转发给另一个函数。
6 使用 decltype 检查值类别
与移动语义一起,C++11引入了一个新的关键字decltype。这个关键字的主要目标是获得声明对象的确切类型,也可以用于确定表达式的值类型。
6.1 使用decltype检查名称的类型
在接受rvalue引用形参的函数中,可以使用decltype查询并使用形参的确切类型。只需将参数的名称传递给decltype。例如:
void rvFunc(std::string&& str)
{
std::cout << std::is_same<decltype(str), std::string>::value; // false
std::cout << std::is_same<decltype(str), std::string&>::value; // false
std::cout << std::is_same<decltype(str), std::string&&>::value; // true
std::cout << std::is_reference<decltype(str)>::value; // true
std::cout << std::is_lvalue_reference<decltype(str)>::value; // false
std::cout << std::is_rvalue_reference<decltype(str)>::value; // true
}
decltype(str)表达式总是表示\textit{str}的类型,即std::string&&。在表达式中任何需要该类型的地方都可以使用该类型。类型特征(类型函数如std::is_same<>)会帮助我们处理这些类型。
例如,要声明传递的形参类型不是引用的新对象,可以声明:
void rvFunc(std::string&& str)
{
std::remove_reference<decltype(str)>::type tmp;
...
}
\(tmp\) 在这个函数中是std::string类型(也可以显式地声明,如果使它成为T类型对象的泛型函数,代码仍可以工作)。
6.2 使用decltype检查值类型
目前为止,只向decltype传递了名称来查询类型。但是,也可以将表达式(不仅仅是名称)传递给decltype,会根据以下约定生成值类型:
- 对于prvalue,产生值类型:type
- 对于lvalue,将其类型作为lvalue引用:type&
- 对于xvalue,将其类型作为rvalue引用:type&&
例如:
void rvFunc(std::string&& str)
{
decltype(str + str) // yields std::string because s+s is a prvalue
decltype(str[0]) // yields char& because the index operator yields an lvalue
...
}
这意味着,如果只是传递一个放在圆括号内的名称(这是一个表达式,而不再只是名称),decltype将生成其类型。行为如下:
void rvFunc(std::string&& str)
{
std::cout << std::is_same<decltype((str)), std::string>::value; // false
std::cout << std::is_same<decltype((str)), std::string&>::value; // true
std::cout << std::is_same<decltype((str)), std::string&&>::value; // false
std::cout << std::is_reference<decltype((str))>::value; // true
std::cout << std::is_lvalue_reference<decltype((str))>::value; // true
std::cout << std::is_rvalue_reference<decltype((str))>::value; // false
}
将此函数与不使用括号的前一个函数实现进行比较。这里,decltype(str)的结果是std::string&,因为str是lvalue的std::string类型。
对于decltype,当传递的名称周围加上圆括号时,会产生不同的结果,这在稍后讨论decltype(auto)时会很重要。
检查值类型内部代码
- !std::is_reference_v<decltype((expr))> 检查expr是否为prvalue。
- std::is_lvalue_reference_v<decltype((expr))> 检查expr是否为lvalue。
- std::is_rvalue_reference_v<decltype((expr))> 检查expr是否为xvalue。
- !std::is_lvalue_reference_v<decltype((expr))> 检查expr是否为rvalue。
请再次注意这里使用的括号,以确保即使只传递名称\textit{expr},也使用decltype的值-类别检查形式。
C++20之前,必须使用::value来替代后缀_v。
7 总结
-
C++ 程序中的任何表达式都只属于以下主要值类别中的一种:
-
lvalue (用于命名对象或字符串字面量)
-
prvalue (用于未命名的临时对象)- xvalue (对于标记为 std::move() 的对象)
-
-
C++ 中的调用或操作是否有效取决于类型和值类别。
-
类型的 rvalue 引用只能绑定到 rvalue(prvalues 或 xvalues)。
-
隐式操作可能更改传递参数的值类别。
-
将 rvalue 传递给 rvalue 引用,可以将其绑定到 lvalue。
-
移动语义不可传递。
-
函数和对函数的引用总是 lvalue。
-
对于 rvalue(临时对象或标记为 std::move() 的对象),普通值成员具有移动语义,而引用或静态成员没有。
-
decltype 既可以检查所传递名称的声明类型,也可以检查所传递表达式的类型和值类别。