3.5.1 对象引用
引用是 C++ 中的一种特殊类型,它可以用来指代一个对象。例如:
int a = 42;
int& b = a;
在这个例子中,b
是一个整数类型的引用,它指代了对象 a
。一个对象的引用可以当做这个对象来使用,例如:
int a = 42;
int& b = a;
b = 43; // 修改了 a 的值
在这个例子中,通过对 b
赋值,修改了 a
的值。
除了一些特例外,引用总是当做指代的对象来使用(对于读取值或者写入值,一定是当做指代的对象来使用)。并且,在初始化之后,不能更改这种指代。
用一个对象初始化引用,也称为将引用绑定到对象。
例如:
int a = 1; // a 初始化为不确定值
int& b = a; // 将 b 绑定到 a
// 以 b 初始化 c,b 此时只有指代 a 的功能
// 因此,c 也绑定到 a
int& c = b;
c = 1; // 将 1 赋值给 c,也即是赋值给 a
将引用绑定到对象时,并不依赖对象的值,因此也可以使用不确定值对象来初始化引用。
int a; // a 初始化为不确定值
int& b = a; // 以具有不确定值的 a 初始化引用
b = 1; // 将 1 赋值给 a
引用不是一个对象,具体来说,引用类型不需要有对齐和大小、不需要占用内存,也不能声明引用的引用。(但是,根据需要,编译器可以生成使用存储来实现的引用。)
在前面的一节中提到,变量不一定是对象。这里我们可以给出更准确的说法:对象,以及绑定到对象的引用,统称为变量。
引用类型的声明与初始化
引用类型有两种,左值引用类型和右值引用类型。
左值引用类型的形式是
type_name &
右值引用类型的形式是
type_name &&
其中,type_name
是引用所指代的目标的类型。注意,&&
是一个整体,不是两个 &
符号,不可以拆开。
上下文相关
考虑这样的一个问题
a & b = c;
这段代码的含义是什么?是声明了一个 a &
类型的引用,还是求 a & b
的结果再试图以 c
给它赋值(并导致一个错误)?显然,这取决于 a
究竟是一个类型还是一个变量。
C++ 的语法是上下文相关的,相同的代码在不同的上下文中可能有不同的含义。初学者需要注意这一点。
左值引用
顾名思义,左值引用必须以左值表达式初始化,例如:
int a = 1, b = 2;
int& ref = a;
int& ref2 = 1;// 错误,表达式 1 不是左值
int& ref3 = a + b;// 错误,表达式 a + b 不是左值
int& ref4 = true ? a : b; // 正确,这个条件表达式是左值,ref4 绑定到 a
左值引用绑定到左值表达式,因此也能如左值表达式一般被赋值。
int a;
int& b = (a = 2); // 正确,b 绑定到 a
b = 1
也可以使用 auto &
来自动推导左值引用类型:
int a = 1;
auto& ref = a; // ref 是 int& 类型
函数中的左值引用
可以在函数参数中使用左值引用,来修改作为参数传入的对象:
void set_to_42(int& a) {
a = 42;
}
int b = 1;
set_to_42(b); // b 的值变为 42
传出参数
这种通过引用修改参数的方式,也称为传出参数。例如考虑如下的代码:
bool safe_divide(int a, int b, int& result) {
if (b == 0) {
return false;
}
result = a / b;
return true;
}
int a = 10, b = 2, c;
safe_divide(a, b, c); // c 的值变为 5
在这个 safe_divide
函数的设计中,我们需要从这个函数得到两个结果,一是函数是否执行成功,二是计算的结果。这种情况下,可以使用引用来传出结果。
注意,在实际工程中,我们一般不希望函数修改传入的参数,传出参数的形式在使用时应充分注释,或遵循一定的代码规范,以免造成误解。
可以在返回值中使用左值引用,例如:
int& max(int& a, int& b) {
if (a > b) {
return a;
} else {
return b;
}
}
int a = 1, b = 2;
auto &ref = max(a, b); // ref 绑定到 a 或 b 中较大的那个
ref = 3; // 修改了 b 的值
max(a, b) = 4; // 由于 max 返回的是引用,因此可以直接修改返回值,b 的值变为 4
这里可以发现,当函数返回一个左值引用时,可以直接对返回值进行赋值操作。结合在中的讲解,这种函数的调用是左值表达式。我们可以补充前面值类别的定义:
下列表达式是左值表达式:
- 返回值类型是引用类型的函数调用
下列表达式是右值表达式:
- 返回值类型是非引用类型的函数调用
右值引用
右值引用必须以右值初始化,例如:
int a = 1, b = 2;
int&& ref = 1; // 正确,1 是右值
int&& ref2 = a;// 错误,a 是左值
int&& ref3 = a + b; // 正确,a + b 是右值
int&& ref4 = true ? a : b;// 错误,这个条件表达式是左值
观察下面的代码:
int&& ref = 1;
ref = 2; // 为什么?这不是字面量吗?
你可能会奇怪,为什么右值引用绑定到字面量之后,这个右值引用还能修改?右值引用不是右值吗?
首先,右值引用的名字本身(也即这里的ref
),是一个标识符组成的基础表达式,当然是左值表达式。
此外,ref
所绑定到的对象,实际上是一个从 1
构造而来的临时对象。为了将一个右值表达式绑定给右值引用,会将右值表达式转换为一个与引用类型匹配的。
将亡值表达式代指这个临时的对象,这个临时的对象的生命周期会延长到绑定到它的引用的生命期结束。举例而言:
int&& ref = true; // 用 true 初始化一个 int 类型的临时对象,将这个临时对象绑定到 ref
ref = 2; // 修改了这个临时对象的值,而非字面量 true 的值
特殊的 auto&&
前面介绍过 auto &
可以推导左值引用类型,但 auto &&
并不是推导右值引用,而是“通用引用”,它会根据初始化器的值类别推导出适合的引用类型,例如:
int a = 1;
auto&& ref = a; // ref 是 int& 类型
auto&& ref2 = 1; // ref2 是 int&& 类型
这一原理及其应用会在后面的章节中详细介绍。
函数中的右值引用
类似于左值引用,右值引用也可以用在函数参数中,例如:
int a = 1;
void set_a_to(int&& value) {
a = value;
}
这个 set_a_to
函数接受一个右值引用,可以接受右值表达式作为参数,例如:
set_a_to(1);
set_a_to(a + 1); // a + 1 是纯右值表达式
当然,这个函数不接受左值表达式作为参数,例如:
set_a_to(a);// 错误,a 是左值表达式
函数返回值中当然也可以使用右值引用,其作用和左值引用相似,但是二者的差异涉及重载的知识,在本章后面的部分会介绍相关内容,这里暂且放下。
常量左值引用
常量左值引用是左值引用的一种特殊形式,它不仅可以绑定到左值对象,也可以绑定到右值对象,例如:
const int& ref = 1; // 正确
int a = 1;
const int& ref2 = a; // 正确
类似于右值引用,常量左值引用绑定到右值时,是绑定到右值表达式转换为将亡值表达式时产生的临时对象,例如:
const int& ref = true; // 用 true 初始化一个 int 类型的临时对象,将这个临时对象绑定到 ref
也可以使用 const auto&
来自动推导常量左值引用类型:
const auto& ref = 1; // ref 是 const int& 类型
const auto& ref2 = a; // ref2 也是 const int& 类型
为什么允许常量左值引用绑定到右值?
当我们使用常量左值引用时,我们无法通过这个引用修改绑定的对象。
因此,通过常量左值引用访问一个对象的过程,可以理解为我们只需要这个对象的值,而不需要知道这个对象在哪里占用什么内存。这就和我们使用算术表达式时一样,在计算 1 + 2 + 3
的时候,我们不需要知道 1 + 2
的结果存储在哪里,只需要知道这个结果是 3
就行,足够我们继续计算。
因此,就像在其他地方使用右值一样,我们也应当允许常量左值引用绑定到右值,使我们能够更方便地使用这个值。
藉由常量左值引用,我们可以设计一个接受左值表达式也接受右值表达式的函数:
int a;
int& set_a_to(const int& value) {
a = value;
return a;
}
set_a_to(1); // 1 是右值,但是可以传入
set_a_to(a); // a 是左值,也可以传入
这个 set_a_to
函数当然也可以使用 int
类型作为参数,但当类型更为复杂,构造参数的成本也不得不被考虑时,常量左值引用的作用就会凸显出来。
悬垂引用
当使用引用作为函数返回值时,可能会导致引用绑定的对象生命期结束却仍然可用,这种引用称为悬垂引用。
考虑下面的例子:
int&& max(int&& a, int&& b) {
if (a > b) {
return a;
} else {
return b;
}
}
max(1, 2) = 3;// 错误,试图使用悬垂引用
在这个例子中,max
函数的两个参数绑定到了从字面量 1
和 2
构造的临时对象。这两个临时对象的生命期在 max
函数返回后结束,但是 ref
仍然绑定到这两个临时对象中的较大者。
这种情况下,ref
就是一个悬垂引用。在第二次调用 max
函数时,需要使用 ref
绑定的对象的值来比较大小,但是这个对象的生命期已经结束,因此这个操作是错误的。
上面的例子用常量左值引用写时,会更难以被发现:
const int& max(const int& a, const int& b) {
if (a > b) {
return a;
} else {
return b;
}
}
const int& ref = max(1, 2); // ref 是悬垂引用,由于不能写入,因此显得很隐晦
const int& ref2 = max(ref, 3); // ![!code error] // 错误,试图使用悬垂引用
没有更好的办法
实际上,上面描述的这个 max
函数问题体现在 C++ 各处。C++ 并不强迫引用的生命期在绑定对象的生命期之内。于是,为了得到上面这个 max
函数又能用在左值又能用在右值的便利,程序员们只能遮住眼睛,在心中反复念诵,“保佑 max
函数结束的时候参数都没死”。然后一不小心写下了字面量,功亏一篑。
因此,代码规范往往要求不要返回引用,或者返回引用时要确保引用绑定的对象的生命期足够长。