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