通用引用在语法上很容易和右值引用混淆,所以本文介绍了构成通用引用条件。然后,着重介绍类型推导和引用折叠(reference collapsing)是如何演绎出通用引用的。
左值右值都可引用 (1)
熟悉右值引用的话,就会知道它是通过&&
来声明的。可能看见&&
的第一反应就是右值引用。可惜,有些情况下竟然不是。在C++11中的右值引用一篇中,我们看到了std::move
的源代码:
1 | template <typename T> |
这里std::move
的参数t
,就不是右值引用。若是右值引用的话,你怎么能传一个左值给它当参数呢?它的作用可是把左值转换成右值啊。所以,t
必须能匹配左值。另一方面,这样的代码也不会出错:
1 | Foo f("123"); |
也就是说,t
也能够匹配右值。实际上,参数t
就是一个通用引用。通用引用,可以使用左值表达式初始化,也可以使用右值表达式初始化。匹配左值表达式时,它表现的像一个左值引用;匹配右值表达式时,它表现的像一个右值引用。
构成通用引用的条件 (2)
出现&&
的时候,如何判断它是右值引用,还是通用引用呢?通用引用有两个条件:
- 形如
T&&
; - T的类型需要推导;
先看一些例子:
1 | int lvalue = 3; |
这里r3
是右值引用,因为没有类型推导;相反,r1, r2, r4, r5
和p
都是通用引用:
r1
等价于左值引用;r2
等价于右值引用;r4
等价于左值引用;没错,是左值引用,因为r3是左值(虽然它是右值引用类型的变量);r5
等价于左值引用;因为std::vector<int>::operator[]
返回左值引用(左值引用是左值);func(lvalue)
中,p
等价于左值引用;func(10)
中,p
等价于右值引用;
也就是说,形如T&&
的类型中,若T
是已知类型,则是右值引用;若T
需要推导,则是通用引用。这两个条件看起来不难,但实际上还是比看起来要苛刻:
例1:必须形如T&&
1 | int lvalue = 3; |
注:必须是T&&
,const T&&
都不行。当然,T
可以是auto
或者别的类型参数名;
例2:必须存在类型推导
1 | template<typename T> |
虽然是类模板,但没有类型推导,other
的类型就是Bar&&
,所以也不是通用引用。甚至下面的也不是:
1 | template<typename T> |
这里也不存在类型推导:Bar()
和push_back()
不可能脱离它们的class而独立存在,然而,当它们的class产生的时候(也即是,类模板的实例化的时候),类型参数已经确定了。所以,调用Bar()
和push_back()
的时候,不存在类型推导。
而下面这种情况,却是通用引用,因为调用Bar()
和emplace_back()
的时候,需要推导类型:
1 | template<typename T> |
例3:推导的必须是T&&
中的T的类型
1 | template<typename S> |
虽然这里存在类型推导,但推导是std::vector的类型参数,而不是T&&
中的T
(实际上,p的类型是已知的std::vector
),所以,p
也不是通用引用。
通用引用类型的变量 (3)
一个通用引用类型的变量,不管它是通过左值初始化的,还是通过右值初始化的,它本身是一个左值。这类似于**右值引用类型的变量是左值(因为有名字)**,见C++11中的右值引用。
如果它引用的是左值(通过左值初始化的),我们当然想把它当作左值处理;相反,如果它引用的是右值(通过右值初始化的),我们又想保留它右值的特征(例如,能作为移动拷贝的参数)。你拿到一个通用引用类型的左值,它引用的可能是左值,也可能是右值,你不清楚(例如,你在写一个摸板函数,它的参数是通用引用,你当然不知道调用者传进左值还是右值),但你还想保留它原有特征(左值或右值),怎么办呢?std::forward
可以帮你,见C++11中的完美转发。
类型推导和引用折叠 (4)
前面我们看到,通用引用在引用左值时变成左值引用,在引用右值时变成右值引用。这种奇特的效果是如何产生的呢?
- 编译器允许它匹配左值和右值;
- 类型推导和引用折叠(reference collapsing)最终让它变成左值引用或者右值引用;
类型推导规则 (4.1)
首先,通用引用auto&&
和T&&
中的类型推导规则是这样的:
- 若匹配的是左值lv,
auto
或T
被推导成lv的引用类型;例如匹配i
时(int i=3;
),推导结果是int&
; - 若匹配的是右值rv,
auto
或T
被推导成rv的类型;例如匹配3时,推导结果是int
;
引用的引用是非法的 (4.2)
其次,必须说明,引用的引用是非法的,例如:
1 | int f = 3; |
编译错误指出,不能声明int&
或者int&&
的引用(即引用的引用)。
编译过程中产生引用的引用 (4.3)
虽然我们不能在源代码里写引用的引用(如4.2节所示),但是由于第4.1节的类型推导规则(下面例1和例2),模板类型参数实例化(下面例3)和typedef展开(下面例4)等原因,在编译的过程中,可能产生引用的引用这种情况:
例1:
1 | template<typename T> |
根据类型推导规则:
- 对于
func(3)
,T
的推导结果是int
,模板实例化的结果是void func(int&& p)
; - 对于
func(i)
,T
的推导结果是int&
,模板实例化的结果是void func(int& && p)
;
前一种情况,通用引用变成右值引用;后一种情况,模板实例化的过程中产生了引用的引用。
例2:
1 | auto&& r1 = 3; |
根据类型推导规则:
r1
引用的是右值,所以auto
的推导结果是int
,r1
的类型是int&&
;r2
引用的是左值,所以auto
的推导结果是int&
,r2
的类型是int& &&
;
通用引用r1
变成右值引用;r2
的类型推导过程中出现了引用的引用。
例3:
1 | template<typename T> |
使用引用类型(左值引用或右值引用)去实例化Bar:
1 | int i=1; |
这样,模板类实例化过程中也产生引用的引用:
1 | typedef int& & LVRefType; //Bar<int&> |
例4:
1 | void func(Bar<int>::LVRefType&& p); |
因为Bar<int>::LVRefType
,Bar<int&>::LVRefType
和Bar<int&&>::LVRefType
都等价于int&
,所以,typedef展开得到的结果都是:
1 | void func(int& && p); |
即,产生了引用的引用。
如第4.2节所说,引用的引用是非法的。那我们这些例子中都存在引用的引用怎么办呢?好在,这些引用的引用不是存在于源代码中(否则编译失败),而是在编译过程中临时产生的。编译器会立即消除它们,手段就是**引用折叠(reference collapsing)**。
引用折叠规则 (4.4)
左值引用和右值引用可以组合出四种引用的引用:
- 左值引用的左值引用;
- 左值引用的右值引用;
- 右值引用的左值引用;
- 右值引用的右值引用;
折叠(collapsing rules)规则比较简单:
- 右值引用的右值引用折叠成(collapses into)右值引用;
- 其他情况折叠成(collapses into)左值引用;
基于折叠规则,看看上面的四个例子:
- 例1:对于
func(i)
,T的推导结果是int&
,模板实例化过程中产生void func(int& && p)
,最终折叠成void func(int& p)
,也就是说,通用引用p
最终变成左值引用; - 例2:r2引用的是左值,所以
auto
的推导结果是int&
,r2
的类型展开成int& &&
,最终折叠成int&
,也就是说,通用引用r2
最终变成左值引用; - 例3:
int& &
和int&& &
都折叠成int&
,LVRefType最终变成int&
;这么定义是正确的:无论取代T
的是不是引用,是左值引用还是右值引用,LVRefType
最终都是左值引用类型,和它的名字相符; - 例4:
int& && p
被折叠成int& p
;
可见,类型推导和引用折叠在通用引用机制中起着重要作用:编译器允许T&&
匹配左值和右值,然后经过类型推导和(或)引用折叠,T&&
才最终变成左值引用或者右值引用。
std::move的工作原理 (4.5)
回头看std::move
的实现,你发现,它的工作原理不过是类型推导和引用折叠的另一个例子:
1 | template <typename T> |
move左值:
1 | Foo f; |
根据类型推导规则(第4.1节),std::move
的类型参数T
推导为Foo&
,std::move
展开为:
1 | typename remove_reference<Foo&>::type&& move(Foo& && t) noexcept |
经过remove_reference和引用折叠:
1 | Foo&& move(Foo& t) noexcept |
可见:左值经过std::move()变为右值;
move右值:
1 | std::move(Foo("xyz")); |
根据类型推导规则(第4.1节),std::move
的类型参数T
推导为Foo
,std::move
展开为:
1 | typename remove_reference<Foo>::type&& move(Foo&& t) noexcept |
经过remove_reference:
1 | Foo&& move(Foo&& t) noexcept |
可见:右值经过std::move()还是右值;
引用剥除 (4.6)
其实,引用剥除(reference stripping)和引用折叠(reference collapsing)没有一毛钱关系。之所以在这里提它,纯粹是为了消除直观上的混淆。
在C++11中的auto关键字中,我们已经见到过引用剥除,auto类型推导的时候,需要进行引用剥除,模板类型参数推导也需要。
1 | template<typename T> |
- 问题:在
func(r1)
中p
的类型是int&& &&
,然后折叠成int&&
,对吗?答:不对。在推导类型T的时候,r1
的右值引用是被剥除的(int&& r1=3
中的&&
被剥除)。又因为r1
是一个左值(说过很多次了,右值引用类型的变量有名字,因此是左值,见C++11中的右值引用),所以T
推导为int&
。因此,模板实例化为void func(int& && p)
,最终折叠成void func(int& p)
。 - 问题:在
func(r2)
中,p
的类型是int& &&
,然后折叠成int&
,对吗?答:对,但是,这和int& r2=x
中的&
无关,因为它也被剥除了。T
被推导成int&
完全是因为r2
是左值的缘故。
可见,由于引用剥除的缘故,r1
和r2
声明中的&&
和&
起不到任何作用;模板参数类型的推导,完全取决于函数实参是左值还是右值。
小结 (5)
通用引用既能引用左值又能引用右值。实质上,是类型推导和引用折叠(reference collapsing)导致这种现象产生的。我们知道,const类型的左值引用(const T&
)也能够同时引用左值和右值,那通用引用的优势是什么呢?见C++11中的完美转发。