Cpp 进阶笔记——2. 引用折叠、万能引用和完美转发
引用折叠规则
引入了右值引用后,我们必须要处理右值引用所带来的一系列类型推导问题,因为C++不允许“引用的引用”这种类型存在,
对于涉及两个连续出现的引用修饰词的类型推导时,定义了如下的引用折叠规则:
1 | & + & -> & |
简而言之,就是左值引用短路右值引用:只有连续两个右值引用遇到一起,才会推导出右值引用,只要出现左值引用,就会推导出左值引用。这套规则主要在模板类型匹配和auto中使用。
C++希望坚持“引用就是别名”的原则,并且不允许直接定义引用的引用(还是可以间接实现的),这与指针的指针可以任意级嵌套是不同的。虽然左值引用主要就是靠指针实现,但那其实只是编译器选择的一种实现方案,并不是语法直接规定的。
模板函数实验
我们考虑如下五类的模板函数进行实验,对它们的类型推导可谓是各不相同
1 | template <typename T> |
例一 T
对于f1的调用测试如下(注释行是编译错误)
1 | template <typename T> |
这里的实例化结果是显然的,编译错误也是很好理解的:
f1<int &&>(a);报错,因为变量a不能被右值引用绑定;f1<const int &&>(a);报错,因为变量a不能被const版本的右值引用绑定;f1<int &>(3);报错,因为字面值常量3不能被非const的左值引用绑定。
例二 T &
对于f2的调用测试如下(注释行是编译错误)
1 | template <typename T> |
这里的实例化结果体现了引用折叠规则,f3的参数类型总是左值引用。
几个错误也是很好理解的:只有const引用才能绑定到字面量常量,其它的左值引用都不可以。
例三 const T &
对于f3的调用测试如下(注释行是编译错误)
1 | template <typename T> |
这里的实例化结果中,大部分比较显然,但是有几个地方比较奇怪:
const (int &) &会被推导为int &,const (int &&) &会被推导为int &,也就是说在引用折叠的同时把const丢失了;const (const int &) &会被推导为const int &,const (const int &&) &会被推导为const int &,只有内层具有const修饰的情况下才能保留const。
关于这个问题似乎并没有明确的官方解释,可以参考Stackoverflow上的提问:提问之一,提问之二。
这个问题似乎不属于未定义行为,因为各个编译器得到的实例化结果都是一样的,不过它们提供的函数签名不太一样,有的函数签名中仍然含有const。
例四 T &&
对于f4的调用测试如下(注释行是编译错误)
1 | template <typename T> |
这里的实例化结果解释如下:
f4(a);会自动传入T=int &而非T=int!(因为int &&不能绑定到变量),根据引用折叠规则,参数类型为int &,下面几个同理;f4(3);会自动传入T=int,参数类型为int &&;f4<const int &>(3)根据引用折叠规则,参数类型为const int &;f4<int &&>(3)根据引用折叠规则,参数类型还是右值引用类型int &&;f4<const int &&>(3)根据引用折叠规则,参数类型还是右值引用类型const int &&。
几个错误的解释如下:
f4<int>(a);报错,因为变量不能被右值引用绑定;f4<int &&>(a);和f4<const int &&>(a);报错,也是因为引用折叠规则得到的还是右值引用类型,不能绑定到变量;f4<int &>(3);报错,因为引用折叠规则得到的是左值引用,不能绑定到字面值常量。
例五 const T &&
对于f5的调用测试如下(注释行是编译错误)
1 | template <typename T> |
这里的实例化结果和f3一样,也存在const丢失的问题
const (int &) &&会被推导为int &,const (int &&) &&会被推导为int &&,也就是说在引用折叠的同时把const丢失了;const (const int &) &&会被推导为const int &&,const (const int &&) &&会被推导为const int &&,只有内层具有const修饰的情况下才能保留const。
几个错误的解释如下:
f5(a);错误,因为会自动传入T=int,得到const版本的右值引用,无法绑定变量;f4<int>(a);错误,原因同上;f5<int &&>(a);和f5<const int &&>错误,因为类型推导的结果必然是某种右值引用,无法绑定变量;f5<int &>;错误,因为类型推导的结果必然是某种左值引用,无法绑定字面值。
小结
模板参数化的实验表明,类型推导中的引用折叠更具体的规则如下。(这里X不含引用和const修饰)
简单情况下,左值引用短路右值引用
1 | (X &) & -> X & |
在涉及到const修饰时,外层的const总是会被丢弃,而内层的const则会被保留
1 | const (X &) & -> X & |
万能引用
将f2和f4进行对比
1 | template <typename T> |
f2表达的含义为:参数类型必然是左值引用,而f3表达的含义为:参数类型是一个引用类型,可能是左值引用,也可能是右值引用,这取决于显式提供的T或传入参数的类型。
如果我们不显示提供T,那么f4其实可以接收任何类型的参数!这也被称为万能引用(Universal Reference)。
auto& 和 auto&&
auto &和auto &&采用了和模板类型推导基本一致的规则。
auto &总是尝试使用左值引用进行绑定,例如
1 | //auto & r1 = 5; // compile error 左值引用不能绑定字面值常量(只能作为右值) |
auto &&在语法上则更加灵活:遇到左值时会推导出左值引用,遇到右值时才会推导出右值引用,例如
1 | auto && r1 = 5; // 绑定常量是右值,推导出int && |
完美转发
假设我们的func函数有接收左值和接收右值的两个版本
1 | template <typename T> |
这里虽然T &&是万能引用,但是我们还提供了T &的版本,对于左值来说,后者的匹配优先级更高。
直接使用例如
1 | int a; |
但是如果是间接使用呢?如果我们也分别提供两个版本
1 | template <typename T> |
测试结果如下
1 | int a; |
可以发现:在两个版本的func2内部,t始终都是一个左值(无论它是左值引用还是右值引用),因此只会调用左值版本的func。
我们可以在右值版本的间接调用函数中使用std::move,迫使它调用右值版本的func
1 | template <typename T> |
此时的使用结果就满足我们的期望
1 | int a; |
明确一下我们的需求:在参数传递过程中,我们希望的是保持其自身的左右性继续传递下去。
我们可以利用模板编程提供如下的转发函数
1 | template <typename T> |
此时我们可以更简洁地实现间接调用函数
1 | template <typename T> |
C++标准库为我们提供了一个细节更完整的转发函数std::forward,被称为“完美转发”,
意义就是传递引用时可以保持左右性不变,下面是MSVC的源码
1 | template <class T> |
我们也可以利用std::forward简洁地实现间接调用函数
1 | template <typename T> |
注意std::forward需要显式提供类型参数T,否则自动进行的类型推导很可能是不对的。
std::forward只是编译期的处理,不会在运行期引入任何额外成本。
