一、前言
本文主要来简单探讨一下C++中的左值、右值问题。
虽然大多数时候即使我们不了解这两个概念依旧能很好的写代码了,但想要进一步理解C++的特性,了解这两个概念也是有必要的。
为了更加容易的引出这两个概念,我直接在vs中写下如下代码:
我让一个常量等于另外一个常量时,编译器此时就会报错,这里便提到了“左值”。
然后我再写一段代码:
这里就提到了“右值”。
虽然大家平时可能很少写出这样的代码,但不管怎么说,问题确实是存在的,多了解一些也总是好的。
二、简单定义
这里简单给出一个网上搜到的定义,可以很好的判断两者:
- 左值:表示了一个占据内存中某个可识别的位置(也就是一个地址)的对象
- 右值:这就可以使用排除法来定义,一个表达式不是左值就是右值,所以右值就应该是一个不表示内存中某个可识别位置的对象的表达式。
定义看起来依旧容易使人迷糊,所有这里再举个例子,首先定义并赋值了一个整形变量:
int var;
var = 4;
赋值操作就需要左操作数是一个左值,由于var
是一个有内存位置的对象,因此它是左值。然而,下面的写法则是错的:
4 = var; // 错误!
(var + 1) = 4; // 错误!
常量4
和表达式var + 1
都不是左值,因为它们都是表达式得到的临时结果,而没有可识别的内存位置,因此赋值给它们是没有任何语义上的意义的:我们赋值到了一个不存在的位置。
不是左值,那么就可以认为它们是右值了,这就是排除法,而这些结果之所以无法在内存中被定位,是因为它们的计算结果只存在于计算过程中的临时寄存器中,而不在内存中。
此时我们就能理解第一个例子了,因为100
本身是一个临时的值,它是一个右值,只会短暂存在于寄存器中,无法定位地址,自然无法对其进行赋值。
左值一开始在C中定义为“可以出现在赋值操作左边的值”。然而,当ISO C加入 const
关键字后,这个定义便不再成立,比如下面这段代码:
const int a = 10; // 'a' 是左值
a = 10; // 但不可以赋值给它!
于是定义需要继续精化,不是所有的左值都可以被赋值,可赋值的左值被进一步称为“可修改左值”。
三、左右值转换
通常来说,计算都使用右值作为参数。例如两元加法操作符 '+'
就需要两个右值参数,并返回一个右值:
int a = 1; // a 是左值
int b = 2; // b 是左值
int c = a + b; // + 需要右值,所以 a 和 b 被转换成右值
// + 返回右值
在例子中,a
和 b
都是左值。因此,在第三行中,它们经历了隐式的“左值到右值转换”。除了数组、函数、不完整类型的所有左值都可以转换为右值。
那右值能否转换为左值呢?这自然是不能的,因为根据左值的定义,这种转换会违反左值的本质。
不过,右值可以通过一些更显式的方法产生左值。例如,一元解引用操作符 '*'
需要一个右值参数,但返回一个左值结果。考虑这样的代码:
int arr[] = {1, 2};
int* p = &arr[0];
*(p + 1) = 10; // 正确: p + 1 是右值,但 *(p + 1) 是左值
相反地,一元取地址操作符 '&'
需要一个左值参数,返回一个右值:
int var = 10;
int* bad_addr = &(var + 1); // 错误: 一元 '&' 操作符需要左值参数
int* addr = &var; // 正确: var 是左值
&var = 40; // 错误: 赋值操作的左操作数需要是左值
在 C++ 中&
符号还有另一个功能:定义引用类型。引用类型又叫做“左值引用”。因此,不能将一个右值赋值给左值引用:
std::string& sref = std::string(); // 错误: 非常量的引用 'std::string&' 错误地使用右值 'std::string` 初始化
这很好理解,str::string声明了一个字符串对象,但其是临时的、还没有复制存放在一个指定的内存中,此时直接让引用等于它,根本矛盾就在于临时对象没有地址无法被引用,所以是错误的写法。
虽然非常量的左值引用不可以使用右值赋值,但对于常量的左值引用却是可以的,因为对于常量来说,编译器会自动将其存放在全局内存中,也就是有地址。
比如C++函数中,我们常常使用常量引用来接收参数,这就可以避免创建不必要的临时对象。
int fun(const obj& b){
}
fun(obj());
四、CV限定的右值
CV限定指的是const
和volatile
两个类型限定符,只不过大部分时候我们用的应该都是const
关键字,而volatile
涉及更加底层的优化,一般我们是用不到的,所以这里就不再赘述了。
在 C 中,只有左值有CV限定的类型,而右值从来没有。而在 C++ 中,类右值可以有 CV 限定的类型,但内置类型 (如 int
) 则没有,考虑下面的例子:
A类中有两个同名函数,唯一不同的是函数后面有无const
关键字进行限定。
而bar
、cbar
这两个函数用同样的构造函数返回一个A对象,唯一不同的也是返回值后面是否有const关键字进行限定。
这便是典型的CV限定,用一个const
限定了cbar
返回的右值时,此时它能调用的也只有被const
限定的函数。
五、移动语义
最后,再来看看“右值”概念最大的用处,也就是C++11推出的移动语义。
举个简单的例子:
该类中只有两个构造函数,然后main函数中声明两个对象,一个使用默认构造函数,一个使用拷贝构造函数,此时至少会构造两次。
虽然这样看起来也没什么,但有时候我们可能是在传参中完成的:
这就会导致多一次拷贝的问题,虽然依旧可以用引用解决,不过我们这里还是主要讨论如何使用移动语义来解决这个问题:
此时就不再是拷贝构造函数了,而是移动构造函数,通过&&
来定义,这是移动语义的基础,也被称为右值引用,有了移动构造函数,就能使用move
函数来进行移动构造。
因为string
类型其内部也已经实现了移动构造函数,所以这里就直接用的move
再次移动它的值。
如果是
char*
,那么这里就应该直接交接指针,而如果是拷贝构造函数,那就应该进行拷贝操作。
move
这个步骤,本质上就是直接交接所有权的意思,这里便是直接将临时转换得到的右值a
交给了移动构造函数。
然后在移动构造函数中,虽然此时也调用了构造函数,但由于是直接交接的内部数据,而不是像前面那样进行拷贝操作,这便可以提高执行效率。
并且一旦调用了move
函数,那么该变量此后就无法再被使用了,这很像rust中的所有权概念。
六、简单总结
最后简单总结一下两者的概念:
- 左值简单来说就是可以被修改的值,可以获取到地址的值。
- 右值与之相反,无法获取到它的地址,仅仅只是一个字面量、表达式之内的东西,它们存在于寄存器中,无法在内存中找到。
- 通过右值引用结合移动语义,可以实现高效的数据传递,内部仅交接数据指针,而无需进行拷贝操作。