C++ 左值和右值

 

C++ 左值和右值

自C++11开始,对值类别进行了详细分类,在原有左值的基础上增加了纯右值和消亡值,并对以上三种类型通过是否具名(identity)和可移动(moveable),又增加了glvalue和rvalue两种组合类型,在后面的内容中,会对这几种类型进行详细讲解。

1 表达式与值类别

  • C/C++代码是由标识符、表达式和语句以及一些必要的符号(大括号等)组成。表达式由按照语言规则排列的运算符,常量和变量组成。一个表达式可以包含一个或多个操作数,零个或多个运算符来计算值。每个表达式都会产生一些值,该值将在赋值运算符的帮助下分配给变量。

  • 在C/C++中,表达式有很多种,我们常见的有前后缀表达式、条件运算符表达式等。字面值(literal)和变量(variable)是最简单的表达式,函数的返回值也被认为是表达式。表达式是可求值的,对表达式求值可得到一个结果,这个结果有两个属性:

    • 类型:比如int string或者我们自定义的类。类型确定了表达式可以进行哪些操作。

    • 值类别

  • 表达式是可求值的,而值类别就是求值结果的属性之一。

  • 自C++11开始,表达式的值分为以下五种

    1. 左值(lvalue, left value)
    2. 将亡值(xvalue, expiring value)
    3. 纯右值(pvalue, pure ravlue)
    4. 泛左值(glvalue, generalized lvalue)
    5. 右值(rvalue, right value)

    这五种类别的分类基于表达式的两个特征:具名(identity):可以确定表达式是否与另一表达式指代同一实体,例如通过比较它们所标识的对象或函数的(直接或间接获得的)地址可被移动:移动构造函数、移动赋值运算符或实现了移动语义的其他函数重载能够绑定于这个表达式。

    基于上述两个特征,对五种表达式值类别进行重新定义:

    • lvalue:左值,具名且不可被移动
    • xvaue:将亡值,具名且可被移动
    • prvalue:纯右值,不具名且可被移动
    • glvalue:泛左值,具名,lvalue和xvalue都属于glvalue
    • rvalue:右值,可被移动的表达式,prvalue和xvalue都属于rvalue

    图示如下:

2 值类型详解

  • 左值(lvalue, left value) :在内存有确定存储地址、有变量名,表达式结束依然存在的值,简单来说左值就是非临时对象。

    左值具有以下特征:

    • 可通过取地址运算符获取其地址

    • 可修改的左值可用作内建赋值和内建符合赋值运算符的左操作数

    • 可以用来初始化左值引用

    表达式值类型为左值大概有以下几种情况:

    • 变量名、函数名以及数据成员名

    • 返回左值引用的函数调用

    • 由赋值运算符或复合赋值运算符连接的表达式,如(a=b, a-=b等)

    • 解引用表达式*ptr

    • 前置自增和自减表达式(++a, ++b)

    • 成员访问(点)运算符的结果

    • 由指针访问成员( -> )运算符的结果

    • 下标运算符的结果([])

    • 字符串字面值(“abc”)

    例子如下:

      int a = 1; // a是左值
      T& f();
      f();//左值
      ++a;//左值
      --a;//左值
      int b = a;//a和b都是左值
      struct S* ptr = &obj; // ptr为左值
      arr[1] = 2; // 左值
      int *p = &a; // p为左值
      *p = 10; // *p为左值
      class MyClass{};
      MyClass c; // c为左值
      "abc"
    
  • 纯右值:就是在内存没有确定存储地址、没有变量名,表达式结束就会销毁的值,简单来说右值就是临时对象。字面值或者函数返回的非引用都是纯右值。

    纯右值特征:

    • 等同于C++11之前的右值

    • 不会是多态

    • 不会是抽象类型或数组

    • 不会是不完全类型

    以下表达式的值都是纯右值:

    • 字面值(字符串字面值除外),例如1,’a’, true等

    • 返回值为非引用的函数调用或操作符重载,例如:str.substr(1, 2), str1 + str2, or it++

    • 后置自增和自减表达式(a++, a–)

    • 算术表达式

    • 逻辑表达式

    • 比较表达式

    • 取地址表达式

    • lambda表达式

    下述例子都是常见的纯右值:

      nullptr;
      true;
      1;
      int fun();
      fun();
    
      int a = 1;
      int b = 2;
      a + b;
    
      a++;
      b--;
    
      a > b;
      a && b;
    
  • 将亡值:顾名思义即将消亡的值,是C++11新增的跟右值引用相关的表达式,这样表达式通常是将要被移动的对象(移为他用),比如返回右值引用T&&的函数返回值、std::move的返回值,或者转换为T&&的类型转换函数的返回值。

    将亡值可以理解为通过“盗取”其他变量内存空间的方式获取到的值。在确保其他变量不再被使用、或即将被销毁时,通过“盗取”的方式可以避免内存空间的释放和分配,能够延长变量值的生命期。(通过右值引用来续命)。

    xvalue 只能通过两种方式来获得,这两种方式都涉及到将一个左值赋给(转化为)一个右值引用:

    • 返回右值引用的函数的调用表达式,如 static_cast<T&&>(t); 该表达式得到一个 xvalue

    • 转换为右值引用的转换函数的调用表达式,如:std::move(t)satic_cast<T&&>(t)

    下面通过几个代码来详细分析什么是将亡值:

      std::string fun() {
      std::string str;
      // ...
      return str;
      }
    
      std::string s = fun();
    

    在函数fun()中,str是一个局部变量,并在函数结束时候被返回。

    在C++11之前,s = fun();会调用拷贝构造函数,会将整个str复制一份,然后把str销毁。如果str特别大的话,会造成大量额外开销。在这一行中,s是左值,fun()是右值(纯右值),fun()产生的那个返回值作为一个临时值,一旦str被s复制后,将被销毁,无法获取、也不能修改。

    自C++11开始,引入了move语义,编译器会将这部分优化成move操作,即不再是之前的复制操作,而是move。此时,str会被进行隐式右值转换,等价于static_cast<std::string&&>(str),进而此处的 s 会将 foo 局部返回的值进行移动。

    无论是C++11之前的拷贝,还是C++11的move,str在填充(拷贝或者move)给s之后,将被销毁,而被销毁的这个值,就成为将亡值。

    将亡值就定义了这样一种行为:具名的临时值、同时又能够被move。

  • 泛左值:泛左值(“广义左值”)表达式是具名表达式,对应了一块内存。glvalue有lvalue和xvalue两种形式。

    一个表达式是具名的,则称为glvalue,例子如下:

      struct S{
      int n;
      };
    
      S fun();
      S s;
      s;
      std::move(s);
    
      fun();
      S{};
      S{}.n;
    

    在上述代码中:

    • 定义了结构体S和函数fun()

    • 第6行声明了类型为S的变量s,因为其是具名的,所以是glvalue

    • 第7行同上,因为s具名,所以为glvalue

    • 第8行中调用了move函数 ,将左值s转换成xvalue,所以是glvaue

    • 第10行中,fun()是不具名的,是纯右值,所以不是glvalue

    • 第11行中,生成一个不具名的临时变量,是纯右值,所以不是glvalue

    • 第12行中,n具名,所以是glvalue

    glvalue的特征如下:

    • 可以自动转换成prvalue
    • 可以是多态的
    • 可以是不完整类型,如前置声明但未定义的类类型
  • 右值:右值是指可以移动的表达式。prvalue和xvalue都是rvalue。

    右值具有以下特征:

    • 无法对rvalue进行取地址操作。例如:&42&i++,这些表达式没有意义,也编译不过。

    • rvalue不能放在赋值或者组合赋值符号的左边,例如:3 = 53 += 5,这些表达式没有意义,也编译不过。

    • rvalue可以用来初始化const左值引用(见下文)。例如:const int& a = 1

    • rvalue可以用来初始化右值引用。

    • rvalue可以影响函数重载:当被用作函数实参且该函数有两种重载可用,其中之一接受右值引用的形参而另一个接受 const 的左值引用的形参时,右值将被绑定到右值引用的重载之上。

3 左值引用与右值引用

  • 左值引用:绑定到左值的引用,通过&来获得左值引用。(x 绑定到 y 可以理解为 x = y)

    例子:

      int a=10;              //非常量左值(有确定存储地址,也有变量名)
      const int a1=10;       //常量左值(有确定存储地址,也有变量名)
      const int a2=20;       //常量左值(有确定存储地址,也有变量名)
        
      //非常量左值引用
      int &b1=a;            //正确,a是一个非常量左值,可以被非常量左值引用绑定
      int &b2=a1;           //错误,a1是一个常量左值,不可以被非常量左值引用绑定
      int &b3=10;           //错误,10是一个非常量右值,不可以被非常量左值引用绑定
      int &b4=a1+a2;        //错误,(a1+a2)是一个常量右值,不可以被非常量左值引用绑定
    
      //常量左值引用
      const int &c1=a;      //正确,a是一个非常量左值,可以被非常量右值引用绑定
      const int &c2=a1;     //正确,a1是一个常量左值,可以被非常量右值引用绑定
      const int &c3=a+a1;   //正确,(a+a1)是一个非常量右值,可以被常量右值引用绑定
      const int &c4=a1+a2;  //正确,(a1+a2)是一个常量右值,可以被非常量右值引用绑定
    

    总结归纳:非常量左值引用只能绑定到非常量左值上;常量左值引用可以绑定到非常量左值、常量左值、非常量右值、常量右值等所有的值类型。

  • 右值引用:绑定到右值的引用,通过&&来获得右值引用。

    例子:

      int a=10;             //非常量左值(有确定存储地址,也有变量名)
      const int a1=20;      //常量左值(有确定存储地址,也有变量名)
      const int a2=20;      //常量左值(有确定存储地址,也有变量名)
    
      //非常量右值引用
      int &&b1=a;            //错误,a是一个非常量左值,不可以被非常量右值引用绑定
      int &&b2=a1;           //错误,a1是一个常量左值,不可以被非常量右值引用绑定
      int &&b3=10;           //正确,10是一个非常量右值,可以被非常量右值引用绑定
      int &&b4=a1+a2;        //错误,(a1+a2)是一个常量右值,不可以被非常量右值引用绑定
    
      //常量右值引用
      const int &&c1=a;      //错误,a是一个非常量左值,不可以被常量右值引用绑定
      const int &&c2=a1;     //错误,a1是一个常量左值,不可以被常量右值引用绑定
      const int &&c3=a+a1;   //正确,(a+a1)是一个非常量右值,可以被常量右值引用绑定
      const int &&c4=a1+a2;  //正确,(a1+a2)是一个常量右值,不可以被常量右值引用绑定
    

    非常量右值引用只能绑定到非常量右值上;常量右值引用可以绑定到非常量右值、常量右值上。

  • 绑定表

    引用类型 非常量左值 常量左值 非常量右值 常量右值 标记
    非常量左值引用 Y N N N
    常量左值引用 Y Y Y Y 全能类型,用于拷贝语义
    非常量右值引用 N N Y N 用于移动语义,完美转发
    常量右值引用 N N Y Y 暂无用途
               
  • 若想使右值引用绑定到左值,可以利用std::move函数将左值转为右值,但是被调用后的左值将不能再被使用。

      int a=10;                 //非常量左值(有确定存储地址,也有变量名)
      const int a1=20;          //常量左值(有确定存储地址,也有变量名)
    
      //非常量右值引用
      int &&d1=std::move(a);    //正确,将非常量左值a转换为非常量右值,可以被非常量右值引用绑定
      int &&d2=std::move(a1);    //错误,将常量左值a1转换为常量右值,不可以被非常量右值引用绑定
    
      //常量右值引用
      const int &&c1=std::move(a);      //正确,将非常量左值a转换为非常量右值,可以被常量右值引用绑定
      const int &&c2=std::move(a1);     //正确,将常量左值a1转换为常量右值,可以被常量右值引用绑定
    
  • 左值引用与右值引用的区别

    1. 左值引用绑定到有确定存储空间以及变量名的对象上,表达式结束后对象依然存在;

    2. 右值引用绑定到要求转换的表达式、字面常量、返回右值的表达式等临时对象上,赋值表达式结束后就对象就会被销毁。

    3. 左值引用后可以利用别名修改左值对象;右值引用绑定的值不能修改。

  • 引入右值引用的原因

    • 替代需要销毁对象的拷贝,提高效率:某些情况下,需要拷贝一个对象然后将其销毁,如:临时类对象的拷贝就要先将旧内存的资源拷贝到新内存,然后释放旧内存,引入右值引用后,就可以让新对象直接使用旧内存并且销毁原对象,这样就减少了内存和运算资源的使用,从而提高了运行效率;

    • 移动含有不能共享资源的类对象:像IO、unique_ptr这样的类包含不能被共享的资源(如:IO缓冲、指针),因此,这些类对象不能拷贝但可以移动。这种情况,需要先调用std::move将左值强制转换为右值,再进行右值引用。