模板编程,指的是可以我们可以将函数或者类的数据类型抽离出来,做到类型无关性。我们关注的对象,是普通函数、普通类。如下面的这个经典的模板函数:
template<typename T>
bool compare(T t1,T t2) {
return t1 >t2;
}
我们可以使用一份代码,来判断两个相同的类型的对象,t1是否大于t2。
而模板元编程,则是对模板函数、模板类本身,进行编程。继续上面的代码例子,假如有一些类型,他并没有>
运算符,只有<=
运算符,那么我们需要重载两个模板函数,对这两个类型的数据进行分类:
// 函数
template<typename T>
bool compare(T t,T t2) {
return t > t2;
}
// 函数
template<typename T>
bool compare(T t,T t2) {
return t <= t1;
}
拥有>
运算符的类型进入函数1,拥有<=
运算符进入函数2。我们这里对模板类型进行判断、选择的过程,就是模板元编程。可以说,模板编程,是将数据类型从函数或者类抽离出来;而模板元编程,则是对类型进行更加细致的划分,分类别进行处理。
这个时候可能有读者会有疑问:这不就是类型识别吗?我用typeid也可以实现啊,例如以下代码:
template<typename T>
void show(T t) {
if(typeid(T).hash_code()==...) {
t.toString();
} else {
t.toType();
}
}
这种写法是错误的。上面代码例子中无法通过编译,原因是T类型无法同时拥有toString()和toType()函数,即使我们的代码只会运行其中一个路径。其次:
原因有很多,这里列举了几条,一句话总结就是不可靠、不适用、不优雅。因此我们才需要模板元编程。
那么,如何在模板中实现对类型的判断并分类处理呢?我们接着往下看。
文章内容略长,我非常建议你完整阅读,但是如果时间比较紧,可以选择性阅读章节:
开始:从一个具体的例子从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; // 输出
std::cout << "Cat:" << HasToString<Cat>::value << std::endl; // 输出
通过类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
的内容,当我们调用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 =;
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 =;
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 =;
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 =;
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;
代码中有一个关键字std::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& t,const T& t2) {
if(hasOperate<T>::value) {
return t < t2;
} else {
return t >= 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& t,const A& t2) {
if(hasOperate<A>::value) {
return t < t2;
} else {
return t >= t1; // 这里报错,找不到>=操作符
}
}
代码中,即使我们的else逻辑不会运行到,但编译器会检查所有关于类型A的调用,再抛出找不到操作符的错误。那么我们该如何操作呢,有两个思路。
第一个思路是直接在hasOperate结构体中,分别编写各自的处理函数。这样能解决一些问题,但是局限性比较大,不够灵活。
另一个思路就是我要给你介绍的一个非常好用工具类std::enable_if。有了它之后我们可以这么使用:
template<typename T>
bool compare(typename std::enable_if<hasOperate<T>::value,T>::type t,T t2) {
return t < t2;
}
template<typename T>
bool compare(typename std::enable_if<!hasOperate<T>::value,T>::type t,T t2) {
return t >= 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[];
int *pointer = new int();
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 =;
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等。解决这个问题的方法有两个:
第二种方法,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 =;
int &r = i;
func<remove_cvRef<decltype<r>>(r); // 移除引用修饰,转化为int类型
关于类型推断相关的问题这里不多展开,但要特别注意由于类型修饰导致的匹配失败问题。
文章真的长呀,如果你能坚持看到这里,说明你是一个非常坚持且对编程有强烈兴趣的人,希望这篇文章让你在c++模板的路上有所帮助。
那么接下来我们再来回顾一下这篇文章的内容。
模板元编程他要解决的最核心的问题就是:对模板类型的判断与选择。而其所依赖的最核心的内容是模板函数重载匹配规则以及SFINAE法则,他是我们模板元编程得以实现的基础。需要注意,整个元编程发生在编译期,任何的函数调用都无法通过编译。其次需要类型的推断导致的匹配错误问题,而且此错误比较隐蔽难以发现。
最后,模板元编程十分强大,但涉及的相关内容多,容易出错。只有当我们十分确定要使用模板元编程解决的问题,再去使用他。切不可为了使用而使用,成为自己炫技的工具,这会给代码留下很多的隐患。