当一个对象内部管理着较大的动态内存时,如果每次传递数据都重新申请内存并把数据复制一遍,运行开销是比较大的。
这时我们可以想一想:如果数据来源是后面还要继续用的变量,就必须通过深拷贝复制出独立副本。但如果它只是一个 “算完马上销毁的临时结果”,能不能直接把内存接管过来呢?要让编译器分清这两种情况,就要先认识 “左值” 与 “右值”。
什么是左值和右值?
很多初学者第一次听到左值(lvalue)和右值(rvalue)时,容易按字面意思理解成:“写在赋值等号(=)左边的是左值,写在等号右边的是右值。” 这种说法虽然好记,但并不准确。我们先看下面 3 行简单的代码:
int a = 10;
int b = a;
int c = a + 5;在 int b = a; 这一行中,变量 a 明明站在等号右边,但它依然是左值。因为在 C++ 中,区分左值和右值并不是看 “站在等号的哪一边”,而是看它有没有固定的内存地址:
- 左值(lvalue):指的是有名字、有固定内存地址、在这行代码执行完之后依然存在的变量或对象。我们可以使用取地址符(&)获取它的地址,比如上面代码中的变量
a、b、c。 - 右值(rvalue):指的是没有名字、在计算过程中临时产生、当前表达式执行完通常就会销毁的值,不能使用取地址符(&)获取地址。比如数值字面量
10、算术表达式a + 5算出来的临时值,以及函数按值返回的临时结果。
平时写代码时,判断一个表达式是左值还是右值有一个非常实用的办法:看能不能在它前面加上取地址符(&)获取内存地址。能够取地址的就是左值,不能取地址的就是右值:
int a = 10;
// 正确方式
int* p1 = &a; // 变量 a 是左值
// 错误方式
int* p2 = &10; // 数值字面量是右值
int* p3 = &(a + 5); // 临时计算结果是右值C++ 右值引用(&&)
在前面学习引用时,我们已经使用过单个 & 给变量起别名,比如 int& ref = a;。它的完整名称其实叫做 “左值引用”,专门用来绑定左值。
为了让程序能够专门识别并接收即将销毁的右值,C++11 标准引入了 “右值引用”,写法是在数据类型后面加上两个连续的 & 符号,也就是写成 &&。
语法:
数据类型&& 引用名 = 右值表达式;说明:
这里的 && 紧跟在数据类型后面,表示声明右值引用,小伙伴们不要把它和条件判断里的逻辑与运算符搞混了。右值引用专门用来绑定数值字面量、临时计算结果等右值,不能直接绑定普通的左值变量:
int num = 10;
// 正确方式
int&& rref1 = 20; // 绑定字面量右值
int&& rref2 = num + 5; // 绑定表达式右值
// 错误方式
int&& rref3 = num; // 报错:不能绑定左值回顾前面学过的左值引用,普通引用 int& 只能绑定左值,但如果加上 const 写成 const int& cref = 20;,常引用同样可以绑定右值。那为什么 C++11 还要专门引入右值引用 int&& 呢?
原因就在于 “能不能修改”。const int& 受 const 限制只能读取数据,不能修改它。而在后续接管临时对象的资源时,我们还必须修改它的内部状态,比如把内部指针置为 nullptr。这 3 种引用的区别如下表所示。
| 引用类型 | 可绑定的目标 | 能否修改 | 主要用途 |
|---|---|---|---|
| 普通左值引用(类型&) | 只能绑定左值 | 可以修改 | 作为函数参数修改外部传入的变量 |
| const 左值引用(const 类型&) | 左值和右值均可 | 不可修改 | 作为只读参数,省去复制开销 |
| 右值引用(类型&&) | 只能绑定右值 | 可以修改 | 接收临时右值,修改数据或转移资源 |
本来临时右值在当前这行表达式算完后就会立刻销毁,但一旦使用右值引用把它接住,这个临时值就会跟着该引用变量一起保留下来,直到引用变量离开当前花括号范围才销毁。在此期间,我们可以通过右值引用直接读取或修改它的值。请看下面例子。
示例 1:定义与修改右值引用
#include <iostream>
int main() {
int num = 10;
int&& rref1 = 20; // 绑定字面量右值
int&& rref2 = num + 5; // 绑定表达式右值
std::cout << "初始 rref1 = " << rref1 << "\n";
std::cout << "初始 rref2 = " << rref2 << "\n";
rref2 = 100; // 修改右值引用的值
std::cout << "修改后 rref2 = " << rref2 << "\n";
return 0;
}运行结果如下。
初始 rref1 = 20
初始 rref2 = 15
修改后 rref2 = 100分析:
int&& rref1 = 20; 和 int&& rref2 = num + 5; 分别把右值 20 和临时计算结果 15 绑定到了右值引用上。绑定之后,临时值不会立即消失,我们不仅可以读取它们,还可以执行 rref2 = 100; 直接修改它的值。
使用函数重载区分左值与右值
在实际开发中,我们很少会在函数里单独定义一个 int&& 局部变量来存整数。右值引用真正发挥作用的地方是作为函数形参,配合函数重载让编译器自动区分 “传进来的是左值还是右值”,从而决定是执行常规拷贝还是直接转移资源。
为了先看清编译器的匹配规则,下面我们用最简单的 int 类型定义两个同名的重载函数:一个形参写成常左值引用 const int&,另一个形参写成右值引用 int&&,看看编译器是如何自动分流的。
示例 2:重载区分左值与右值
#include <iostream>
// 重载版本 1:接收左值引用
void checkValue(const int& value) {
std::cout << "匹配左值引用版本:" << value << "\n";
}
// 重载版本 2:接收右值引用
void checkValue(int&& value) {
std::cout << "匹配右值引用版本:" << value << "\n";
}
int getNumber() {
return 30;
}
int main() {
int a = 10;
checkValue(a); // 传入左值变量
checkValue(20); // 传入字面量右值
checkValue(a + 5); // 传入表达式右值
checkValue(getNumber()); // 传入函数返回的右值
return 0;
}运行结果如下。
匹配左值引用版本:10
匹配右值引用版本:20
匹配右值引用版本:15
匹配右值引用版本:30分析:
在这个例子中,调用 checkValue(a) 时,实参 a 是有名字的变量,属于左值。由于右值引用 int&& 不能绑定左值,因此自动匹配到了 const int& 版本。
而调用 checkValue(20)、checkValue(a + 5) 和 checkValue(getNumber()) 时,传入的都是没有名字的临时右值,全部进入了 int&& 版本。
虽然 const int& 也能绑定右值,但当两个重载版本同时存在时,非 const 右值会优先匹配更专门的 int&& 版本。只有没提供 int&& 版本,或传入 const 只读对象时,才会匹配 const int& 版本。
已命名的右值引用是左值吗?
在使用右值引用时,有一个初学者很容易搞混的细节:当我们写下 int&& ref = 20; 之后,如果紧接着调用 checkValue(ref);,程序会进入左值引用版本还是右值引用版本呢?我们通过一个例子来验证一下。
示例 3:已命名的右值引用变量
#include <iostream>
void checkValue(const int& value) {
std::cout << "匹配左值引用版本:" << value << "\n";
}
void checkValue(int&& value) {
std::cout << "匹配右值引用版本:" << value << "\n";
}
int main() {
int&& ref = 20;
checkValue(ref); // 传入有名字的变量 ref
return 0;
}运行结果如下。
匹配左值引用版本:20分析:
从运行结果可以看出,虽然 ref 声明的类型是右值引用 int&&,但调用 checkValue(ref) 时却匹配到了左值引用版本 const int&。
为什么会这样呢?我们可以回到第一节讲过的判断办法,看它现在有没有名字、能不能使用 & 取地址。
在 int&& ref = 20; 中,等号右边的 20 没有名字、不能取地址,所以 20 是右值。但一旦把 20 绑定给 ref 之后,ref 就有了名字和固定的内存地址,我们可以写出 &ref 获取地址,并在后续代码中反复读取它。
假如编译器把有名字的 ref 也当成右值,那么第一次把 ref 传给某个移动函数时,它里面的数据就可能被转走清空,导致下一行代码再访问 ref 时出错。
注意 判断左值还是右值时,不要看声明时写了几个 &,只需要记住一个规则:只要是有名字的变量,当作表达式使用时一律是左值。哪怕变量声明为 int&& ref,单独写出 ref 时它依然属于左值。
