谈到C++模板,多少业界大佬无不讳莫如深,而基于模板的元编程更是历来被业界同仁视为黑魔法。模板从诞生至今已有几十年的历史,实际上在经历了C++11标准的革命性洗礼以及后续的缝缝补补后,模板元编程的门槛和抽象程度已经大大降低。纵观历史长河,优秀的C++书籍鳞次栉比,然而涉及到元编程的书籍却屈指可数,那些传世经典的圣经对于模板皆是浅尝辄止。本系列文章将致力于揭开模板元编程的迷雾,通过牺牲一定程度的表述严谨性,按照笔者的归纳方式穿针引线,为痴迷于黑魔法而又始终不得其解的同学打通任督二脉。
本篇是走进模板世界的最后一道前菜,通过型别推导、万能引用与完美转发这三个C++11所引入的新机制,我们得以对模板的世界管中窥豹。
型别推导、万能引用与完美转发
上一讲的最后我们提到了一种特殊的引用类型:万能引用(Universal
Reference),万能引用虽然长得很像普通的右值引用,但二者却有着本质差别。万能引用是形如T&&
的引用类型,当且仅当T
的型别需要被直接推导时,万能引用的最终类型判定需要介入引用折叠规则,虽然上一讲我们有提到,但却浅尝辄止。而说到型别推导(Type
Deduction),这是一个迷人且拥有非常复杂的判定规则的设定,在传统C++中,它始终和模板一起出现,因此传统的C++开发者即使对模板望而生畏、不了解型别推导也不会在日常开发工作中因此而困扰,而C++11引入了auto
和decltype
关键字,将型别推导的作用放大到开发者的日常中,至此,在现代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); char c = convert <char >(d); int (*ptr)(float ) = convert; }
这就是一个显式指定 +
型别推导的例子,第一行我们仅指定了To
为int
型,From
则根据传入的参数d
被编译器推导成double
型,它与d
的类型一致。第二行与第一行类似,只不过显式指定的To
类型是char
。第三行则有些不同,它定义了一个型别为int(*)(float)
的函数指针变量ptr
,使其指向convert
函数,这里To
和From
都需要编译器来做推导,根据函数指针类型,分别将To
推导成int
、From
推导成float
。ptr
实际指向的是函数模板convert
实例化出的形如int convert<int, float>(float){...}
的函数,而非convert
本身,我们知道函数模板本身只是模板,是没有实体对象的。
该例出自https://en.cppreference.com/w/cpp/language/template_argument_deduction,道行够深的同学一定要通读。
如果将上例中的To
和From
顺序颠倒,会发生什么事呢?由于模板参数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); char c = convert <double , char >(d); int (*ptr)(float ) = convert; }
顺序的调换导致我们前两行的自动推导失效,从而不得不完全显式地指定,代码也写成了“愚型”。而另一方面,我们也可以看出,对于函数模板来说,大多数情况的模板实参型别推导还是依赖于函数调用的参数列表,这其中出现了的模板参数往往可以进行推导。我们简单总结下,对于函数模板template<typename T>void f(T param);
,编译器可以根据我们实际调用时传入的实参类型来进行推导(f(argument);
),得出模板参数T
的类型和相应的参数列表(T param
)。
然而这只是一种情况,也是最简单的情况。实际上,我们需要考虑的维度有三个:
模板参数本身:T
函数参数列表:不一定是T param
,很可能对T
做了CV限定或是引用/指针修饰,我们记为ParamType
函数调用实参:argument
这个表达式的型别可以千奇百怪
T
型别的推导实际上是ParamType
和argument
的共同作用,从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" ; f (ptr);
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;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); f (cx); f (rx); f (&x); f (&cx); f (&rx); f (pcx); f (cpcx); 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) { 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; f (&x); f (px); f (rpx); f (pcx); f (pcpx); 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); f (cx); f (rx); f (pcx); f (cpcx); 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); f (cx); f (rx); f (&x); f (&cx); f (&rx); f (pcx); f (cpcx); 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) { 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); f (cx); f (rx); f (&x); f (&cx); f (&rx); f (pcx); f (cpcx); 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
),引入了auto
和decltype
这两个关键字。这两位爷都是用于型别推导的,只是推导的规则不同,auto
这个关键字的使用在现代C++中要分两个场景来看:其一是最常见的为变量做型别推导;其二是为函数返回类型做推导(C++14之后才发糖支持)。
1 2 3 4 5 6 7 8 9 10 auto a = 1 ;const auto & r = a;auto *p = &a;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 ; auto & rx = x; auto && rrx = 666 ; auto && lrx = x; const auto cx = x; const auto & rcx = x; auto * px = &x; const auto * pcx = &cx; const auto * const cpcx = &cx; 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; }decltype (auto ) get_ref_forward1 (const int * p) { return get_ref (p); }auto get_ref_forward2 (const int * p) { return get_ref (p); }int main () { A a; decltype (a.x) y = 1.0 ; decltype ((a.x)) z = y; int i = 42 ; decltype (i) j = i; decltype ((i)) k = j; const int ci = i; decltype (ci) cj = ci; 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" ; const char * p1 = name; const char * p2 = "world" ;
从C++的视角来看,假如没有退化规则,那么对指针的初始化显然是不合法的,但为了兼容C的legacy,不得不做了容忍。另一方面,在C中我们经常写:
1 2 3 4 5 6 7 char *p = "hello" ; char str[] = "world" ;
为了说明两种语法的差别,我们编写如下程序:
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 template <typename T>void f (T (¶m)[6 ]) { 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 << "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 (&¶m)[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 template <typename T>void f (T (¶m)[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 (¶m)[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); g (v); char str[] = "hello" ; f (str); 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 (¶m)[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 (¶m)[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 (¶m)[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); g (my_max); g (std::move (my_max)); func fp = my_max; 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::forward
和std::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; 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); func1 (2333 ); 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
时对param
再move
一下不就行了吗?
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等杂七杂八的修饰。
参考文献