〈参考〉中谈到,参考是对象的别名,在 C++ 中,「对象」这个名词,不单只是指类的实例,而是指内存中的一块数据,那么可以参考字面常量吗?常量无法使用&
取址,例如无法&10
,因此以下会编译错误:
int &r = 10; // error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'
不过,加上const
的话倒是可以:
const int &r = 10;
常量是内存中临时的数据,无法对常量取址,因此编译器会将以上转换为像是:
const int _n = 10;
const int &r = _n;
实际上,r
并不是真的参考至 10,而是 10 被复制给_n
,然后r
参考至_n
,如果不加上const
,那么你可能会以为变更了r
,就是变更了 10 地址处的值,因此就要求你一定得加上const
,不让你改了。
为什么会需要参考至常量?通常跟函数调用相关,这之后文件再来讨论;类似地,以下会编译失败:
int a = 10;
int b = 20;
int &r = a + b; // error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'
这是因为a + b
运算出的结果,会是在临时的内存空间中,无法取址;类似地,若想通过编译,必须加上const
:
int a = 10;
int b = 20;
const int &r = a + b;
不过在 C++ 11 之后,像以上的表达式,可以直接参考了:
int a = 10;
int b = 20;
int &&rr = a + b;
在以上的程序中,int&&
是 rvalue 参考(rvalue reference),rr
参考了a + b
运算结果的空间,相对于以下的程序来说比较有效率:
int a = 10;
int b = 20;
int c = a + b; // 将 a + b 的结果复制给 c
因为不必有将值复制、存储至c
的动作,效率上比较好,特别是当 rvalue 表达式会产生庞大对象的时候,复制就会是个成本考量,例如s1
、s2
若是个很长的string
,那么s1 + s2
的结果还会复制给目标string
的话:
string result = s1 + s2;
改用以下会比较经济:
string &&result = s1 + s2;
相对于 rvalue 参考,int&
这类参考就被称为 lvalue 参考;只不过,lvalue 或 rvalue 是什么?方才编译错误的消息中,似乎也出现了 lvalue、rvalue 之类的字眼,这些是什么?
lvalue、rvalue 是 C++ 对表达式(expression)的分类方式,一个粗略的判别方式,是看看&
可否对表达式取址,若可以的话,表达式是 lvalue,否则是个 rvalue。
若要精确的定义,可以参考〈Value categories〉,该文件中 History 的区域,有谈到表达式分类的历史,最早是从 CPL 开始对表达式区分为左侧模式(left-hand mode)与右侧模式(right-hand mode),左、右是指表达式是在指定的左或右侧,有些表达式只有在指定的左侧才会有意义。
C 语言有类似的分类方式,分为 lvalue 与其他表达式,l 似乎暗示着 left 的首字母,不过实际上,并非以指定的左、右来分类,lvalue 是指可以识别对象的表达式,白话点的说法是,表达式的结果会是个有名称的对象。
到了 C++ 98,非 lvalue 表达式被称为 rvalue,一些 C 中非 lvalue 的表达式成了 lvalue,到了 C++ 11,表达式又被重新分类为〈Value categories〉中的结果。
许多文件取 lvalue、rvalue 的 l、r,将它们分别译为左值、右值,就表达式的分类历史来说,不能说是错,不过严格来说,C++ 中 lvalue、rvalue 的 l、r,并没有左、右的意思,lvalue、rvalue 只是个分类名称。
在〈Value categories〉一开头,可以看到目前的 C++ 标准,将表达式更细分为 glvalue、prvalue、xvalue、lvalue 与 rvalue,g 暗示为 generalized,pr 暗示为 pure,x 暗示为 eXpiring,就涵盖关系而言,使用图来表示会比较清楚:

具体来说,哪个表达式属于哪个分类,〈Value categories〉都有举例,当然,容易看到眼花花…
方才谈到,一个粗略的判别方式,是看看&
可否对表达式取址,若可以的话,表达式是 lvalue,否则是个 rvalue;另一个白话点的判别方式是,lvalue 表达式的结果会是个有名称的对象,例如a
,rvalue 的结果是暂时性存在于内存,例如a + b
。
那么++i
、i++
呢?在〈递增、递减、指定运算〉中谈过,++i
运算结果是递增后的i
,也就是++i
运算结果是个有名称的对象,因此可以使用 lvalue 参考:
int i = 10;
int &r = ++i; // OK
然而i++
运算结果是递增前的i
,暂时性存在于内存,若不指定给变量的话就不见了,因此i++
是个 rvalue,因此以下会编译失败:
int i = 10;
int &r = i++; // error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'
C++ 11 开始,若想参考i++
运算时暂时存在于内存中递增前的i
,可以使用 rvalue 参考:
int i = 10;
int &&rr = i++; // OK
哪些是 lvalue,而哪些又是 rvalue,基本上还是以〈Value categories〉的定义为准,不清楚的话就查一下。
使用 rvalue 参考通常是为了效率上的考量,
还有个std::move
(定义于utility
标头文件)用来实现移动语义(move semantics),例如实现移动构造函数(move constructor),这需要在认识类定义、复制构造函数等之后才能细谈,就现阶段而言,可以从string
来稍微认识一下,例如,以下会将s1
的数据复制给s2
:
string s2 = s1; // s1 是个 string,而这边会复制 s1 的内容给 s2
若s1
指定给s2
后,就不再会用到原本的内容,那么复制就是不必要的成本,若能把s1
的内容直接移给s2
的话就好了,C++ 11 开始可以这么做:
string s2 = std::move(s1);
这么一来,s1
的数据就被移至s2
了,在这之后不能立即使用s1
来取值,因为数据转移出去了,取值结果是不可预期的,只能销毁s1
,或者是重新指定字符串给s1
。
来看个简单的示范:
#include <iostream>
#include <string>
using namespace std;
int main() {
string s1 = "abc";
string s2 = s1; // 复制 s1 的数据
cout << s1 << endl; // 显示 "abc"
cout << s2 << endl; // 显示 "abc"
}
跟移动版本比较一下:
#include <iostream>
#include <string>
#include <utility>
using namespace std;
int main() {
string s1 = "abc";
string s2 = std::move(s1); // 转移 s1 的数据
// cout << s1 << endl; // 这时取值结果不可预期
cout << s2 << endl; // 显示 "abc"
s1 = "xyz"; // OK
cout << s1 << endl; // 这时可以取值
}
移动版本之所以能够运作,是因为string
的构造函数之一,使用了 rvalue 参考,而std::move
的作用,其实是告诉编译器,将指定的 lvalue 当成是 rvalue(某些程度就是一种 cast),以选择定义了 rvalue 参考的构造函数,而构造函数中实现了移动来源数据的演算。
因为 move 这个名称太平凡了,为了避免名称冲突,建议包含std
命名空间,也就是使用std::move
。