C++模板从入门到劝退(2)——型别推导、万能引用与完美转发

谈到C++模板,多少业界大佬无不讳莫如深,而基于模板的元编程更是历来被业界同仁视为黑魔法。模板从诞生至今已有几十年的历史,实际上在经历了C++11标准的革命性洗礼以及后续的缝缝补补后,模板元编程的门槛和抽象程度已经大大降低。纵观历史长河,优秀的C++书籍鳞次栉比,然而涉及到元编程的书籍却屈指可数,那些传世经典的圣经对于模板皆是浅尝辄止。本系列文章将致力于揭开模板元编程的迷雾,通过牺牲一定程度的表述严谨性,按照笔者的归纳方式穿针引线,为痴迷于黑魔法而又始终不得其解的同学打通任督二脉。

本篇是走进模板世界的最后一道前菜,通过型别推导、万能引用与完美转发这三个C++11所引入的新机制,我们得以对模板的世界管中窥豹。

型别推导、万能引用与完美转发

上一讲的最后我们提到了一种特殊的引用类型:万能引用(Universal Reference),万能引用虽然长得很像普通的右值引用,但二者却有着本质差别。万能引用是形如T&&的引用类型,当且仅当T的型别需要被直接推导时,万能引用的最终类型判定需要介入引用折叠规则,虽然上一讲我们有提到,但却浅尝辄止。而说到型别推导(Type Deduction),这是一个迷人且拥有非常复杂的判定规则的设定,在传统C++中,它始终和模板一起出现,因此传统的C++开发者即使对模板望而生畏、不了解型别推导也不会在日常开发工作中因此而困扰,而C++11引入了autodecltype关键字,将型别推导的作用放大到开发者的日常中,至此,在现代C++开发中,开发者或多或少都需要亿点点型别推导的知识。

auto这个关键字早就存在(从C语言继承过来的legacy),但是在现代操作系统中这玩意毫无卵用,C++11罕见的废弃了auto原本的语义而重新定义了它,要知道,C++可是个相当保守的老顽固。

函数模板实参推导

回想一下传统C++中的函数模板实参推导(Function Template Argument Deduction),一个函数模板想要实例化出具体的函数需要确定所有的模板实参,而这里的确定一般有两种手法:一种是显式地指定;另一种是让编译器根据上下文自行推断。实际上这两种手法也常常混用,即函数模板实参的确定由二者共同完成。举个例子:

1
2
3
4
5
6
7
8
9
10
// 这里只做了模板声明,省略了定义,这里只是为了做推导演示
// 但我们要知道:函数一旦需要执行的话,那么其定义是必不可少的
template<typename To, typename From>
To convert(From f);

void g(double d) {
int i = convert<int>(d); // 需要实例化出int convect<int, double>(double){...}
char c = convert<char>(d); // 需要实例化出int convert<int, char>(char){...}
int(*ptr)(float) = convert; // 需要实例化出int convert<int, float>(float){...}
}

这就是一个显式指定 + 型别推导的例子,第一行我们仅指定了Toint型,From则根据传入的参数d被编译器推导成double型,它与d的类型一致。第二行与第一行类似,只不过显式指定的To类型是char。第三行则有些不同,它定义了一个型别为int(*)(float)的函数指针变量ptr,使其指向convert函数,这里ToFrom都需要编译器来做推导,根据函数指针类型,分别将To推导成intFrom推导成floatptr实际指向的是函数模板convert实例化出的形如int convert<int, float>(float){...}的函数,而非convert本身,我们知道函数模板本身只是模板,是没有实体对象的。

该例出自https://en.cppreference.com/w/cpp/language/template_argument_deduction,道行够深的同学一定要通读。

如果将上例中的ToFrom顺序颠倒,会发生什么事呢?由于模板参数To并没有出现在函数的参数列表中,故某些情况编译器无法通过上下文来推导出To的类型,我们只好这样来写:

1
2
3
4
5
6
7
8
template<typename From, typename To>
To convert(From f);

void g(double d) {
int i = convert<double, int>(d); // 完全显式指定,编译器不再需要按参数d的类型做推导
char c = convert<double, char>(d); // 完全显式指定,编译器不再需要按参数d的类型做推导
int(*ptr)(float) = convert; // 还是可以完成推导,需要实例化出int convert<int, float>(float){...}
}

顺序的调换导致我们前两行的自动推导失效,从而不得不完全显式地指定,代码也写成了“愚型”。而另一方面,我们也可以看出,对于函数模板来说,大多数情况的模板实参型别推导还是依赖于函数调用的参数列表,这其中出现了的模板参数往往可以进行推导。我们简单总结下,对于函数模板template<typename T>void f(T param);,编译器可以根据我们实际调用时传入的实参类型来进行推导(f(argument);),得出模板参数T的类型和相应的参数列表(T param)。

然而这只是一种情况,也是最简单的情况。实际上,我们需要考虑的维度有三个:

  • 模板参数本身:T
  • 函数参数列表:不一定是T param,很可能对T做了CV限定或是引用/指针修饰,我们记为ParamType
  • 函数调用实参:argument这个表达式的型别可以千奇百怪

T型别的推导实际上是ParamTypeargument的共同作用,从ParamType的视角来看,大抵有着四种情况:

  • ParamType既非指针也非引用
  • ParamType是个指针
  • ParamType是个左值引用
  • ParamType是个万能引用

ParamType既非指针也非引用

这是最简单的情况:按值传递。

1
2
3
4
template<typename T>
void f(T param);

f(argument);

值传递的推导规则非常轻量:编译器在推导时会忽略掉argument的顶层CV限定和引用修饰。结合第一讲中的值语义,这一点其实非常好理解:值传递的形参本质上是实参的副本,所以实参的顶层const,volatile特性形参完全可以不care,拷贝以后就跟数据源头毫无瓜葛了,通俗的理解是”大可以我改我的,反正我又不影响你“。至于引用修饰,那就更没关系了,都值语义了,它只能影响从哪个源头拷贝而已。

此外还要注意,值传递推导所忽略的CV限定只有顶层(top),底层(bottom)CV限定是不能被忽略的,比如:

1
2
const char* const ptr = "Fun with pointers";    // ptr是个指向const对象的const指针
f(ptr); // T被推导成const char*,底层const得以保留,也必须保留

const是限定char还是限定pointer,要看它的位置在*的左边还是右边。

其实这也很好理解,因为值语义拷贝的是个指针,指针本身的const限定可以被擦掉,但是它所指向的类型的const限定是绝对不可以擦掉的。

来看一些用例:

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
#include <iostream>
#include "type_info.h"

using namespace std;

// param类型和T类型始终一致
template<typename T>
void f(T param) {
// 打印T的型别和param的型别
cout << type_name<T>() << '\t' << type_name<decltype(param)>() << endl;
}

int main(){
int x = 27;
const int cx = x;
const int& rx = x;

const int * pcx = &cx;
const int * const cpcx = &cx;

f(x); // 最简单的情景,T被推导为int
f(cx); // 由于const被忽略,T依然被推导为int
f(rx); // 先忽略掉引用、再忽略掉const,T还是被推导成int

f(&x); // T被推导为int*
f(&cx); // T被推导为const int*,底层const特性不能忽略
f(&rx); // 同上

f(pcx); // T被推导为const int*, 底层const特性不能忽略
f(cpcx); // 顶层const被忽略,T被推导为const int*
return 0;
}

运行结果:

1
2
3
4
5
6
7
8
int	int
int int
int int
int * int *
const int * const int *
const int * const int *
const int * const int *
const int * const int *

C++标准库的typeid贼鸡儿难用,由于标准没有规定其name成员函数的输出结果,各大编译器花式整活,输出结果尽是些听不懂的“方言”,另一方面typeid也是运行期输出,没那个味儿。作为一名C++程序员,我们遇到困难,也不要怕,微笑着(bushi)……自己实现一个编译期类型计算的方法(当然,本鶸搬运的是so大神的实现):

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

template <class T>
constexpr
std::string_view
type_name()
{
using namespace std;
#ifdef __clang__
string_view p = __PRETTY_FUNCTION__;
return string_view(p.data() + 34, p.size() - 34 - 1);
#elif defined(__GNUC__)
string_view p = __PRETTY_FUNCTION__;
# if __cplusplus < 201402
return string_view(p.data() + 36, p.size() - 36 - 1);
# else
return string_view(p.data() + 49, p.find(';', 49) - 49);
# endif
#elif defined(_MSC_VER)
string_view p = __FUNCSIG__;
return string_view(p.data() + 84, p.size() - 84 - 7);
#endif
}

现阶段初学者还不需要理解这段代码,实际上他并没有做什么神奇的操作,只是对不同的编译器生成的函数签名中截取了想要的部分字符串而已。总之,现在只需要知道type_name这个函数模板可以输出任何模板参数T的类型。

ParamType是个指针

模板形如:

1
2
template<typename T>
void f(T* param);

此时,形参param的类型已经被限制成必须是某个类型的指针型,指针型本质上也是按值传递(拷贝的是地址值,即指针型变量存储的value),推导规则就尝试将实参argument“适配”到形参上去,形参类型确定了,那么T的类型也就确定了,此时形参和T是不同的。

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
#include <iostream>
#include "type_info.h"

using namespace std;

template<typename T>
void f(T* param) {
// 输出T的类型和形参param的类型
cout << type_name<T>() << '\t' << type_name<decltype(param)>() << endl;
}

int main(){
int x = 27;
int* px = &x;
int*& rpx = px; // 指针的引用,

const int* pcx = &x;
const int* const pcpx = &x; // 当然了,像rpx,pcpx这种东西你日常编程基本遇不到,这里只是为了让例子尽量丰满

f(&x); // T被推导为int, param被推导为int*
f(px); // 同上
f(rpx); // 同上
f(pcx); // T被推导为const int, param被推导为const int*
f(pcpx); // T被推导为const int, param被推导为const int*
return 0;
}

运行结果:

1
2
3
4
5
int	int *
int int *
int int *
const int const int *
const int const int *

实际上,指针型的推导和第一种情况的值推导很相似,引用修饰和顶层const同样会被忽略,只不过T的类型和形参param的类型有所区别罢了。

ParamType是左值引用

模板形如:

1
2
template<typename T>
void f(T& param);

这种情况相当于限定了形参param的类型一定是某个类型的左值引用,这就意味着我们的传参方式是按引用传递(传址),因此一方面实参argument的引用修饰会被忽略(因为不管是不是引用类型,最终推导出的形参都必须是个左值引用),另一方面其CV限定不会也不能被忽略,因为相比于值传递,我们此时传递的对象并不会拷贝一份,因此其const,volatile特性绝对不能忽略或者舍弃。

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
#include <iostream>
#include "type_info.h"

using namespace std;

template<typename T>
void f(T& param) {
cout << type_name<T>() << '\t' << type_name<decltype(param)>() << endl;
}

int main(){
int x = 27;
const int cx = x;
const int& rx = x;

const int * pcx = &cx;
const int * const cpcx = &cx;

f(x); // T被推导为int, param被推导为int&
f(cx); // T被推导为const int, param被推导为const int&
f(rx); // rx本身的reference会被忽略,因此和传递cx没什么两样

//f(&x); // 这三个都不能通过编译,因为param是左值引用必须绑定到左值上
//f(&cx); // 取地址符表达式是prvalue,不能被lvalue reference绑定
//f(&rx);

f(pcx); // T被推导为const int*, param被推导为const int*&
f(cpcx); // T被推导为const int* const, param被推导为const int* const&
return 0;
}

输出结果:

1
2
3
4
5
int	int &
const int const int &
const int const int &
const int * const int *&
const int *const const int *const &

而说到左值引用,就不得不提到C++中的万金油:const左值引用,试想如果我们前置地对param型别增加一个const限定会如何呢?

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
#include <iostream>
#include "type_info.h"

using namespace std;

template<typename T>
void f(const T& param) {
cout << type_name<T>() << '\t' << type_name<decltype(param)>() << endl;
}

int main(){
int x = 27;
const int cx = x;
const int& rx = x;

const int * pcx = &cx;
const int * const cpcx = &cx;

f(x); // T被推导为int, param被推导为const int&
f(cx); // T被推导为int, param被推导为const int&
f(rx); // 同上

f(&x); // const左值引用也可以绑定到右值上,因此编译通过。T被推导为int*,param被推导为const int*&
f(&cx); // T被推导为const int*,param被推导为const int* const &
f(&rx); // 同上

f(pcx); // pcx是左值,T被推导为const int*, param绑定其上,本身追加const限定,因此被推导为const int* const&
f(cpcx); // 同上,不管T本身是否有顶层const, param都会具有顶层const
return 0;
}

输出结果:

1
2
3
4
5
6
7
8
int	const int &
int const int &
int const int &
int * int *const &
const int * const int *const &
const int * const int *const &
const int * const int *const &
const int * const int *const &

这些例子看起来很绕,但实际上,如果你搞懂了基于值语义和引用语义的型别推导原则,它们都是显而易见的。

ParamType是万能引用

这里就和上一讲的结尾接轨了,它是C++11引入右值引用后,自然而然演化出的产物。我将C++标准对万能引用的定义翻译成白话:万能引用是一种特殊的引用类型,它所引用的类型需要被推导,且携带了用于型别推导的实参的值分类信息,使得其可以被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
26
27
28
29
30
31
32
33
34
#include <iostream>
#include "type_info.h"

using namespace std;

template<typename T>
void f(T&& param) { // 此时T&&是个万能引用,不论类型如何,我们知道param一定是引用,所以是按引用传递
cout << type_name<T>() << '\t' << type_name<decltype(param)>() << endl;
}

int main(){
int x = 27;
const int cx = x;
const int& rx = x;

const int * pcx = &cx;
const int * const cpcx = &cx;

f(x); // x是左值,按引用传递就视为左值引用,这和你用x作为实参去调用一个形参为int&的函数没什么区别
// 因此T被推导成int&, param也被推导成int&(此时发生了引用折叠)
f(cx); // cx也是左值,但因为按引用传递,const特性必须保留
// 因此T被推导成const int&, param也被推导为const int&
f(rx); // 同上,是否按引用传递取决于形参有没有reference修饰,这一点跟实参本身是不是reference没关系

f(&x); // &x是右值,只有右值引用才能绑定到右值上,因此param只能被推导成int* &&(int指针型的右值引用)
// 此时T被推导为int*
f(&cx); // 同上
f(&rx); // 同上

f(pcx); // pcx是左值,故T被推导为const int*&,param也一样
f(cpcx); // cpcx是左值,故T被推导为const int* const &, param也一样
// 只不过按引用传递的情况,顶层const必须保留
return 0;
}

运行结果:

1
2
3
4
5
6
7
8
int &	int &
const int & const int &
const int & const int &
int * int *&&
const int * const int *&&
const int * const int *&&
const int *& const int *&
const int *const & const int *const &

可以看到万能引用终归是个引用,不管是左值引用还是右值引用,不管有没有CV限定,最终都得是按引用传递,因此规则上和第三种:ParamType为左值引用时相同,只不过它多了一步根据实参来确定引用类型(同时也解决了传统C++中,只能用const左值引用来按引用传递右值以避免拷贝的局限性设计问题)。

事实上,我们大可以将分类改成两类,即前两种是一类,后两种是一类,分类的口径则是形参的传递方式(值传递还是引用传递)。

不管是标准手册还是广为流传的参考书,它们对型别推导的归纳看上去都非常的复杂(哪怕是用很简单的例子),本质上是因为它们没有从值语义或是引用语义来出发,事实上,只要你拿捏住一件事:推导过程中形参究竟是值传递还是引用传递,那么不管多复杂的case,都有迹可循。

auto与decltype

以函数模板实参推导为例,我们看穿了型别推导背后的机制:值传递和引用传递的差别对待。C++11为了简化历来饱受诟病的又臭又长的语法(诸如std::map<std::string, std::vector<std::string>>::const_iterator),引入了autodecltype这两个关键字。这两位爷都是用于型别推导的,只是推导的规则不同,auto这个关键字的使用在现代C++中要分两个场景来看:其一是最常见的为变量做型别推导;其二是为函数返回类型做推导(C++14之后才发糖支持)。

1
2
3
4
5
6
7
8
9
10
// 第一种场景:变量型别推导
auto a = 1;
const auto& r = a;
auto *p = &a;

// 第二种场景:函数返回类型推导
// 在C++11中,只能写作:
// auto f() -> decltype(666) { return 666; }
// 虽然看起来只是加了个尾部型别推导,但这里的auto只是个占位符(PlaceHolder),与C++14中真正用于推导的auto有本质差别
auto f() { return 666; }

无论哪一种场景,auto所应用的推导规则实际上就是模板实参推导的规则。我们知道,前面在做函数模板实参推导时,需要考量的有3个维度:模板参数T,函数形参类型ParamType和传递的实参argument。如何类比呢?实际上我们可以把auto看做模板参数T,而auto结合CV限定和引用、指针修饰而成的最终变量类型则看做ParamType=右边的表达式看做argument

auto的型别推导与模板实参推导的规则实际上有一点不同:前者会将花括号语法视为std::initializer_list(C++17有所调整,仅限于'='右边),而后者并不会。std::initializer_list这个东西在C++11引入,主要是为了给花括号初始化语法做补丁,然而C++语法的复杂导致这个东西在很多使用场景下显得格格不入,之后的标准演进中也一直在缝缝补补。

我们直接看例子,按照上面描述的置换规则置换一下,再根据在模板实参推导中已掌握的知识,揣摩一下输出结果:

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
#include <iostream>
#include "type_info.h"

using namespace std;

int main(){
auto x = 27; // 值传递,对应case 1,T被推导为int,x的类型也是int
auto& rx = x; // 引用传递,对应case3, T被推导为int, rx的类型是int&
auto&& rrx = 666; // 引用传递,对应case4,auto&&是个万能引用
// 666是prvalue,故T被推导为int,rrx的类型是int&&
auto&& lrx = x; // 引用传递,对应case4,auto&&是个万能引用
// x是lvalue,故T被推导为int&,lrx的类型也是int&

const auto cx = x; // 值传递,T被推导为int, cx的类型是const int
const auto& rcx = x; // 引用传递,T被推导为int, rcx的类型是const int&

auto* px = &x; // 值传递,T被推导为int, px的类型是int*
const auto* pcx = &cx; // 值传递,T被推导为int, pcx的类型是const int*
const auto* const cpcx = &cx; // 值传递,T被推导为int, cpcx的类型是const int* const

// 这里用到了decltype这个specifier,我们暂且只需要知道decltype(variable)可以原封不动的给出variable的类型
cout << "type of x: " << type_name<decltype(x)>() << endl;
cout << "type of rx: " << type_name<decltype(rx)>() << endl;
cout << "type of rrx: " << type_name<decltype(rrx)>() << endl;

cout << "type of cx: " << type_name<decltype(cx)>() << endl;
cout << "type of rcx: " << type_name<decltype(rcx)>() << endl;

cout << "type of px: " << type_name<decltype(px)>() << endl;
cout << "type of pcx: " << type_name<decltype(pcx)>() << endl;
cout << "type of cpcx: " << type_name<decltype(cpcx)>() << endl;
return 0;
}

输出结果:

1
2
3
4
5
6
7
8
9
type of x: int
type of rx: int &
type of rrx: int &&
type of lrx: int &
type of cx: const int
type of rcx: const int &
type of px: int *
type of pcx: const int *
type of cpcx: const int *const

思考一下:如果auto* pcx2 = &cx;pcx2是什么类型?auto* const & pcx3 = &cx;呢?如果你可以轻松地推导出来,那么恭喜你,你已经领悟了型别推导的奥义。

decltype也有两个与auto类似的使用场景,一种就是像上例那样使用的对变量做'='右边表达式的型别推导,另一种也是用在函数返回值推导中,写作decltype(auto)

decltype相比auto则没有那么麻烦的推导规则,它只是简单粗暴的告诉我们给定实参(argument)的真实类型(意味着不会忽略CV限定、引用修饰)。argument是一个表达式(也包括实体(entity)的情况),但通过前两节的学习,我们知道表达式的值类型有多种情况,所以还是要分情况讨论下:

  • 如果argument是没有套上小括号的变量(严格的说法是id-expression,不过为了便于理解我们忽略官方的黑话)或是类成员访问表达式(通俗的理解这也是个变量),decltype给出其本身的类型。
  • 否则,对于其他类型T的表达式
    • 如果表达式的值分类是xvalue,则推导为T&&
    • 如果表达式的值分类是lvalue,则推导为T&
    • 如果表达式的值分类是prvalue,则推导为T

这实际上就是cppreference给出的decltype说明符的解释,初学者可能会对第一条款中提到的小括号包裹感到奇怪,实际上它的本质原因在于entity和expresion的区别,假设有变量int a = 3;a我们可以说它是一个entity,但是(a)就不再是一个实体,而是一个表达式了,套上小括号意味着表达式需要被计算(或处理),即使我们对a什么都没做,它也需要处理。因此,对于(a)来说,它是个表达式,要根据第二条款来判定,由于(a)是个lvalue,所以此时型别为int&

说到这里就不得不提一个现代C++中很有意思的坑:返回值类型支持decltype(auto)推导后,对于函数返回语句如果要返回一个变量,那么写成return a;return (a);意义完全不同,前者会被推导为a的类型,而后者被推导为a的左值引用类型。而如果没有用到推导,而是老老实实的返回a的类型,那么这两种写法其实都可以,只不过后者做了一次计算,不会引入其他问题。

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
36
37
38
39
40
41
42
43
44
#include <iostream>
#include "type_info.h"

using namespace std;

struct A {
double x;
};

const int& get_ref(const int *p) { return *p; }

// 相当于C++11中的auto get_ref_forward1(const int* p) -> decltype(get_ref(p))
// 由于get_ref(p)是个表达式返回的是个lvalue,故推导成const int&
decltype(auto) get_ref_forward1(const int* p) {
return get_ref(p);
}

// 值传递,返回的一定是个值类型,此时的实参是get_ref(p),忽略掉顶层CV限定,于是推导为int
auto get_ref_forward2(const int* p) {
return get_ref(p);
}

int main() {
A a;
decltype(a.x) y = 1.0; // 对于entity,y推导成其本身的类型double
decltype((a.x)) z = y; // 由于小括号的存在导致按表达式来推导,(a.x)是左值故推导成double&

int i = 42;
decltype(i) j = i; // 对于entity,j推导成本身类型int
decltype((i)) k = j; // 由于小括号的存在导致按表达式来推导,(i)是左值故推导成int&
const int ci = i;
decltype(ci) cj = ci; // 推导成const int,decltype不会像auto那样忽略顶层CV限定

cout << "type of y: " << type_name<decltype(y)>() << endl;
cout << "type of z: " << type_name<decltype(z)>() << endl;

cout << "type of j: " << type_name<decltype(j)>() << endl;
cout << "type of k: " << type_name<decltype(k)>() << endl;
cout << "type of cj: " << type_name<decltype(cj)>() << endl;

cout << "type of return value of get_ref_forward1: " << type_name<decltype(get_ref_forward1(&ci))>() << endl;
cout << "type of return value of get_ref_forward2: " << type_name<decltype(get_ref_forward2(&ci))>() << endl;
return 0;
}

运行结果:

1
2
3
4
5
6
7
type of y: double
type of z: double &
type of j: int
type of k: int &
type of cj: const int
type of return value of get_ref_forward1: const int &
type of return value of get_ref_forward2: int

思考一下:如果get_ref_forward2的返回类型写作auto&,又该是什么类型呢?

恼人的数组与函数

凡是总有例外,在C++中,有两种类型天生需要被特殊处理,那就是从C语言继承过来的数组和函数名称。在C语言中,数组和指针常常混用,绝大部分场景都可以互为代替使用,这就导致很多人将数组和指针视为同一种东西的不同写法,虽然这个看法是错误的,但在日常开发中,用这样一种局限性的看法来读写代码确实会使事情简单化。函数名称则简单不少,在C中几乎被视为相应的函数指针类型。C++继承了C的legacy,但随着C++语法规则的不断演进,数组和函数名称逐渐显得格格不入,为此,C++做了很多语法上兼容性处理,在很多语境下,数组会退化(decay)成指向其首元素的指针型,而函数名称会退化(decay)成相应的函数指针型。

举个例子:

1
2
3
4
const char name[] = "hello";    // name的类型是const char[6]
const char* p1 = name; // 数组退化,p1指向name的首元素
const char* p2 = "world"; // "world"是一个字符串字面量左值,类型为const char[6]
// 数组退化,p2指向"world"首元素

从C++的视角来看,假如没有退化规则,那么对指针的初始化显然是不合法的,但为了兼容C的legacy,不得不做了容忍。另一方面,在C中我们经常写:

1
2
3
4
5
6
7
char *p = "hello";      // 这在C中非常常见,但如果是在C++11之后是不合法的,尽管编译器只抛了warning:
// ISO C++11 does not allow conversion from string literal to 'char *'
// 字符串字面量是左值(意味着可以被取地址),它有着const特性,底层const不能丢,所以得用const char*
char str[] = "world"; // 这是C语言对字符数组发放的语法糖,str的类型实际上是char[6]
// 等价于char str[6] = {'w', 'o', 'r', 'l', 'd', '\0'}
// C语言一方面支持定界值省略,另一方面对字符数组做了照顾:花括号初始化语法可以改写为字符串字面量
// C++继承了C,所以这在C++中也合法,且语义相同,注意区分和前者的差别

为了说明两种语法的差别,我们编写如下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <cstdio>

using namespace std;

int main() {
const char* p = "hello";
char str[] = "world";

cout << p << " " << str << endl;
printf("address of p: %p\n", p);
printf("address of \"hello\": %p\n", "hello");
printf("address of str: %p\n:", str);
printf("address of \"world\": %p\n", "world");
return 0;
}

运行结果:

1
2
3
4
5
hello world
address of p: 0x10232bea4
address of "hello": 0x10232bea4
address of str: 0x16dad76f8
:address of "world": 0x10232bf03

显然,p"hello"的地址是相同的,说明它们是同一个对象。而str则和"world"有所不同,str只是以char为单位逐个拷贝了"world"的数据到自身的存储单元而已,其类型是char[6]

再来看看当数组涉及型别推导时,效果如何:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
void f(T param) {
cout << "type of T: " << type_name<T>() << endl;
cout << "type of param: " << type_name<decltype(param)>() << endl;
}

int main(){
int v[] = {1,2,3,4,5};
f(v);
char str[] = "hello";
f(str);
return 0;
}

运行结果:

1
2
3
4
type of T: int *
type of param: int *
type of T: char *
type of param: char *

由于param值传递,所以推导时数组类型发生了退化,降级成对应的首元素指针型。而如果改为按引用传递,则不会发生decay:

1
2
3
4
5
6
7
8
9
10
11
12
13
template<typename T>
void f(T&& param) {
cout << "type of T: " << type_name<T>() << endl;
cout << "type of param: " << type_name<decltype(param)>() << endl;
}

int main(){
int v[] = {1,2,3,4,5};
f(v);
char str[] = "hello";
f(move(str));
return 0;
}

运行结果:

1
2
3
4
type of T: int (&)[5]
type of param: int (&)[5]
type of T: char [6]
type of param: char (&&)[6]

可以看到param就是传递进来的参数的原本类型。

如果说上面的例子根据我们的口诀来看还算中规中矩,那下面这个就有点反人类了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 这里的定界6不能少,否则param是个非法的数组引用
template<typename T>
void f(T (&param)[6]) {
cout << "type of T: " << type_name<T>() << endl;
cout << "type of param: " << type_name<decltype(param)>() << endl;
}

// 虽然写作T[],但因为值传递的关系,还是退化成了T*,定界写不写都无所谓,随便写什么都行
template<typename T>
void g(T param[]) {
cout << "type of T: " << type_name<T>() << endl;
cout << "type of param: " << type_name<decltype(param)>() << endl;
}

int main(){
int v[] = {1,2,3,4,5,6};
f(v);
g(v);
char str[] = "hello";
f(str);
g(str);
return 0;
}

运行结果:

1
2
3
4
5
6
7
8
type of T: int
type of param: int (&)[6]
type of T: int
type of param: int *
type of T: char
type of param: char (&)[6]
type of T: char
type of param: char *

我特意把v改成了6个成员,思考一下如果这里不改的话会不会有啥问题?

另外,由于数组引用并不是个直截了当的类型,如果我们写成T (&&param)[6]会编译报错,因为此时param不再是一个万能引用。

你以为这样就结束了?当数组和非类型模板参数相遇时,还有更离谱的操作:

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
36
37
38
39
// 函数模板也可以重载,但要注意可能会引发的ambiguous调用问题
template<typename T>
void f(T (&param)[5]) {
cout << "template f:" << endl;
cout << "type of T: " << type_name<T>() << endl;
cout << "type of param: " << type_name<decltype(param)>() << endl;
}

template<typename T, int N>
void f(T (&param)[N]) {
cout << "nontype template f:" << endl;
cout << "type of T: " << type_name<T>() << endl;
cout << "type of param: " << type_name<decltype(param)>() << endl;
}

template<typename T>
void g(T param[]) {
cout << "template g:" << endl;
cout << "type of T: " << type_name<T>() << endl;
cout << "type of param: " << type_name<decltype(param)>() << endl;
}

template<typename T, int N>
void g(T param[N]) {
cout << "nontype template g:" << endl;
cout << "type of T: " << type_name<T>() << endl;
cout << "type of param: " << type_name<decltype(param)>() << endl;
}

int main(){
int v[] = {1,2,3,4,5};
f(v); // 优先匹配template f,template f比nontype template f更特殊
g(v); // 只能匹配template g,因为值传递会退化成指针
// 即使把template g注释掉也无法匹配nontype template g
char str[] = "hello";
f(str); // 只能匹配nontype template f,因为template f不满足匹配条件无法成为candidate
g(str);
return 0;
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
template f:
type of T: int
type of param: int (&)[5]
template g:
type of T: int
type of param: int *
nontype template f:
type of T: char
type of param: char (&)[6]
template g:
type of T: char
type of param: char *

因为decay的关系,nontype template g实际上无法被用到,如果我们把它改写成传引用呢:

1
2
3
4
5
6
template<typename T, int N>
void g(T (&param)[N]) {
cout << "nontype template g:" << endl;
cout << "type of T: " << type_name<T>() << endl;
cout << "type of param: " << type_name<decltype(param)>() << endl;
}

此时就会遇到编译错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
test_array3.cpp:37:5: error: call to 'g' is ambiguous
g(v);
^
test_array3.cpp:21:6: note: candidate function [with T = int]
void g(T param[]) {
^
test_array3.cpp:28:6: note: candidate function [with T = int, N = 5]
void g(T (&param)[N]) {
^
test_array3.cpp:40:5: error: call to 'g' is ambiguous
g(str);
^
test_array3.cpp:21:6: note: candidate function [with T = char]
void g(T param[]) {
^
test_array3.cpp:28:6: note: candidate function [with T = char, N = 6]
void g(T (&param)[N]) {
^
2 errors generated.

因为两个版本此时都可以完成匹配作为candidate,但二者之间论优先级或者说特殊性并不能分出高下,所以导致了函数模板重载所常见的ambiguous错误。

实际上当涉及到类模板对数组的特化时,情形远远比这里给出的例子要复杂得多,另一方面未定界数组实际上也有特殊的手法来传递推导。考虑到不希望这一部分内容喧宾夺主故不做展开,有兴趣的同学可以看一下《C++ Templates》5.4的内容。

函数名称和函数指针的情景和数组很像:

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
template<typename T>
void f(T param) {
cout << type_name<T>() << endl;
cout << type_name<decltype(param)>() << endl;
}

template<typename T>
void g(T&& param) {
cout << type_name<T>() << endl;
cout << type_name<decltype(param)>() << endl;
}

int my_max(int a, int b) {
return a < b ? b : a;
}

using func = int(*)(int, int);

int main() {
f(my_max); // decay,退化成函数指针
g(my_max); // 不会decay
g(std::move(my_max)); // 对函数名称进行move没意义

func fp = my_max; // fp本身就是函数指针类型
f(fp); // 不需要退化,单纯的值传递
g(fp); // 注意是函数指针的左值引用
g(std::move(fp)); // 注意是函数指针的右值引用
return 0;
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
int (*)(int, int)
int (*)(int, int)
int (int, int)&
int (int, int)&
int (int, int)&
int (int, int)&
int (*)(int, int)
int (*)(int, int)
int (*)(int, int)&
int (*)(int, int)&
int (*)(int, int)
int (*)(int, int)&&

相信读到这里的你,一定会觉着上面的结果一目了然。

完美转发

最后谈谈完美转发。

除了我们之前详细解读过的std::move,C++11在标准库中还定义了一个非常常用的std::forward,相信对于大部分现代C++初学者来说,std::forwardstd::move一度云里雾里。我们先撇开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
template<typename T>
void func2(T&&);

template<typename T>
void func1(T&& param) {
cout << "in func1, T:" << type_name<T>() << ", param:" << type_name<decltype(param)>() << endl;
// 我们希望把param原封不动的“渗透”到下一层
func2(param);
}

template<typename T>
void func2(T&& param) {
std::cout << "in func2, T:" << type_name<T>() << ", param:" << type_name<decltype(param)>() << std::endl;
}

int main() {
int a = 666;
func1(a); // a是左值,所以func1中T被推导成int&,param也是int&,此时向下调用func2传递的依然是左值引用
// 所以func2中的T和param也都是int&,这是符合预期的
func1(2333); // 2333是纯右值,func1中T被推导成int,param被推导成int&&,此时向下调用func2传递的是右值引用类型的param
// 尽管param的类型是右值引用,但param本身是个左值(具名的变量),这就导致渗透传递时func2中
// T和param被推导成int&,这就不符合预期了,我们的引用型在渗透过程中变质了
return 0;
}

运行结果:

1
2
3
4
in func1, T:int&, param:int&
in func2, T:int&, param:int&
in func1, T:int, param:int&&
in func2, T:int&, param:int&

我们在func1中又调用了func2,且原封不动的将参数param传递了下去,希望能够把左值引用或是右值引用这一特性渗透到下一层,然而事与愿违,由于在C++语法中,即使类型为右值引用的具名变量其本身依然是个左值,这就导致了不可传递性。

可能有的小伙伴说了,我们不是学过std::move吗,只需要调用func2时对parammove一下不就行了吗?

1
2
3
4
5
6
template<typename T>
void func1(T&& param) {
cout << "in func1, T:" << type_name<T>() << ", param:" << type_name<decltype(param)>() << endl;
// 强制传递右值引用
func2(move(param));
}

显然,如果按照这个写法,上述例子中后者的运行结果符合预期,可是前者却又出了问题:

1
2
3
4
in func1, T:int&, param:int&
in func2, T:int, param:int&&
in func1, T:int, param:int&&
in func2, T:int, param:int&&

为什么呢?因为你把一个原本是左值引用的param强制转成了右值引用向下传递!可以说是按下葫芦浮起瓢。可是这种需求在我们日常开发中很常见啊,就真的没办法兼容吗?不,成年人的世界从来都是:我全都要~

我们采用标准库中的std::forward试一下:

1
2
3
4
5
template<typename T>
void func1(T&& param) {
cout << "in func1, T:" << type_name<T>() << ", param:" << type_name<decltype(param)>() << endl;
func2(forward<T>(param));
}

运行结果:

1
2
3
4
in func1, T:int&, param:int&
in func2, T:int&, param:int&
in func1, T:int, param:int&&
in func2, T:int, param:int&&

可以发现确实达成了我们想要的结果,而这就是C++中所谓的完美转发(Perfect Forwarding)。那么问题来了,std::forward只不过是标准库中的函数,既不是什么语法糖,也不是什么黑科技,它是怎么做到完美转发的呢?

实际上std::forward并没有做什么神奇的操作,它背后所依赖的原理就在于:我们需要区分左值引用和右值引用,而这一信息其实通过param是可以知晓的,param要么是一个左值引用、要么是一个右值引用,而对于这两种情况,我们需要一种if-else的逻辑来分别处理,对于前者来说,我们直接传递下去即可;而对于后者来说,渗透传递时我们要做一次std::move来把它强制转成右值引用。

另一方面,std::forward是一个函数模板,我们传入了param作为参数,而param携带了引用类型的信息,所以只需要编写重载函数,分别处理左值引用和右值引用的不同param即可:

1
2
3
4
5
6
7
8
9
template<typename T>
T&& forward(remove_reference_t<T>& param) {
return static_cast<T&&>(param);
}

template<typename T>
T&& forward(remove_reference_t<T>&& param) {
return static_cast<T&&>(param);
}

remove_reference_t是标准库中的一种type trait,可以洗掉类型T的引用修饰,这里是为了先确保拿到一个纯净类型,然后再分别施加左值引用或者是右值引用的修饰,注意,对于后者而言remove_reference_t<T>&&不是万能引用,而是一个彻彻底底的右值引用。于是,当我们向std::forward传递一个左值时,会匹配到第一个函数模板,此时T是左值引用,因此返回的类型根据引用折叠规则已然是一个左值引用;而当传递右值时,则会匹配到第二个函数模板,此时T不是引用类型,但返回的类型由于static_cast<T&&>T强制转成了其右值引用类型。

实际上上面的代码与各大编译器厂商实现的标准库std::forward如出一辙,只是标准库中的代码有更多的诸如constexpr、noexcept等杂七杂八的修饰。

参考文献


C++模板从入门到劝退(2)——型别推导、万能引用与完美转发
https://r00tk1ts.github.io/2022/06/07/C++模板从入门到劝退(2)——型别推导、万能引用与完美转发/
作者
r00tk1t
发布于
2022年6月7日
许可协议