c++元编程模板函数重载匹配规则示例详解
一只修仙的猿 人气:0前言
模板元编程,是一个听起来非常硬核的概念,会感觉这个东西非常的难,是大佬才能掌握的内容。而事实上,他也确实不简单(手动狗头),但是也并没有想象中的复杂。
我们对很多事物,都喜欢加上“元”的概念,如学习,指的是学习知识,比如学习数学。而元学习,指的是学习学习本身,去学习如何更好地学习,也就是提升学习能力。所以“元”概念,在很多时候值得就是把关注对象回到本身,比如上面的例子,把关注对象从数学等知识回到学习本身。
模板编程,指的是可以我们可以将函数或者类的数据类型抽离出来,做到类型无关性。我们关注的对象,是普通函数、普通类。如下面的这个经典的模板函数:
template<typename T> bool compare(T t1,T t2) { return t1 > t2; }
我们可以使用一份代码,来判断两个相同的类型的对象,t1是否大于t2。
而模板元编程,则是对模板函数、模板类本身,进行编程。继续上面的代码例子,假如有一些类型,他并没有>
运算符,只有<=
运算符,那么我们需要重载两个模板函数,对这两个类型的数据进行分类:
// 函数1 template<typename T> bool compare(T t1,T t2) { return t1 > t2; } // 函数2 template<typename T> bool compare(T t1,T t2) { return t2 <= t1; }
拥有>
运算符的类型进入函数1,拥有<=
运算符进入函数2。我们这里对模板类型进行判断、选择的过程,就是模板元编程。可以说,模板编程,是将数据类型从函数或者类抽离出来;而模板元编程,则是对类型进行更加细致的划分,分类别进行处理。
这个时候可能有读者会有疑问:这不就是类型识别吗?我用typeId
也可以实现啊,例如以下代码:
template<typename T> void show(T t) { if(typeid(T).hash_code()==...) { t.toString(); } else { t.toType(); } }
这种写法是错误的。上面代码例子中无法通过编译,原因是T
类型无法同时拥有toString()
和toType()
函数,即使我们的代码只会运行其中一个路径。其次:
typeid
在多动态库环境下,会出现不一致的问题,并不是非常可靠。typeid
只能对已有的数据类型进行判断,无法判断新增类型。- 会导致函数臃肿,判断条件众多,代码不够优雅。
原因有很多,这里列举了几条,一句话总结就是不可靠、不适用、不优雅。因此我们才需要模板元编程。
那么,如何在模板中实现对类型的判断并分类处理呢?我们接着往下看。
文章内容略长,我非常建议你完整阅读,但是如果时间比较紧,可以选择性阅读章节:
开始:从一个具体的例子从0到1解析模板元编程
模板函数重载匹配规则+模板匹配规则:介绍模板编程最核心的两个规则,他是整个模板元编程依赖的基础
最后的章节进行全文的总结
开始
我们先从一个例子来看模板元编程是如何工作的。我们创建一个类HasToString
,其作用是判断一个类型是否有toString
成员函数,使用的代码如下:
template<typename T> HasToString{...} class Dog { }; class Cat { public: std::string toString() const{ return "cat"; } }; std::cout << "Dog:" << HasToString<Dog>::value << std::endl; // 输出0 std::cout << "Cat:" << HasToString<Cat>::value << std::endl; // 输出1
通过类HasToString
,我们可以判断一个类型是否有toString
这个成员函数。好,接下来让我们看一下HasToString
是如何实现的:
// 判断一个类型是否有 toString 成员函数 template<typename T> class HasToString { template<typename Y, Y y> class Helper {}; template<typename U = T> constexpr static bool hasToString(...) { return false; } template<typename U = T> constexpr static bool hasToString(Helper<std::string (U::*)() const,&U::toString>*) { return true; } public: const static bool value = hasToString<T>(nullptr); };
好家伙,这也太复杂了!!完全没看懂。你是否有这样的感觉呢?如果你是第一次接触,感觉比较复杂很正常,现在我们无需完全理解他,下面我们一步步慢慢说。
首先有两个c++的其他知识先解释一下:constexpr
关键字和成员函数指针,了解的读者可以直接跳过。
constexpr:表示一个变量或者函数为编译期常量,在编译的时候可以确定其值或者函数的返回值。在上面的代码中,const static bool value
需要在编译器确定其值,否则不能在类中直接复制。因此我们给hasToString
函数增加了constexpr
关键字。
成员函数指针:我们可以获取一个对象的成员函数指针,而在合适的时候,调用此函数。如下代码
std::string (Cat::*p)() const = &Cat::toString; // 获取Cat的函数成员指针 Cat c; std::string value = (c.*p)(); // 通过成员函数指针调用c的成员函数
可以看到成员函数指针的声明语法和函数指针很相似,只是在前面多了Cat::
表示是哪个类的指针。
这里仅简单介绍,其他更详细的内容,感兴趣可以百度一下了解。
好,我们第一步先看到HasToString
的value
变量,他是一个const static bool
类型,表示T
类型是否有toString
函数的结果。他的值来源于hasToString<T>(nullptr)
,我们继续看到这个函数。
hasToString
是一个返回值为bool
类型的模板函数,由于其为constexpr static
类型,使得其返回值可以直接赋值给value
。他有两个重载实例:
- 第一个重载函数的参数为函数参数包
- 第二个重载函数的参数为Helper对象的的指针
我们暂时先不管Helper
的内容,当我们调用hasToString<T>(nullptr)
时,他会选择哪个重载函数?答案是不管T
类型如何,都会先进入第二个重载函数。原因是,第二个重载函数相比第一个更加特例化:实参与形参均为指针类型,根据模板函数匹配规则,他的优先级更高,因此会选择第二个重载函数进行匹配。
到这里,我们已经可以明确,在编译时,不管T
的类型如何,均会调用到hasToString
的第二个重载函数。这个时候,我们看到模板类Helper
,他的模板类型很简单,第一个模板参数是Y
,而第二个模板参数则为第一个模板类型的对象值。
看到hasToString
第二个重载函数,其参数为一个Helper
类型指针。其中,Helper
的第一个模板类型描述了成员函数toString
的函数类型,第二个模板参数获取模板类型U
的成员函数toString
的指针。这一步可以保证类型U
拥有成员函数toString
,且类型为我们所描述的函数类型。
好,到这里就可能有两种情况:
- 假如类型
U
拥有toString
成员函数,那么函数匹配正常,hasToString
实例化成功。 - 假如类型
U
没有toString
成员函数,此时会匹配失败,因为&U::toString
无法通过编译。这个时候,根据c++的模板匹配规则,匹配失败并不会直接导致崩溃,而是会继续寻找可能的函数重载。
对于类型Dog
,他没有toString
成员函数,hasToString
第二个重载函数匹配失败,此时会继续寻找hasToString
的其他重载类型。到了第一个重载类型,匹配成功,类型Dog
匹配到hasToString
第一个重载函数。
这里就是我们整个HasToString
的重点:他成功将含toString
成员函数的类型,与不含toString
成员函数的类型成功分到两个不同重载函数中去,完成我们判断的目的。
这,就是模板元编程。
好了,对于一开始我们觉得很复杂的代码,我们也基本都了解了,可以先暂时松一口气,先来回顾一下上面的内容:
// 判断一个类型是否有 toString 成员函数 template<typename T> class HasToString { template<typename Y, Y y> class Helper {}; template<typename U = T> constexpr static bool hasToString(...) { return false; } template<typename U = T> constexpr static bool hasToString(Helper<std::string (U::*)() const,&U::toString>*) { return true; } public: const static bool value = hasToString<T>(nullptr); };
- 我们创建了一个模板类
HasToString
来判断一个类型是否拥有toString
成员函数,并将结果存储在静态常量value
中。 value
的值来源于静态模板函数hasToString
的判断,我们将该函数设置为constexpr
类型,因此可以直接将返回值赋值给value
。- 利用模板函数重载匹配规则,将函数调用优先匹配到
hasToString
的第二个重载函数进行匹配。 - 我们创建了
Helper
辅助模板类,来描述我们需要的成员函数类型,并获取类型的成员函数。 - 利用模板匹配规则,匹配失败的类型,将进入
hasToString
的第一个重载函数进行匹配,实现类型的选择。
整个过程最核心的部分,是模板函数hasToString
的重载与匹配。而其所依赖的,是我们重复提到模板函数重载匹配规则、模板匹配规则,那么接下来,我们来聊聊这个匹配规则的内容。
模板函数重载匹配规则
模板函数重载匹配规则,他规定着,当我们调用一个具有多个重载的模板函数时,该选择哪个函数作为我们的调用对象。与普通函数的重载类似,但是模板属性会增加一些新的规则。
模板函数重载匹配规则可以引用《c++ primer》中的一段话来总结:
对于一个调用,其候选函数包括所有模板实参推断成功的函数模板实例。
候选的函数模板总是可行的,因为模板实参推断会排除任何不可行的模板。
与往常一样,可行函数(模板与非模板)按类型转换 (如果对此调用需要的话)来排序。当然,可以用于函数模板调用的类型转换是非常有限的。
与往常一样,如果恰有一个函数提供比任何其他函数都更好的匹配,则选择此函数。 但是,如果有多个函数提供同样好的匹配,则:
- 如果同样好的函数中只有一个是非模板函数,则选择此函数。
- 如果同样好的函数中没有非模板函数,而有多个函数模板,且其中一个模板比其他模板更特例化,则选择此模板。
- 否则,此调用有歧义。
看着有点不知所以然,我们一条条来看。这里我给整个过程分为三步:
第一步:模板函数重载匹配会将所有可行的重载列为候选函数。
举个例子,我们现在有以下模板函数以及调用:
template<typename T> void show(T t) {...} // 形参为T template<typename T> void show(T* t) {...} // 形参为T* int i = 9; show(i); show(&i);
代码中模板函数show
有两个重载函数,其形参不同。当调用show(i)
时,第一个重载函数T
可以匹配为int
类型,第二重载函数,无法完成int
类型到指针类型的匹配,因此本次调用的候选重载函数只有第一个重载函数。
第二个调用show(&i)
,第一个重载函数T
可以匹配为int*
类型,第二个重载函数T
可以匹配为int
类型,因此本地调用两个重载函数都是候选函数。
选择候选函数是整个匹配过程的第一步,过滤掉那些不符合的重载函数,再进行后续的精确选择。
第二步:候选可行函数按照类型转换进行排序
匹配的过程中,可能会发生类型转换,需要类型转换的优先级会更低。看下面代码:
template<typename T> void show(T* t) {...} // 形参为T* template<typename T> void show(const T* t) {...} // 形参为const T* int i = 9; show(&i);
show
两个重载函数均作为候选函数。第一个函数的形参会被匹配为int*
,而第二个重载函数会被匹配为const int*
,进行了一次非const指针到const指针的转换。因此前者的优先级会更高。
类型转换,主要涉及volatile
和const
转换,上面的例子就是const
相关的类型转换。类型转换是匹配过程中的第二步。
此外,还有char*
到std::string
的转换,也属于类型转换。字符串字面量,如"hello"
属于const char*
类型,编译器可以完成到std::string
的转化。
第三步:若第二步存在多个匹配函数,非模板函数优先级更高;若没有非模板函数,则选择特例化更高的函数。
到了这一步,基本选择出来的都是精确匹配的函数了。但是却存在多个精确匹配的函数,需要按照一定规则进行优先级排序。看下面例子代码:
template<typename T> void show(T t) {...} // 形参为T template<typename T> void show(T* t) {...} // 形参为T* void show(int i) {...} // 非模板函数 int i = 9; show(i); show(&i);
在上面代码中,show(i)
的调用,有两个精确匹配的函数,第一个和第三个重载函数。但是,第三个重载函数为非模板函数,因此其优先级更高,选择第三个重载函数。
show(&i)
调用中,可以精确匹配到第一个和第二个重载函数。但是第二个函数相比第一个会更加特例化,他描述的形参就是一个指针类型。因此选择第二个重载函数版本。
到此基本就能选择最佳匹配的重载函数版本。若最后出现了多个最佳匹配,则本地调用时有歧义的,调用失败。
这里需要注意的一点是,引用不属于特例化的范畴,例如以下的代码在调用时是有歧义的:
template<typename T> void show(T t) {...} // 形参为T template<typename T> void show(T& t) {...} // 形参为T& int i = 9; show(i); // 调用失败,无法确定重载版本
好了,这就是整个模板函数重载的匹配过程,主要分三步:
- 选择所有可行的候选重载函数版本
- 根据是否需要进行类型转换进行排序
- 优先选择非模板类型函数;若无非模板函数则选择更加特例化的模板函数。若出现多个最佳匹配函数则调用失败
了解了模板函数重载的匹配过程,那么我们就能在进行模板元编程的时候,对整体的匹配过程有把握。除了模板函数重载匹配规则,还有一个重要的规则需要介绍:模板匹配规则。
模板匹配规则
模板,有两种类型,模板函数和模板类。模板类没有和模板函数一样的重载过程,且在使用模板类时需要指定其模板类型,因此其貌似也不存在匹配过程?不,其实也存在一种场景具有类似的过程:默认模板参数。看下面的例子:
template<typename T,typename U = int> struct Animal {}; template<typename T> struct Animal<T,int> {}; Animal<int> animal;
模板类Animal
有两个模板参数,第二个模板参数的默认类型为int。代码中特例化了<T,int>
类型,与第二个模板参数的默认值保持一致。当我们使用Animal<int>
实例化时,Animal
两个模板参数被转化为<int,int>
,模板匹配会选择特例化的版本,也就是template<typename T> struct Animal<T,int>
版本。这个过程有点类似我们前面的模板函数重载匹配过程,但是本质上是不同的,模板类的匹配过程不涉及类型转换,完全是精确类型匹配。但在行为表现上有点类似,因此在这里补充说明一下。
这里我们要介绍一个更加重要的规则:SFINAE法则。
这个法则很简单:模板替换导致无效代码,并不会直接抛出错误,而是继续寻找合适的重载。我们还是通过一个例子来理解:
// 判断一个类型是否有 toString 成员函数 template<typename T> class HasToString { template<typename Y, Y y> class Helper {}; template<typename U = T> constexpr static bool hasToString(...) { return false; } template<typename U = T> constexpr static bool hasToString(Helper<std::string (U::*)() const,&U::toString>*) { return true; } public: const static bool value = hasToString<T>(nullptr); };
这是我们前面的例子,当我们调用hasToString<T>(nullptr)
时,模板函数hasToString
的两个重载版本都是精确匹配,但是后者为指针类型,更加特例化,因此优先选择第二个重载版本进行替换。到这里应该是没问题的。
但是,如果我们的类型T
不含toString
成员函数,那么在这个部分Helper<std::string (U::*)() const,&U::toString>
会导致替换失败。这个时候,按照SFINAE法则,替换失败,并不会抛出错误,而是继续寻找其他合适的重载。在例子中,虽然第二个重载版本替换失败了,但是第一个重载版本也是精确匹配,只是因为优先级没有第二个高,这个时候会选择第一个重载版本进行替换。
前面我们在讲模板函数重载规则时提到了候选函数,在匹配完成后发生替换失败时,会在候选函数中,按照优先级依次进行尝试,直到匹配到替换成功的函数版本。
这一小节前面提到的模板类的默认模板参数场景,也适用SFINAE法则。看下面的例子:
class Dog {}; template<typename T,typename U = int> struct Animal {}; template<typename T> struct Animal<T, decltype(declval<T>().toString(),int)> {}; Animal<Dog> animal;
代码中有一个关键字declval
,有些读者可能并不熟悉。
declval的作用是构建某个类型的实例对象,但是又不能真正去执行构建过程,一般结合decltype使用。例如代码中的例子,我们利用declval构建了类型T的实例,并调用了其toString的成员函数。使用decltype保证这个过程并不会被执行,仅做类型获取,或者匹配的过程。更详细的建议读者搜索资料进一步了解,declval是c++14以后的新特性,如果是c++11则无法使用。
根据前面的内容,我们知道Animal<Dog>
会匹配到特例化的版本,但是由于Dog
类型没有toString
成员函数,会导致替换失败。这时候会回到第一个非特例化的版本,进行替换。
好了,通过这两个例子,读者应该也能理解SFINAE法则的内容。模板重载匹配规则,是整个模板元编程中最核心的内容,利用这个规则,就可以在整个匹配的流程的不同的重载中,函数重载或者类特例化,选择我们需要的类型,并将其他不需要的类型根据匹配流程继续寻找匹配的目标,从而完成我们对数据类型的选择。
这个过程其实有点类似于流转餐厅:厨师放下的食物是数据类型,每个客户是重载版本,流水线是模板匹配规则流程,每个客户选择自己喜爱的食物,并将不感兴趣的食物利用流水线往后传,每个食物最终都到了感兴趣的客户中。当然如果最终无人感兴趣,则意味着匹配出错。
使用
到此,我们对于模板元编程核心内容就了解完成了。那么在实际中如何去使用呢?这里给出笔者的一些经验。
首先,必须要明确目的,不要为了使用技术而使用技术。模板元编程,能完成的功能是,在模板重载中实现对类型的判断与选择。当我们有这个需求的时候,可以考虑使用模板元编程,这里举几个常见场景。
我们回到我们最开始的那个例子:比较大小。假如一个类型拥有<
操作,采用<
运算符进行比较,否则采用>=
运算符进行比较。这里我们采用默认模板参数的方式进行编写:
template<typename T,typename U = int> struct hasOperate { constexpr static bool value = false; }; template<typename T> struct hasOperate<T, decltype(declval<T>() < declval<T>(),int())> { constexpr static bool value = true; };
这样通过value值就可以获取到结果。那么我们很容易写出下面的代码:
template<typename T> bool compare(const T& t1,const T& t2) { if(hasOperate<T>::value) { return t1 < t2; } else { return t2 >= t1; } }
好了,大功告成。运行一下,诶,怎么编译不过?这个问题在文章前面有简单提到。对于类型T
,他可能只有两种操作符其中的一种,例如以下类型:
class A { public: explicit A(int num) : _num(num){} bool operator<(const A& a) const{ return _num < a._num; } int _num; };
A类型只有<
操作符,并没有>=
操作符,上面的模板函数实例化之后会变成下面的代码:
bool compare(const A& t1,const A& t2) { if(hasOperate<A>::value) { return t1 < t2; } else { return t2 >= t1; // 这里报错,找不到>=操作符 } }
代码中,即使我们的else逻辑不会运行到,但编译器会检查所有关于类型A的调用,再抛出找不到操作符的错误。那么我们该如何操作呢,有两个思路。
第一个思路是直接在hasOperate
结构体中,分别编写各自的处理函数。这样能解决一些问题,但是局限性比较大,不够灵活。
另一个思路就是我要给你介绍的一个非常好用工具类std::enable_if
。有了它之后我们可以这么使用:
template<typename T> bool compare(typename std::enable_if<hasOperate<T>::value,T>::type t1,T t2) { return t1 < t2; } template<typename T> bool compare(typename std::enable_if<!hasOperate<T>::value,T>::type t1,T t2) { return t2 >= t1; }
感觉有点不太理解,没事,我们先来了解一下他。enable_if
的实现代码很简单:
template<bool enable,typename T> struct enable_if {}; template<typename T> struct enable_if<true,T> { using type = T; };
他是一个模板结构体,第一个参数是一个布尔值,第二个是一个泛型T
。其特例化了布尔值为true
的场景,并增加了一个type
别名,反之如果布尔值为false,则没有这个type
类型。
回到我们前面使用代码,我们使用hasOperate<T>::value
来获取该类型是否拥有指定操作符,如果没有则获取不到type类型,那么整个替换过程就会失败,需要继续寻找其他的重载。这样就实现对类型的选择。
系统库中,还提供了很多类型判断接口可以和enable_if
一起使用。例如判断一个类型是否为指针std::is_pointer<>
、数组std::is_array<>
等。例如我们可以创建一个通用的析构函数,根据是否为数组类型进行析构:
template<typename T> void deleteAuto(typename std::enable_if<std::is_array<T>::value,T>::type t) { delete[] t; } template<typename T> void deleteAuto(typename std::enable_if<!std::is_array<T>::value,T>::type t) { delete t; } int array[9]; int *pointer = new int(1); deleteAuto<decltype(array)>(array); // 使用数组版本进行析构 deleteAuto<decltype(pointer)>(pointer);// 使用指针版本进行析构
结合模板具体化与enable_if
,也可以实现对一类数据的筛选。例如我们需要对数字类型进行单独处理。首先需要编写判断类型是否为数组类型的代码:
template<typename T> constexpr bool is_num() { return false; } template<> constexpr bool is_num<int>() { return true; } template<> constexpr bool is_num<float>() { return true; } template<> constexpr bool is_num<double>() { return true; } ...
注意这里的函数必须要声明为constexpr
,这样才能在enable_if
中使用。补充好所有我们认为是数字的类型,就完成了。使用模板类也是可以完成这个任务的:
template<typename T> struct is_num { constexpr static bool value = false; }; template<> struct is_num<int> { constexpr static bool value = true; }; ... // 补充其他的数字类型
使用静态常量来表示这个类型是否为数字类型。静态常量也可以使用标准库的类,减少代码量,如下:
template<typename T> struct is_num : public false_type {}; template<> struct is_num<int> : public true_type{}; ... // 补充其他的数字类型
改为继承的写法,但原理上是一样的。
有了以上的判断,就可以使用enable_if
来分类处理我们的逻辑了:
template<typename T> void func(typename std::enable_if<is_num<T>(),T>::type t) { //... } template<typename T> void func(typename std::enable_if<!is_num<T>(),T>::type t) { //... }
使用enable_if
的过程中,还需要特别注意,避免出现重载歧义,或者优先级问题导致编程失败。
最后,再补充一点关于匹配过程的类型问题。还是上面判断是否是数字的例子,看下面的代码:
int i = 9; int &r = i; func<decltype<r>>(r); // 无法判断是数字类型
在我们调用func<decltype<i>>(i);
时,i
的类型是const int
,而我们具体化是template<> constexpr bool is_num<int>() { return true; }
,他的模板类型是int
,这是两个不同的类型,无法对应。因此判断此类型为非数字类型。
导致这个问题不止有const
,还有volatile
和引用类型。如int&
、volatile int
等。解决这个问题的方法有两个:
- 在具体化中,增加
const int
等类型,但是枚举所有的类型非常繁杂且容易遗忘。 - 在匹配之前,对数据类型进行去修饰处理。
第二种方法,c++提供函数处理。std::remove_reference<T>::type
移除类型的引用,std::remove_cv<T>::type
移除类型的const volatile
修饰。因此我们在调用前可以如此处理:
template<typename T> using remove_cvRef = typename std::remove_cv<typename std::remove_reference<T>::type>::type; int i = 9; int &r = i; func<remove_cvRef<decltype<r>>(r); // 移除引用修饰,转化为int类型
关于类型推断相关的问题这里不多展开,但要特别注意由于类型修饰导致的匹配失败问题。
最后
文章真的长呀,如果你能坚持看到这里,说明你是一个非常坚持且对编程有强烈兴趣的人,希望这篇文章让你在c++模板的路上有所帮助。
那么接下来我们再来回顾一下这篇文章的内容。
- 我们先介绍了模板元编程要解决的场景与问题
- 然后我们从一个具体的模板元编程例子展开,一步步学习了模板元编程的整体内容
- 接下来针对其核心:模板函数重载匹配规则以及模板规则进一步了解
- 最后再给出在使用方面的一些经验供参考
模板元编程他要解决的最核心的问题就是:对模板类型的判断与选择。而其所依赖的最核心的内容是模板函数重载匹配规则以及SFINAE法则,他是我们模板元编程得以实现的基础。需要注意,整个元编程发生在编译期,任何的函数调用都无法通过编译。其次需要类型的推断导致的匹配错误问题,而且此错误比较隐蔽难以发现。
最后,模板元编程十分强大,但涉及的相关内容多,容易出错。只有当我们十分确定要使用模板元编程解决的问题,再去使用他。切不可为了使用而使用,成为自己炫技的工具,这会给代码留下很多的隐患。
参考
- An introduction to C++'s SFINAE concept: compile-time introspection of a class member:这是国外微软c++工程师Jean Guegant写的一篇文章,内容非常好,比较完整地介绍了模板元编程,从最基础的写法到使用c++11、c++14特性等,非常专业。但是文章仅有英文版本,不建议直接网页翻译,有点地方翻译错误无法理解。
- 《c++ primer》:c++学习神书,应该没有疑问?个人建议如果不是完全没有编程基础,使用《c++ primer》来替代《c++ primer plus》吧。
加载全部内容