1. 引用

引用是 C 语言所没有的概念。而这个概念,比它表面看起来要复杂一些。

1.1. 值与对象

为了理解引用,我们需要首先搞清楚什么叫 左值右值

简而言之,左值 是一种 对象 ,而不是 。其关键区别在于,是否明确在内存中有其可访问的位置。 即,其是否存在一个可访问的地址。如果有,那么它就是一个 对象 ,也就是一个 左值 ,否则,它就只是 一个 ,即 右值

比如:你不可能对整数 10 取地址,因而这个表达式是一个 右值 。但是如果你定义了一个变量:

int a = 10;

变量 a 则代表一个 对象 ,即 左值 。如果我们再进一步,表达式 a + 1 则是一个右值表达式,因为你无法对这个表达式取地址。

任何可以取地址的表达式,背后都必然存在一个 对象 ,因而也必然属于 左值 。而如果我们把对象地址看作其 身份证 ( Identifier ), 那么我们也可以换句话说:任何有 身份证 的表达式,都属于 左值 ;否则,肯定属于 右值

1.2. 别名

引用对象别名

所谓 别名 ,是指你没有创建任何 新事物 ,而只是对 已存在事物 赋予了另外一个名字。比如:

using Int = int;

你并没有创建一个叫做 Int 的新类型,而只是对已存在类型 int 赋予了另外一个名字。再比如:

template <typename T>
using ArrayType = Array<T, 10>;

你并没有创建一个签名为 ArrayType<T> 的新模版,而只是对已存在模版 Array<T,N> 进行部分实例化后得到的模版,赋予了一个新名字。

因而,引用 作为 对象别名 ,并没有创建任何 新对象 (包括引用自身),而仅仅是给已存在对象赋予了一个新名字。

1.3. 空间

正是因为其 别名 语义, C++ 没有规定 引用 的尺寸(事实上,从 别名 语义的角度,它本身不需要内存,因而也就没有尺寸而言)。

因而,如果你试图通过 sizeof 去获取一个 引用 的大小,是不可能的。你只能得到它所引用的对象的大小(由于别名语义)。

struct Foo {
  std::size_t a;
  std::size_t b;
};

Foo foo;
Foo& ref = foo;

static_assert(sizeof(ref) == sizeof(Foo));

也正是由于其 别名语义 ,当你试图对一个引用取地址时,你得到的是对象的地址。比如,在上面的例子中, &ref&foo 得到的结果是一样的。

因而,当你定义一个指针时,指针自身就是一个 对象 (左值);它本身有自己明确的存储,并可以取自己的地址,可以通过 sizeof 获取自己的尺寸。

但是 引用 ,本身不是一个像指针那样的额外对象,而是一个对象的别名, 你对引用进行的任何操作,都是其所绑定对象的操作

在上面的例子中,reffoo 没有任何差别,都是对象的一个名字而已。它们本身都代表一个对象,都是一个左值表达式。

因而,在不必要时,编译器完全不需要为引用分配任何内存。

但是,当你需要在一个数据结构中保存一个引用,或者需要传递一个引用时,你事实上是在存储或传递对象的 身份 (即地址)。

虽然这并不意味着 sizeof(T&) 就是引用的大小(从语义上,引用自身非对象,因而无大小,sizeof(T&) == sizeof(T) ),但对象的地址的确 需要对应的空间来存储。

struct Bar {
   Foo& foo;
};

// still, reference keeps its semantics.
static_assert(sizeof(Bar::foo) == sizeof(Foo));

// but its storage size is identical to a pointer
static_assert(sizeof(Bar) == sizeof(void*));

// interesting!!!
static_assert(sizeof(Bar) < sizeof(Bar::foo));

1.4. 受限的指针

在传递或需要存储时,一个引用的事实空间开销与指针无异。因而,在这些场景下,它经常被看作一个受限的指针:

  1. 一个引用必须初始化。这是因为其 对象别名 语义,因而没有 绑定 到任何对象的引用,从语义上就不成立。

  2. 由于必须通过初始化将引用绑定到某一个对象,因而从语义上,不存在 空引用 的概念。这样的语义,对于我们的接口设计,有着很好的帮助: 如果一个参数,从约束上就不可能是空,那么就不要使用指针,而使用引用。这不仅可以让被调用方避免不必要的空指针判断;更重要的是准确的约束表达。

    不过,需要特别注意的是:虽然 空引用 从概念上是不存在的,但从事实上是可构造的。比如: T& ref = *(T*)nullptr

    因而,在项目中,任何时候,需要从指针转为引用时,都需要确保指针的非空性。

    另外,空引用 本身这个概念就是不符合语义的,因为引用只是一个对象的别名。上面的表达式,事实上站在对象角度同样可以构造: T obj = *(T*)nullptr 。 正如我们将指针所指向的对象赋值(或者初始化)给另一个对象一样,我们都必须确保指针的非空性。

  3. 像所有的左值一样,引用可以绑定到一个抽象类型,或者不完备类型(而右值是不可能的)。从这一点上,指针和引用具有相同的性质。因而,在传递参数时,决定 使用指针,还是引用,仅仅受是否允许为空的设计约束。

  4. 一个引用不可能从一个对象,绑定到 另外 一个对象。原因很简单,依然由于其 对象别名 语义。它本身就代表它所绑定的对象,重新绑定另外一个对象,从概念上不通。

    而引用的 不可更换性 ,导致任何存在引用类型非静态成员的对象,都不可能直接实现 拷贝/移动赋值 函数。 因而,标准库中,需要存储数据的,比如 容器tuple , pair , optional 等等结构,都不允许 存储 引用

    这就会导致,当一个对象需要选择是通过 指针 还是 引用 来作为数据成员时,除了 非空性 之外,相对于参数传递,还多了一个约束: 可修改性 。 而这两个约束并不必然是一致的,甚至可以是冲突的。

    比如,一个类的设计约束是,它必须引用另外一个对象(非空性),但是随后可以修改为引用另外一个对象。这种情况下, 使用指针就是唯一的选择。但代价是,必须通过其它手段来保证 非空性 约束。

1.5. 左值

任何一个引用类型的 变量 ,都必然是其所绑定 对象别名 ,因而都必然是 左值 。无论这个引用类型是 左值引用 , 还是 右值引用 。关于这个话题,我们会在后续章节继续讨论。

重要

  1. 引用是对象的别名,对于引用的一切操作都是对对象的操作;

  2. 引用自身从概念上没有大小(或者就是对象的大小);但引用在传递或需要存储时,其传递或存储的大小为地址的大小。

  3. 引用必须初始化;

  4. 引用不可能重新绑定;

  5. 将指针所指向的对象绑定到一个引用时,需要确保指针非空。

  6. 任何引用类型的变量,都是左值。