Cpp 值的种类划分

发布时间 2023-11-24 11:00:53作者: Koshkaaa
  • 本博文会介绍移动语义的形式术语和规则。并且会正式的介绍值的类别,如 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 intc 仍然是 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 的值类别如图所示。

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 是表示不再需要其资源/值的 (长期存在的) 对象。

图 8.2 C++17 新加的值类别

通过值传递 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&&);

原则上,这些重载的声明没问题。但是,按值调用和按引用调用之间没有特定的优先级。如果函数声明以值作为参数(它可以接受任何值类别的任何参数),那么任何匹配声明以引用作为参数都会造成歧义。

因此,只能通过值或引用(使用认为有用的尽可能多的引用重载)接受参数,但永远不要两者都接受。

4lvalue 变成 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

5rvalue 变成 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 既可以检查所传递名称的声明类型,也可以检查所传递表达式的类型和值类别。