c++元编程模板函数重载匹配规则示例详解

C/C++
253
0
0
2023-07-20
目录
  • 前言
  • 开始
  • 模板函数重载匹配规则
  • 模板匹配规则
  • 使用
  • 最后

前言


模板元编程,是一个听起来非常硬核的概念,会感觉这个东西非常的难,是大佬才能掌握的内容。而事实上,他也确实不简单(手动狗头),但是也并没有想象中的复杂。

我们对很多事物,都喜欢加上“元”的概念,如学习,指的是学习知识,比如学习数学。而元学习,指的是学习学习本身,去学习如何更好地学习,也就是提升学习能力。所以“元”概念,在很多时候值得就是把关注对象回到本身,比如上面的例子,把关注对象从数学等知识回到学习本身。

模板编程,指的是可以我们可以将函数或者类的数据类型抽离出来,做到类型无关性。我们关注的对象,是普通函数、普通类。如下面的这个经典的模板函数:

template<typename T>
bool compare(T t,T t2) {
  return t > 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()函数,即使我们的代码只会运行其中一个路径。其次:

  • 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;  // 输出
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对象的的指针

我们暂时先不管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;

代码中有一个关键字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等。解决这个问题的方法有两个:

  • 在具体化中,增加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 =;
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》吧。