当前位置: 首页 > news >正文

理解C++值类别(lvalue, rvalue, prvalue, xvalue)

C++ 值类别 (value categories)概述

在 C++中值类别并不是语言特性, 他们更多的是表达式的语法属性. 理解它们有助于理解:

  • 内置类型和用户定义的操作符
  • 引用类型
  • 移动语义

值类别也是不断演进的:

  • 在早期的 C 语言中, 只有两种值类别: 左值(lvalue)和右值(rvalue). 与之相关的概念也很简单.
  • 早期的 C++扩增了类(class), const修饰符, 和引用类型. 这使得值类别更加丰富.
  • 现代 C++ 引入了右值引用(rvalue references). 为此不得不引入更多的值类别来描述相关的行为.

左值和右值的定义

C Programming Language 这本书中, Kernighan 和 Ritchie 给出的定义是: 出现在赋值表达式=号左边的操作数(operand)是左值.

E1 = E2;
  1. 一个左值(lvalue)是指向一个对象(Object)的表达式. 一个对象则指一块存储区域.

    int n; // 定义了一个名为`n`的int类型对象
    n = 1; // 一个赋值表达式
    

    上面代码中:

    • n 表示一个 int 对象, 它是左值.
    • 1 表示一个整型常量, 它是右值.
  2. 右值(rvalue)被定义为: 不是左值的表达式.

    下面的语句尝试修改一个整型常量 1. 当然, C++会拒绝它.

    1 = n; // 明显不合法
    

    因为一个赋值语句会给一个对象赋值, 赋值语句的左边操作数必须是左值, 但是 1 不是左值, 它是右值.

为什么要区分左值和右值

  • 这样的话编译器可以假设右值(rvalue)不会占用存储空间.
  • 这给编译器机器码生成提供了更多的自由.

右值的存储

我们再次看这个例子:

int n;
n = 1;
  1. 一个编译器或许会把字面量1存放在数据区, 把1看做是一个左值. 这样的情况下它会生成下面的汇编代码:

    one:      ; 一个标签, 指代下面对象的地址.word 1 ; 为整数1分配一个存储位置
    

    那么编译器将会从one复制到n

    move n, one ; 复制`one`地址的值到`n`变量地址
    
  2. 实际上, 现代 CPU 上, 有源操作数是立即数(immediate operand)的指令. 源操作数是立即数意味着数值是指令的一部分.

    在汇编语言中, 会这样写:

    mov n, #1 ; 将数值 #1 拷贝到地址`n`
    

    这种情况下: 右值 1 将会作为指令的一部分在代码区.

    以 x86-64 为例, 我们假定n已经被加载到寄存器RAX中, 那么n=1就可以被写为: MOV RAX, 1, 翻译为机器码就是:

    B8 01 00 00 00
    

    其中:

    • B8是操作码, 表示向 RAX 寄存器赋值一个 32 位的立即数.
    • 01 00 00 00 是立即数 1 的小端序表示(最低有效字节位于最低地址).

字面量

很多的字面量是右值, 他们不一定占用数据存储. 包括:

  • 数字字面量, 如 3, 3.14
  • 字符字面量, 如 ‘a’, ‘b’

然而, 字符串字面量是左值. 它们占用存储空间. 比如"Hello"这种字符串字面量.


左值和右值的区别

  1. 一个左值可以出现在赋值表达式的任意一边, 比如:

    int m, n;
    m = n; // OK, m和n均是左值
    

    这个表达式将左值(n)当做右值来使用. 从 C++术语讲, 这进行了一次左值到右值的转换.

  2. 表达式的操作数可以是左值或右值.

    比如, '+'操作符必须是两个表达式.

    int x;x + 2; // OK, lvalue + rvalue
    2 + x; // OK, rvalue + lvalue
    
  3. 运算结果是右值: 对于内置的二元操作符(不包含赋值=操作符), 比如’+', 它的操作数可能是左值或右值, 它的结果是一个右值.

    • 表达式m+n的结果将放在一个编译器生成的临时变量中, 通常是一个 CPU 寄存器. 这样的临时变量通常被称为右值(rvalue).
    • 表达式m + 1 = n 是错误的, 因为m+1的结果是一个右值, 不能出现在赋值表达式的左边.
  4. 解引用操作符*是左值: 解引用操作符*的结果是一个左值. 一个指针p可以指向一个对象, 所以*p是一个左值.

    int arr[10];
    int *p = arr;
    char *str = nullptr;
    *p = 1; // OK
    *str = 'a'; // undefined behavior
    

    注意: 左值属性是一个编译时属性. *str是一个左值即便str是空指针.

  5. 并非所有的左值都可出现在=左边: 有const修饰的左值是不可修改的.

    char const name[] = "hello";
    name[0] = 'H'; // 错误, name是const左值
    

    name[0]是左值, 但是不可修改.

  6. 枚举类型是右值

    enum { MAX = 100 };
    MAX +=3; // 错误
    const int *p = &MAX; // 错误
    

表达式的数据存储

从概念上将,

  • 右值(内置类型)通常不占用空间. 但是实际上可能会.
  • 左值(任意类型)都会占用空间, 但是实际上编译器可能会优化掉.

C/C++让程序员假定左值(内置类型)占用空间而右值不占用空间.

class 类型的右值

考虑如下的类型:

struct S {int x;int y;
};

那么编译器如何为下面代码生成汇编代码:

S s1 = {1, 2};
int i = s1.y;

编译器会生成下面的汇编代码:

s1:.long   1.long   2
i:.zero   4mov     eax, DWORD PTR s1[rip+4]mov     DWORD PTR i[rip], eax

可以看到编译器使用 基址(base)+偏移量(offset) 来加载s1.x的值到i变量中.

接着再考虑下面的代码:

S foo(); // 返回值是S类型
int j = foo().y; // 访问返回值对象的y成员

同样的, 会使用 基址+偏移量 来加载foo().y的值到j变量中. 因此, foo()的返回值必须要有一个基址.
从概念上将, 任何有地址的对象都占用存储(废话, 不然地址从哪来). 这就是为什么 class 类型的右值需要被区别对待.

引用类型

左值和右值也有助于解释引用类型(reference type). 引用提供了指针之外的另一种方式来为一个对象创建别名.

int i;
int &ref = i;

最后一行创建了一个指向int类型的引用ref, 并用i来初始化它. 因此, 引用refi的别名.

引用类型实际上是一个指针, 并且在每次使用的时候解引用. 可以用指针来重写绝大多数引用类型:

引用指针
int &ref = i;int *const p = &i;
ref = 1;*p = 1;
int j = ref + 1;int j = *p + 1;

一个引用产生一个左值.

const 类型引用

一个指向 const 的引用(reference to const)能接受一个const或者非const类型的参数.

R f(T const& t); // reference to const

在很多情况下, 它与下面的函数表现一致

R f(T t); // by value

也就是调用和返回值都是一样的. 上述两种方式的调用中, 都不会改变x的值.

  • 传参方式: f将会得到x的一个拷贝, 不会修改原来的x
  • 传引用方式: f将会返回一个const类型的值, 也不会修改原来的x

那么为什么要有reference to const呢?

  1. 在某些情况下传引用方式要比传值方式更高效, 比如T类型是一个很大的对象时.
  2. 这主要依赖于拷贝的代价. 对于小的类型(int, bool), 传值方式比传引用方式更高效.

引用和临时变量

指针只能绑定到左值上, 类似的, 普通的引用只能绑定到左值.

int *pi = &3; // 错误
int& ri = 3; // 错误

但是有个例外, const T& 可以绑定到一个非左值的T, 如果存在一个从x类型到T的类型转换(conversion).

这种情况下, 编译器将一个临时变量来保存 T(从x转换而来), 然后将临时变量绑定到const T&.

double const& rd = 3;

当程序执行到此处时:

  1. 3int转为double
  2. 创建一个临时变量来保存转换后的值
  3. 将临时变量绑定到const double&

当程序离开此作用域时, 临时变量被销毁.

两种类型的右值

从概念上讲, 内置类型的右值不占据数据存储. 然而, 创建的临时变量是占用空间的, 即便是内置类型.

临时变量依旧是右值, 只不过他们占用存储. 就像自定义类型的右值一样. 所以就出现了两种该类型的右值:

  • 纯右值(Pure rvalues)或者"prvalues", 它们不占据存储.
  • 将亡值(Expiring values)或者(Xvalues), 它们占据存储.

临时对象是通过 temporary materialization conversion 创建的. 它将一个 纯右值(prvalue) 转为 将亡值(xvalue).

右值引用

C++11 引入了新的引用类型: 右值引用(rvalue references). C++03 中的引用, 在 C++11 中被称为左值引用(lvalue references).

  1. 右值引用使用&&来表示.

    int&& ri = 10;
    
  2. 可以使用右值引用作为函数参数和返回类型:

    double &&f(int &&ri);
    
  3. 也存在指向常量的右值引用(rvalue reference to const), 比如:

    int const&& rci = 10;
    
  4. 右值引用只能绑定右值, 无论是否是 const.

    int n = 10;
    int &&ri = n; // 错误
    int const &&rci = n; // 错误
    

绑定一个右值引用到一个右值会触发一个"temporary materialization conversion".
这与绑定一个"lvalue reference to const" 到一个右值类似.

move 操作

现代 C++支持通过移动操作来避免不必要的拷贝. 主要有移动构造函数和移动赋值函数.

class String {public:String(const char*); // 构造函数String& operator=(String const&); // 赋值函数// move operationString(String&&) noexcept; // 移动构造函数String& operator=(String&&) noexcept;// 移动赋值函数
};

给定下面的对象:

String s1, s2, s3;
  1. 对下面的赋值语句来说, 因为s2还要用, 所以s2不会被移动.

    s1 = s2; // String::operator=(String const&)
    
  2. 对下面的语句来说会使用移动操作, 因为(s2+s3是一个右值):

    s1 = s2 + s3; // String::operator=(String&&)
    

右值引用作为左值

绑定右值引用到一个右值会创建一个xvalue.

看下面的代码:

String& String::operator=(String&& rhs) noexcept {String temp(rhs); // calls string(String const&)// ...
}

operator=中, rhs将会在整个函数周期内存在, 因此在函数体内, 它是一个左值.
一般来说, 有名字的对象是一个左值.

左值(lvalue)作为将亡值(xvalue)

有时候从左值移动是有意义的, 例如:

template <typename T>
void swap(T& a, T& b) {T temp(a);   // 拷贝构造a = b; // 拷贝赋值b = temp; // 拷贝赋值
}

这种情况下编译器会拷贝构造temp, 因为a后续还在被使用. 但是我们知道a会在下一行被覆盖, 所以可以将a的值移动到temp中.

从一个将要消亡的值中移动是安全的. 但是编译器并不能始终发现这些将亡(Expiring)值. 如何告诉编译器呢?

要从一个lvalue移动值, 你需要将其转为一个xvalue. 换句话说就是要将其转换为一个未命名的右值引用. 这就是std::move做的事情.

template <typename T>
constexpr T&& move(T&& t) noexcept {return static_cast<T&&>(t);
}

对前面提到的swap函数, 可以这样改造:

template <typename T>
void swap(T& a, T& b) {T temp(std::move(a)); // 移动构造a = std::move(b); // 移动赋值b = std::move(temp); // 移动赋值
}

值类别总结

如下就是 C++11 之后的值类别:

value category

我们可以发现, 新增了如下的值类别:

  • glvalue: 泛左值(“generalized lvalue”)
  • prvalue: 纯右值(“Pure rvalue”)
  • xvalue: 将亡值(“Expiring lvalue”)

相关阅读

  • C++ 必知必会: 移动语义(Move Semantics)

http://www.mrgr.cn/news/94564.html

相关文章:

  • 【C++】—— 一篇文章解决面试 继承菱形继承
  • 深入理解Linux网络随笔(七):容器网络虚拟化--Veth设备对
  • 共享内存通信效率碾压管道?System V IPC原理与性能实测
  • Lora本地微调实战 --deepseek-r1蒸馏模型
  • 代码随想录刷题有感
  • SpringMVC (二)请求处理
  • Go语言对于MySQL的基本操作
  • 【后端开发面试题】每日 3 题(十三)
  • 图解AUTOSAR_CP_BSW_General
  • Python软件和搭建运行环境
  • [多线程]基于单例懒汉模式的线程池的实现
  • Centos7使用docker搭建redis集群
  • Java 大视界 -- Java 大数据中的异常检测算法在工业物联网中的应用与优化(133)
  • Linux 命令学习记录
  • C++基础 [三] - 面向对象三
  • Java 大视界 -- Java 大数据在智能金融资产定价与风险管理中的应用(134)
  • Keil5下载教程及安装教程(附安装包)
  • Java基础语法练习42(基本绘图-基本的事件处理机制-小坦克的绘制-键盘控制坦克移动)
  • 推荐系统基础
  • 操作系统-八股