2. 右值引用¶
2.1. 缺失的拼图¶
在 C++11 之前,表达式分类为 左值表达式 和 右值表达式 ,
简称 左值 和 右值 。左值 都对应着一个明确的对象;从而也都必然可以通过 &
进行取地址操作。
而 右值 表达式虽然肯定都不能进行取地址操作,但在有些场景下,也会隐含着创建一个 临时对象 的语意。
比如 Foo(10)
,在 C++98 的年代,其语意是:以 10
来构造一个 Foo
类型的临时对象。而这个表达式属于 右值 。
而引用,从 constness 的角度,可以分为: non-const reference 和 const reference 。
因而, constness 和 引用 的 对象类别 组合在一起,一共能产生四种类型的引用:
const lvalue reference
non-const lvalue reference
const rvalue reference
non-const rvalue reference
在 C++11 之前,通过符合 &
和 const
的两种组合,可以覆盖三种场景:
Foo&
non-const lvalue reference
比如:
Foo foo(10); Foo& ref = foo;
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 &
,它可以匹配到三种情况:
non-const lvalue reference
const lvalue reference
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 = foo1
或 foo = foo1
这样的表达式,依然使用 copy
语意的版本。
2.3. 右值引用变量¶
引入了 右值引用 之后,就有一系列的问题需要明确。
首先,在不存在重载的情况下:
左值 是否可以匹配到 右值引用类型参数 ? 比如:
struct non_copyable {
non_copyable(non_copyable&&);
};
答案显然是 NO ,否则,一个左值就会被 move ctor
将其资源偷走,而这很明显不是我们所期望的;
右值 是否可以匹配到 左值引用类型参数 ? 比如:
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
能精确识别临时变量的原因。
重要
对于任何类型为 右值引用 的变量(当然也包括函数参数),只能由 右值 来初始化;
一旦初始化完成, 右值引用 类型的变量,其性质与一个 左值引用 再也没有任何差别。
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)。
除了 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)
的表达式,其类别为 左值 ,而不是 速亡值 。
重要
类型为 右值引用 的变量,只能由 右值 表达式初始化;
右值 包括 纯右值 和 速亡值 ,其中 速亡值 的类型是 右值引用 ;
类型为 右值引用 的变量,是一个 左值 ,因而不能赋值给其它类型为 右值引用 的变量, 当然也不能匹配参数类型为 右值引用 的函数。