2. 右值引用

2.1. 缺失的拼图

C++11 之前,表达式分类为 左值表达式右值表达式 , 简称 左值右值左值 都对应着一个明确的对象;从而也都必然可以通过 & 进行取地址操作。 而 右值 表达式虽然肯定都不能进行取地址操作,但在有些场景下,也会隐含着创建一个 临时对象 的语意。

比如 Foo(10) ,在 C++98 的年代,其语意是:以 10 来构造一个 Foo 类型的临时对象。而这个表达式属于 右值

而引用,从 constness 的角度,可以分为: non-const referenceconst reference

因而, constness引用对象类别 组合在一起,一共能产生四种类型的引用:

  1. const lvalue reference

  2. non-const lvalue reference

  3. const rvalue reference

  4. non-const rvalue reference

C++11 之前,通过符合 &const 的两种组合,可以覆盖三种场景:

  1. Foo&

  • non-const lvalue reference

    比如: Foo foo(10); Foo& ref = foo;

  1. const Foo&

    • const lvalue reference

      比如: Foo foo(10); const Foo& ref = foo;

    • const rvalue reference

      比如: const Foo& ref = Foo(10);

但对于 non-const rvalue reference 无法表达。

好在那时候并没有 move 语意的支持,因而对于 non-const rvalue reference 的需求也并不强烈。

2.2. move 语意

C++11 之前,只有 copy 语意,这对于极度关注性能的语言而言是一个重大的缺失。那时候程序员为了避免性能损失, 只好采取规避的方式。比如:

std::string str = s1;
s += s2;

这种写法就可以规避不必要的拷贝。而更加直观的写法:

std::string str = s1 + s2;

则必须忍受一个 s1 + s2 所导致的中间 临时对象str 的拷贝开销。 即便那个中间临时对象随着表达式的结束,会被销毁(更糟的是,销毁所伴随的资源释放,也是一种性能开销)。

对于 move 语意的急迫需求,到了 C++11 终于被引入。其直接的驱动力很简单:在构造或者赋值时, 如果等号右侧是一个中间临时对象,应直接将其占用的资源直接 move 过来(对方就没有了)。

但问题是,如何让一个构造函数,或者赋值操作重载函数能够识别出来这是一个临时变量?

C++11 之前,拷贝构造和赋值重载的原型如下:

struct Foo {
   Foo(const Foo&);
   Foo& operator=(const Foo&);
};

参数类型都是 const & ,它可以匹配到三种情况:

  1. non-const lvalue reference

  2. const lvalue reference

  3. const rvalue reference

对于 non-const rvalue reference 是无能为力的。 另外,即便是能捕捉 const rvalue reference , 比如: foo = Foo(10); ,但其 const 修饰也保证了其资源不可能被 move 走。

因而,能够被 move 走资源的,恰恰是之前缺失的那种引用类型: non-const rvalue reference

这时候,就需要有一种表示法,明确识别出那是一种 non-const rvalue reference ,最后定下来的表示法是 T&& 。 这样,就可以这样来定义不同方式的构造和赋值操作:

struct Foo {
   Foo(const Foo&);   // copy ctor
   Foo(Foo&&);        // move ctor

   Foo& operator=(const Foo&); // copy assignment
   Foo& operator=(Foo&&);      // move assignment
};

通过这样的方式,让 Foo foo = Foo(10)foo = Foo(10) 这样的表达式,都可以匹配到 move 语意的版本。 与此同时,让 Foo foo = foo1foo = foo1 这样的表达式,依然使用 copy 语意的版本。

2.3. 右值引用变量

引入了 右值引用 之后,就有一系列的问题需要明确。

首先,在不存在重载的情况下:

  1. 左值 是否可以匹配到 右值引用类型参数 ? 比如:

struct non_copyable {
   non_copyable(non_copyable&&);
};

答案显然是 NO ,否则,一个左值就会被 move ctor 将其资源偷走,而这很明显不是我们所期望的;

  1. 右值 是否可以匹配到 左值引用类型参数 ? 比如:

struct non_movable {
   non_movable(const non_movable&);
};

struct non_movable2 {
   non_movable2(non_movable&);
};

答案是看情况。

  • 至少在 C++11 之前, 一个右值,就可以被类型为 const T& 类型的参数匹配;

  • 但一个右值,不能被 T& 类型的参数匹配;毕竟这种可以修改的承诺。 而修改一个调用后即消失的 临时对象 上,没有任何意义,反而会导致程序员犯下潜在的错误,因而还是禁止了最好。

这就遗留下来一种情况:

3. 一个 non-const rvalue reference 类型的变量,是否允许匹配 non-const lvalue reference 类型 的参数?

比如:

void f(Foo& foo) { foo.a *= 10; }

Foo&& ref = Foo{10};

f(ref); // 是否允许

int b = ref.a + 10;

没有任何理由不允许这样的匹配。毕竟,自从变量 ref 被初始化后,其性质上和 左值引用 一样,都是引用了一个已经存在的对象。 例子中,经过 f(ref)ref 所引用的对象内容进行修改之后,还会基于其内容进行进一步的处理。这都是非常合理的需求。 并且,ref 所引用的对象的生命周期,和 ref 一样长,不用担心在使用 ref 期间,对象已经不存在的问题。

这就导致了一个看起来很矛盾的现象:

void f(Foo& foo) { foo.a *= 10; }

Foo&& ref = Foo{10};
f(ref);     // OK

f(Foo{10}); // 不允许

先将一个 临时对象 初始化给一个 右值引用 ,再传递给函数 f , 与直接构造一个 临时对象 传递给 f ,一个是允许的,一个是禁止的。

这背后的差异究竟意味这什么?

一个类型为 右值引用 的变量,一旦被初始化之后,临时对象的生命将被扩展,会在其被创建的 scope 内始终有效。 因而,Foo&& foo = Foo{10},从语意上相当于:

{
   Foo __temp_obj{10};
   Foo& ref = __temp_obj;

   // 各种对ref的操作
}
// 离开scope, __temp_obj被销毁

因而,看似 foo 被定义的类型为 右值引用 ,但这 仅仅约束它的初始化 :只能从一个 右值 进行初始化。 但一旦初始化完成,它就和一个 左值引用 再也没有任何差别:都是一个已存在对象的 标识

函数参数也没有任何特别之处,它就是一个普通的变量。无非是其可访问范围被限定在函数内部。调用一个函数时,传递实参的过程, 就是一个对参数(变量)进行初始化的过程,而初始化的细节与一个普通变量没有任何差别。

void stupid(Foo&& foo) {
   foo.a += 10;   // 在函数体内,foo的性质与一个左值引用毫无差别
   // blah ...
}

stupid(Foo{10});  // 在执行函数体之前,进行参数初始化: Foo&& foo = Foo{10}

而临时对象 Foo{10} 的生命周期,会比参数变量 foo 更长。所以将 foo 看作 左值引用 随意访问,是没有任何风险的。

所以,任何一个类型为 右值引用 的变量,一旦初始化完成,性质上就变成和一个 左值引用 毫无差别。这样的语意,对于程序员的使用是最为合理的。

我们再看下面的例子:

std::string&& ref = std::string("abc");

std::string obj = ref; // move? 还是 copy?

std::string s = ref + "cde"; // 是否可以接着假设ref所引用的对象是合法的?

既然在完成初始化之后,一个 右值引用类型 的变量,就变成了 左值引用 ,按照这个语意, 当然就只能选择 copy 构造。这样的选择,也让后面对于 ref 的继续使用是安全合理的, 这其实也在帮助程序员编写安全的代码。

毕竟,只有在调用 move constructor 那一刻,传入的是真正的临时变量,也就是说 move constructor 调用结束后, 临时变量也就不再存在,无从访问的情况下,自动选择 move constructor 才是确定安全的。

经过之前讨论,我们知道这样的设计决策是最合理的,但矛盾和张力依然存在:毕竟,变量 ref 的类型是 右值引用 , 而 move constructor 的参数类型也是 右值引用 ,为什么它们不是最匹配的,反而是匹配了 copy constructor ? 另外, move constructor 自动匹配真正的临时对象,毫无疑问是合理的(也是我们的初衷), 但我们如何区分一个临时对象和一个类型为 右值引用 的变量?

这个并不难。因为 C++ 早就规定了,产生临时变量的表达式是 右值 ,而任何变量都是一个对象的标识,因而都是 左值 , 哪怕变量类型是 右值引用

因而,右值 选择 move constructor左值 选择 copy constructor

更准确的说,所谓选择 move constructor ,其实是因为 右值 匹配的是 move constructor 参数, 其类型是一个 右值引用 。我们知道,函数参数也是变量,而一个类型为 右值引用 的变量,只能由 右值 来初始化:

Foo   foo{10};
Foo&& ref = foo; // 不合法,右值引用只能由右值初始化

Foo&& ref1 = Foo{10};
Foo&& ref2 = ref1; // 不合法,ref1是个左值

因而,做为类型为 右值引用 的函数参数,唯一能匹配的就是 右值 。这也是 move constructor 能精确识别临时变量的原因。

重要

  1. 对于任何类型为 右值引用 的变量(当然也包括函数参数),只能由 右值 来初始化;

  2. 一旦初始化完成, 右值引用 类型的变量,其性质与一个 左值引用 再也没有任何差别。

2.4. 速亡值

我们现在已经明确了,只有右值临时对象可以初始化右值引用变量,从而也只有右值临时变量能够匹配参数类型为 右值引用 的函数, 包括 move 构造函数。

这中间依然有一个重要的缺口:如果程序员就是想把一个左值 move 给另外一个对象,该怎么办?

最简单的选择是通过 static_cast 进行类型转换:

Foo   foo{10};
Foo&& ref = Foo{10};

Foo obj1 = static_cast<Foo&&>(foo); // move 构造
Foo obj2 = static_cast<Foo&&>(ref); // move 构造

我们之前说过,只有 右值 ,才可以用来初始化一个 右值引用 类型的变量,因而也只有 右值 才能匹配 move 构造。 所以, static_cast<Foo&&>(foo) 表达式,肯定是一个 右值

但同时,它返回的类型又非常明确的是一个 引用 ,而这一点又不符合 右值 的定义。因为,所有的右值,都必须是一个 具体类型 , 不能是不完备类型,也不能是抽象类型,但 引用 ,无论左值引用,还是右值引用,都可以是不完备类型的引用或抽象类型的引用。 这是 左值 才有的特征。

对于这种既有左值特征,又和右值临时对象一样,可以用来初始化右值引用类型的变量的表达式,只能将其归为新的类别。C++11 给这个新类别 命名为 速亡值 (eXpiring value,简称 xvalue)。 而将原来的 右值 ,重新命名为 纯右值 。 而 速亡值纯右值 合在一起,称为 右值 ,其代表的含义是,所有可以直接用来初始化 右值引用类型变量 的表达式。

同时,由于 速亡值 又具备左值特征:可以是不完备类型,可以是抽象类型,可以进行运行时多态。所以,速亡值 又和 左值 一起被归类为 泛左值 (generalized lvalue, 简称glvalue)。

_images/value-category-2.png _images/value-category.png

除了 static_cast<T&&>(expr) 这样的表达式之外,任何返回值为 右值引用 类型的函数调用表达式也属于 速亡值 。 从而让用户可以实现任意复杂的逻辑,然后通过返回值为 右值引用 的方式,直接初始化一个右值引用类型的变量。 以此来达到匹配 move 构造, move 赋值函数,以及任何其它参数类型为 右值引用 的函数的目的。

C++ 标准对其的定义为:

xvalue:

an xvalue (an “eXpiring” value) is a glvalue that denotes an object or bit-field whose resources can be reused.

意思就是,这类表达式表明了自己可以被赋值给一个类型为 右值引用 的变量,当然自然也就可以被 move 构造和 move 赋值操作 自然匹配,从而返回的引用所引用的对象可以通过 move 而被重用。

所以,速亡值未必真的会速亡(expiring),它只是能用来初始化右值引用类型的变量而已。只有用到 move 场景下,它才会真的导致所引用对象的失效。

最后,速亡表达式存在着一个异常场景,那就是函数类型的右值引用。因为函数地址被 move 本身毫无意义。所以,对于返回值为 函数类型右值引用 的函数调用, 或者 static_cast<FunctionType&&>(expr) 的表达式,其类别为 左值 ,而不是 速亡值

重要

  • 类型为 右值引用 的变量,只能由 右值 表达式初始化;

  • 右值 包括 纯右值速亡值 ,其中 速亡值 的类型是 右值引用

  • 类型为 右值引用 的变量,是一个 左值 ,因而不能赋值给其它类型为 右值引用 的变量, 当然也不能匹配参数类型为 右值引用 的函数。