理解完美转发

💡 理解移动语义后,趁热打铁,继续理解完美转发。

真正理解引用这篇 blog 中,我们在补充内容中简单地提到了转发引用和引用折叠的概念。
实际上,所谓的完美转发(Perfect Forwarding)就是基于转发引用(Forwarding references)、引用折叠(Reference collapsing)和类型推导(这不是这篇 blog 的重点)实现的。
那么,我们先回忆一下转发引用和引用折叠。

转发引用

转发引用(Forwarding references)是一种特殊的引用,它保留了函数实参的值类别,从而使用std::forward转发它。注意这句话中的隐藏信息:

  1. 转发引用与右值引用的地位是一样的,是一种特殊的引用。
  2. 它保留了函数实参的值类别,注意是函数实参。
  3. 我们还需要使用std::forward来转发它。

按照这些信息,我们可以完善一下在之前文章中给出的示例:

1
2
3
4
5
6
7
8
// 这里必须是函数模板,T 必须是函数模板实参
// 如果是类模板,则不存在这样的情况
// 因为类模板的参数在一开始定义的时候就被使用者指定了比如:std::vector<int>
template<typename T>
void func(T&& param) { // 此时 param 是一个转发引用,转发引用的写法:T&&
//...
return gfunc(std::forward<T>(x));
}

或者:

1
2
3
4
5
6
7
8
// 这是 C++14 的新特性
auto lambda = [](auto&& param) {
// ...
}
// 这是 C++20 的新特性
void func(auto&& param) {
// ...
}

引用折叠

我们在之前的 blog 中简单的说过,引用折叠(Reference collapsing)是为了解决引用的引用是什么而存在的,并举了一个简单的例子:

1
2
using T = int&;
T&& t; // 等价于 int& && t,进而等价于 int& t

其实,这个例子只解释了一半。实际上,引用折叠要解决的问题有两个:

  1. typedefusing自定义的类型形成的引用的引用,比如上面的例子。
  2. 模板参数自动推导出来的类型形成的引用的引用,比如转发引用中的模板例子中的参数T,可能推导出&,也可能推导出&&

当然了这是问题的本来的面貌,对此 C++11 的解决方案,就是我们前面提到的表格,也一并抄过来:

模板类型 T 的类型 最终类型
T& R R&
T& R& R&
T& R&& R&
T&& R R&&
T&& R& R&
T&& R&& R&&

在回顾完之前的内容后,我们再开始看完美转发。🧐

为什么需要完美转发?

还是一样,我们需要先思考一下为什么需要完美转发。其实,答案就是为了将函数模板的模板实参以其原本的值类别转发出去
下面我们举一个简单的例子,逐步分析。一开始,我们的需求是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>

void func(int& i) {
std::cout << "lvalue" << std::endl;
}

void func(int&& i) {
std::cout << "rvalue" << std::endl;
}

void dofunc(int& i) {
func(i);
}

void dofunc(int&& i) {
func(i);
}

int main() {
int i = 1;
dofunc(i);
dofunc(10);
return 0;
}
/*
lvalue
lvalue
*/

在上述代码中,我们有一个函数func和它的重载版本,不同之处在于一个是以左值引用为参数,另一个是以右值引用为参数,期望输出传入值的值类别。然后,我们用类似的方法得到了两个相似的函数且也构成重载的同名函数dofunc,作用是来调用func

使用模板简化

可是,上述代码的输出结果并不符合我们的预期,而且写了很多不必要的重复代码,我们可以先考虑使用模板来简化,并结合我们上面提到的转发引用,就可以得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

void func(int& i) {
std::cout << "lvalue" << std::endl;
}

void func(int&& i) {
std::cout << "rvalue" << std::endl;
}

template<typename T>
void dofunc(T&& i) {
func(i);
}

int main() {
int i = 1;
dofunc(i);
dofunc(10);
return 0;
}
/*
lvalue
lvalue
*/

在上述代码中,我们把dofunc函数变为了一个函数模板,这样即便有新的func函数,我们也不需要改动dofunc。可最关键的问题——输出结果不合预期,仍然没有解决。

问题产生的原因

观察输出结果,我们发现,不管传入的是左值i还是纯右值10,最终输出的结果都是lvalue。这意味着,我们最终只调用了void func(int& i)这个函数。可我们明明给函数传递的值类别是正确的啊?为什么不会调用呢?
实际上,我们唯一能确定的就是值类别一定发生了改变,且都成了左值,所以都调用的第一个func函数。
现在我们来分析究竟是哪里发生了改变,首先我们知道,这里是一个函数模板,模板会自动推导参数的类型。在上面的例子中:

1
2
dofunc(i); // 会把 T 推导为 int&
dofunc(10); // 会把 T 推导为 int

关于模板参数类型自动推导的话题,这里不做深究,我们只需要知道编译器在做类型推导时,会将绑定左值的T推导为左值引用类型,将绑定右值的T推导为原基本类型。那么,最终我们的函数模板中i的类型就是:

1
2
dofunc(i); // T = int&, i = int& &&
dofunc(10); // T = int, i = int &&

再按照我们上面提到的引用折叠的规则,继续推导可得:

1
2
dofunc(i); // T = int& => i = int& && = int&
dofunc(10); // T = int => i = int && = int&&

看着好像很完美,最终一个类型是左值引用,一个是右值引用,按理说应该是可以输出符合预期的结果的。为什么还是有问题呢?再回顾前面讨论左值与右值的内容,我们知道由右值引用变量组成的表达式也是左值表达式,所以这里我们传入的值的值类别其实还是左值。
现在,问题我们已经清楚了,如何解决呢?

解决问题

答案很简单,就是使用std::forward,正如我们在转发引用中举的例子那样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

void func(int& i) {
std::cout << "lvalue" << std::endl;
}

void func(int&& i) {
std::cout << "rvalue" << std::endl;
}

template<typename T>
void dofunc(T&& i) {
func(std::forward<T>(i));
}

int main() {
int i = 1;
dofunc(i);
dofunc(10);
return 0;
}
/*
lvalue
rvalue
*/

现在,我们就可以得到符合预期的结果了。🎉
而且,回过头来思考,我们那个没有使用模板的例子,其实也是因为值类别不匹配导致的错误结果,我们也可以做一些简单的修改,从而得到正确的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

#include <iostream>

void func(int& i) {
std::cout << "lvalue" << std::endl;
}

void func(int&& i) {
std::cout << "rvalue" << std::endl;
}

void dofunc(int& i) {
func(i);
}

void dofunc(int&& i) {
// 这里是因为右值引用变量组成的表达式也是左值表达式
// 所以最开始还是调用的 func(int&)
// func(i);
// 下面两种方法都可以得到需要的结果
func(std::move(i));
func(std::forward<int&&>(i));
}

int main() {
int i = 1;
dofunc(i);
dofunc(10);
return 0;
}
/*
lvalue
rvalue
rvalue
*/

在上述代码中,我们分别std::movestd::forward得到了预期结果,这两种方式都可以。同时,这两个函数的用法,目前我们也都有了一定的认识,就不再赘述了。
到此,我们的问题就算是完全解决了。😴

总结

现在再来总结一下所有的内容:

  1. 我们明白了完美转发至少由三部分构成:转发引用、引用折叠和std::forward
  2. 我们明白了转发引用、引用折叠的由来和用途,知道了这俩究竟是什么。
  3. 我们明白了完美转发的用途(或者说意义),那就是将函数模板的模板实参以其原本的值类别转发出去。但具体应用场景还不够熟悉,不过至少模板元编程中一定用的比较多。

好了,到此为止,这篇 blog 到这里就结束了,理论已经分析完毕,剩下的就是实践了。


Buy me a coffee ? :)
0%