引用折叠规则

引入了右值引用后,我们必须要处理右值引用所带来的一系列类型推导问题,因为C++不允许“引用的引用”这种类型存在, 对于涉及两个连续出现的引用修饰词的类型推导时,定义了如下的引用折叠规则:

1
2
3
4
& + & -> &
& + && -> &
&& + & -> &
&& + && -> &&

简而言之,就是左值引用短路右值引用:只有连续两个右值引用遇到一起,才会推导出右值引用,只要出现左值引用,就会推导出左值引用。这套规则主要在模板类型匹配和auto中使用。

C++希望坚持“引用就是别名”的原则,并且不允许直接定义引用的引用(还是可以间接实现的),这与指针的指针可以任意级嵌套是不同的。虽然左值引用主要就是靠指针实现,但那其实只是编译器选择的一种实现方案,并不是语法直接规定的。

模板函数实验

我们考虑如下五类的模板函数进行实验,对它们的类型推导可谓是各不相同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <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
19
template <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
19
template <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
19
template <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
19
template <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
19
template <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
14
const (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 &&

万能引用

f2f4进行对比

1
2
3
4
5
template <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
13
auto && 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
9
template <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
4
int 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
9
template <typename T>
void func2(T &t) {
func(t);
}

template <typename T>
void func2(T &&t) {
func(t);
}

测试结果如下

1
2
3
4
int 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
9
template <typename T>
void func3(T &t) {
func(t);
}

template <typename T>
void func3(T &&t) {
func(std::move(t));
}

此时的使用结果就满足我们的期望

1
2
3
4
int 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
9
template <typename T>
T &forward(T &t) {
return t; // 如果传左值,那么直接传出
}

template <typename T>
T &&forward(T &&t) {
return std::move(t); // 如果传右值,那么对右值引用调用std::move
}

此时我们可以更简洁地实现间接调用函数

1
2
3
4
template <typename T>
void func4(T &&t) {
func(forward(t));
}

C++标准库为我们提供了一个细节更完整的转发函数std::forward,被称为“完美转发”, 意义就是传递引用时可以保持左右性不变,下面是MSVC的源码

1
2
3
4
5
6
7
8
9
10
template <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
4
template <typename T>
void func5(T &&t) {
func(std::forward<T>(t));
}

注意std::forward需要显式提供类型参数T,否则自动进行的类型推导很可能是不对的。

std::forward只是编译期的处理,不会在运行期引入任何额外成本。