C ++中的表达类别

诸如lvaluervalue之类的表达式类别与C ++语言的基本理论概念有关,而与其使用的实际方面无关。 因此,许多甚至经验丰富的程序员对其含义也含糊不清。 在本文中,我将尝试尽可能简单地解释这些术语的含义,并通过实际示例稀释理论。 我将立即进行预订:本文并不假装对表达式类别提供最完整,最严格的描述;有关详细信息,建议直接与C ++语言标准联系。


这篇文章将包含许多英语术语,这是由于其中一些术语很难翻译成俄文,而另一些则以不同的方式在不同的来源中进行了翻译。 因此,我经常会用斜体突出显示英语术语。

一点历史


术语左值右值出现在C中。值得注意的是,混淆最初是在术语中造成的,因为它们是指表达式,而不是值。 从历史上看, 左值是赋值运算符可以保留的 ,而右值是只能为right的值


lvalue = rvalue; 

但是,这样的定义在某种程度上简化和扭曲了本质。 C89标准将左值定义为对象定位符 ,即 具有可识别存储位置的对象。 因此,不符合该定义的所有内容都包含在右值类别中。


Bjarn急救


在C ++中,表达类别的术语发展得非常强劲,尤其是在采用C ++ 11标准之后,该标准引入了右值链接和移动语义的概念。 Straustrup的文章“新”价值术语中有趣地描述了新术语出现的历史。


新的更严格的术语基于2个属性:


  • 身份( identity )的存在-即一些参数,通过该参数可以理解两个表达式是否引用同一实体(例如,内存中的地址);
  • 移动的能力( 可以从中移动 )-支持移动的语义。

表示身份的表达式被概括为术语glvalue广义值 ),漫游表达式被称为rvalue 。 这两个属性的组合确定了3个主要的表达式类别:


有一个身份缺乏身份
无法移动左值--
可以移动前值

实际上,C ++ 17标准引入了复制省略的概念—形式化了编译器可以并且应该避免复制和移动对象的情况。 在这方面, prvalue可能不一定要移动。 详细信息和示例可以在这里找到。 但是,这不会影响对表达类别的一般方案的理解。


在现代的C ++标准中,类别结构以这种方案的形式表示:


图片


让我们大致检查类别的属性,以及每个类别中包含的语言表达。 我马上注意到,下面每个类别的表达式列表不能被认为是完整的;有关更准确和详细的信息,请直接参考C ++标准。


总值


glvalue类别中的表达式具有以下属性:


  • 可以隐式转换为prvalue ;
  • 可以是多态的,也就是说,对于他们来说,静态和动态类型的概念是有意义的;
  • 不能为void类型-这直接取决于具有身份的属性,因为对于void类型的表达式,没有这样的参数可以将它们彼此区分开;
  • 可以具有不完整的类型 ,例如,以向前声明的形式(如果允许特定的表达式)。

右值


rvalue类别中的表达式具有以下属性:


  • 您无法在内存中获取右值地址-这直接是由于缺乏身份属性;
  • 不能在赋值或复合赋值语句的左侧;
  • 可用于初始化常量左值链接或右值链接,而对象的生存期延长至链接的生存期;
  • 如果在调用具有2个重载版本的函数时用作参数:一个接受常数的左值引用,另一个接受右值引用,则选择接受右值引用的版本。 此属性用于实现move语义

 class A { public: A() = default; A(const A&) { std::cout << "A::A(const A&)\n"; } A(A&&) { std::cout << "A::A(A&&)\n"; } }; ......... A a; A b(a); //  A(const A&) A c(std::move(a)); //  A(A&&) 

从技术上讲,A &&是右值 ,可用于初始化常量左值引用和右值引用。 但是,由于有了此属性,所以没有歧义;可以接受接受右值引用的构造函数选项。

左值


特性:


  • 所有glvalue属性(请参见上文);
  • 您可以获取地址(使用内置的一元运算符& );
  • 可修改的左值可以在赋值运算符或复合赋值运算符的左侧;
  • 可用于初始化对左值的引用(常数和非常数)。

以下表达式属于左值类别:


  • 任何类型的变量,函数或类字段的名称。 即使变量是右值引用,表达式中此变量的名称也是左值

 void func() {} ......... auto* func_ptr = &func; // :     auto& func_ref = func; // :     int&& rrn = int(123); auto* pn = &rrn; // :    auto& rn = rrn; // :  lvalue- 

  • 调用返回左值引用的函数或重载运算符,或转换为左值引用类型的表达式;
  • 内置赋值运算符,复合赋值运算符( =+=/=等),内置预增和预增( ++a ,-- --b ),内置指针解除引用运算符( *p );
  • 当操作数之一是左值数组时,按索引( a[n]n[a] )访问的内置运算符;
  • 调用一个函数或重载的语句以返回对该函数的右值引用;
  • 字符串文字,例如"Hello, world!"

字符串文字与C ++中所有其他文字的不同之处仅在于它是一个左值 (尽管不可变)。 例如,您可以获取其地址:

 auto* p = &”Hello, world!”; //   ,    

前值


特性:


  • 所有右值属性(见上文);
  • 不能是多态的:静态和动态表达式类型始终重合;
  • 不能是不完整的类型(除了void类型,这将在下面讨论);
  • 不能具有抽象类型,也不能是抽象类型的元素数组。

以下表达式属于prvalue类别:


  • 文字(字符串除外),例如42truenullptr
  • 返回非引用( str.substr(1, 2)str1 + str2it++ )或转换表达式为非引用类型(例如static_cast<double>(x)std::string{}(int)42 );
  • 内置后递增和后递减( a++b-- ),内置数学运算( a + ba % ba & ba << b等),内置逻辑运算( a && ba || b !a等),比较操作( a < ba == ba >= b等),获取地址的内置操作( &a );
  • 指针;
  • 上市项目;
  • 非典型模板参数(如果不是类);
  • lambda表达式,例如[](int x){ return x * x; } [](int x){ return x * x; }


特性:


  • 所有右值属性(见上文);
  • 所有glvalue属性(请参见上文)。

xvalue表达式的示例:


  • 调用返回右值引用的函数或内置运算符,例如std :: move(x)

实际上,对于调用std :: move()的结果,您无法在内存中获取地址或初始化指向该地址的链接,但与此同时,此表达式可以是多态的:

 struct XA { virtual void f() { std::cout << "XA::f()\n"; } }; struct XB : public XA { virtual void f() { std::cout << "XB::f()\n"; } }; XA&& xa = XB(); auto* p = &std::move(xa); //  auto& r = std::move(xa); //  std::move(xa).f(); //  “XB::f()” 

  • 当操作数之一是右值数组时,按索引( a[n]n[a] )访问的内置运算符。

一些特殊情况


逗号运算符


对于内置的逗号运算符,表达式类别始终与第二个操作数的表达式类别匹配。


 int n = 0; auto* pn = &(1, n); // lvalue auto& rn = (1, n); // lvalue 1, n = 2; // lvalue auto* pt = &(1, int(123)); // , rvalue auto& rt = (1, int(123)); // , rvalue 

无效表达


调用返回void的函数,将类型转换表达式返回void并引发异常被视为prvalue表达式,但是它们不能用于初始化引用或用作函数的参数。


三元比较运算符


定义表达式的类别a ? b : c a ? b : c情况并非无关紧要,这完全取决于第二和第三自变量的类别( bc ):


  • 如果bc的类型为void ,则整个表达式的类别和类型对应于另一个参数的类别和类型。 如果两个参数均为void类型,则结果为pr类型的prvalue ;
  • 如果bc是相同类型的glvalue ,则结果是相同类型的glvalue
  • 在其他情况下,结果是prvalue。

对于三元运算符,定义了许多规则,根据这些规则可以将隐式转换应用于参数b和c,但这在本文范围之外;如果您感兴趣,我建议参考标准的条件运算符[expr.cond]部分。


 int n = 1; int v = (1 > 2) ? throw 1 : n; // lvalue, .. throw   void,    n ((1 < 2) ? n : v) = 2; //  lvalue,  ,   ((1 < 2) ? n : int(123)) = 2; //   , ..    prvalue 

对类和结构的字段和方法的引用


对于形式为amp->m表达式(在这里我们讨论的是内置运算符-> ),适用以下规则:


  • 如果m是枚举元素或非静态类方法,则将整个表达式视为prvalue (尽管无法使用这样的表达式初始化链接);
  • 如果a是一个右值,m是一个非引用类型的非静态字段,则整个表达式属于xvalue类别;
  • 否则为左值

对于指向类成员的指针( a.*mpp->*mp ),规则相似:


  • 如果mp是指向类方法的指针,则将整个表达式视为prvalue
  • 如果a是一个右值 ,而mp是一个指向数据字段的指针,则整个表达式都指向xvalue
  • 否则为左值

位域


位字段是用于低级编程的便捷工具,但是,它们的实现在某种程度上超出了表达类别的一般结构。 例如,对位字段的调用似乎是一个左值因为它可能出现在赋值运算符的左侧。 同时,通过位字段获取地址或初始化非恒定链接将无效。 您可以初始化对位字段的常量引用,但是将创建对象的临时副本:


位域[class.bit]
如果用于类型const T&的引用的初始化程序是引用位字段的左值,则该引用将绑定到临时的初始化对象,以保留位字段的值; 参考没有直接绑定到位域。

 struct BF { int f:3; }; BF b; bf = 1; // OK auto* pb = &b.f; //  auto& rb = bf; //  

而不是结论


正如我在引言中提到的,上面的描述并不要求是完整的,而只是给出了表达类别的一般概念。 该视图将使您对标准的段落和编译器错误消息有更好的理解。

Source: https://habr.com/ru/post/zh-CN441742/


All Articles