天天看点

C++模板和模板特例(防坑指南)

    大家好啊!逗比老师又和大家见面了!今天要给大家分享的是C++中的模板。不过并不是基础教程,而是以“避坑”为主。所以呢,可能更适合有一定C++基础的同学。当然了,如果你正在被这个恶心的C++模板困扰,那么,你来对地方了!

    那么首先,我们举一个栗子给大家吃(呸~~给大家听)。假如我们要做的事,是接受一个变量作为圆的半径,然后返回圆的面积。(emmm...这里栗子很逗比,原谅我实在想不出什么高大上的例子了,大家凑合看看吧,咳咳……)。但是,这个半径可能是用整数、浮点数、字符串等形式传进来的,我们要求返回值类型和传入的类型一直。这种需求自然是很适合用模板来完成的,于是,有了以下代码:

template <typename T>
T aera(const T &radium) {
    return M_PI * radium * radium;
}
           

    只不过这样写有个问题,我传整型、无符号整型、浮点型都是OK的,但是,传字符串就出问题了。因为如果T实例化为std::string的话,两个字符串是没有*运算的。所以,字符串需要单独适配的。有一种做法就是,为字符串单独写一个函数,和这个模板函数分开,比如这样:

std::string aera_str(const std::string &radium) {
    double r = std::stod(radium);
    double squ = M_PI * r * r;
    return std::to_string(squ);
}
           

    但是这种解决方法就有点逃避问题了,因为两个函数的函数名是不一样的,我们在使用的时候,还必须知道两个函数的存在,否则,很容易会调用aera<std::string>,甚至有时候还会由于自动类型推导调用了aera<const char *>,而这两个模板示例都是会报错的,因为std::string和const char *类型都是没有乘法运算的。

    那么,有没有一种方法是,我们还是使用aera这个模板函数,但是,当我们传入的是字符串的时候,单独写一个方法呢?答案是肯定的,那就是使用模板特例。

    顾名思义,模板特例就是有别于通用模板定义的一个特例,它针对于某一个特殊的类型,进行特殊的处理,而没有在特例中的则使用通用的模板方法。例如,我们想为std::string类型做一个模板特例,我们应该这样做:

template <typename T>
T aera(const T &radium) {
    return M_PI * radium * radium;
}

template <>
std::string aera<std::string>(const std::string &radium) {
    double r = std::stod(radium);
    double squ = M_PI * r * r;
    return std::to_string(squ);
}
           

    效果和上面的aera_str是相同的,但是,我们在调用的时候,就可以直接用aera<std::string>(),当模板参数是std::string的时候,就会调用下面的函数,而其他情况会调用通用的函数。

    貌似到此我们问题完美解决了。勤快的同学可能已经在自己的IDE上试验了,确实,这样做可以解决我们的问题。但是,这种表面上的美丽背后是一些列**疼的坑。

    假如我们这个求aera的功能是要在多个文件中使用的,我们就应当把它单独写到一个头文件中,然后,让需要的模块去include这个头文件。下面的代码我们将aera函数放在aera.hpp中,然后main.cpp和test.cpp都去调用它:

// aera.hpp
#ifndef aera_h
#define aera_h

#include <cmath>
#include <string>

template <typename T>
T aera(const T &radium) {
    return M_PI * radium * radium;
}

template <>
std::string aera<std::string>(const std::string &radium) {
    double r = std::stod(radium);
    double squ = M_PI * r * r;
    return std::to_string(squ);
}


#endif /* aera_h */
           
// test.cpp
#include "test.hpp"
#include "aera.hpp"

void test() {
    int a1 = aera<int>(5);
    std::string a2 = aera<std::string>("6");
}
           
// main.cpp
#include <iostream>
#include "aera.hpp"

int main(int argc, const char * argv[]) {
    
    int a1 = aera<int>(7);
    std::string a2 = aera<std::string>("2");
    
    return 0;
}
           

    每一个cpp文件编译都是OK的,但是,一旦链接,就会报错,报了一个重定义的错误。它会说,aera<std::string>这个函数被重定义了。什么?你在逗我吗?我明明就写了一份啊。哦!对了,好像我把这个写到头文件里了,因为引入头文件造成了再main.cpp和test.cpp中都有一份定义,于是重定义的。

    道理上说得通,但是我们会发现,如果仅仅把string处理的特例函数注释掉,保留原来的模板函数,这样就是OK可以链接通过的。这又是为什么呢?不写模板特例的话,难道就不会重定义吗?

    这个问题先放一下,既然是这个特例函数出了问题,那我们让它单独定义可不可以呢?做个实验,把string这个特例函数从头文件中拿出来,放到一个cpp文件中,这样可行吗?比如我们定义了aera.cpp,然后现在的代码是这样的:

// aera.hpp
#ifndef aera_h
#define aera_h

#include <cmath>
#include <string>

template <typename T>
T aera(const T &radium) {
    return M_PI * radium * radium;
}


#endif /* aera_h */
           
// aera.cpp
#include "aera.hpp"

template <>
std::string aera<std::string>(const std::string &radium) {
    double r = std::stod(radium);
    double squ = M_PI * r * r;
    return std::to_string(squ);
}
           
// test.cpp
#include "test.hpp"
#include "aera.hpp"

void test() {
    int a1 = aera<int>(5);
    std::string a2 = aera<std::string>("6");
}
           
// main.cpp
#include <iostream>
#include "aera.hpp"

int main(int argc, const char * argv[]) {
    
    int a1 = aera<int>(7);
    std::string a2 = aera<std::string>("2");
    
    return 0;
}
           

    这下我们会发现,直接在编译阶段报错了,而且main.cpp和test.cpp都报了同样的错,错误的信息是两个const char *类型的变量没有重载"*"运算。

    换句话说,它其实是调用到默认的函数模板了,没有找到这个特例。可能是因为没有找到test.cpp中的函数定义吧,如果我们把这个模板特例拿到main.cpp或者test.cpp中,那么,这个文件就正常了,另外那个文件就会编译失败。但是如果我们两个文件都放一份,其实也就相当于之前写在头文件中是一种效果了,也就是在链接时会报重定义错误。

    天哪!这玩意原来是个鸡肋啊!看似模板特例可以解决很多问题,怎么现在看起来这玩意没办法用啊!我总不能保证某一个特例只在一个cpp文件中使用,其他文件都不使用吧?所以到这一步,很多小伙伴就受不了了,改去使用最初的定义两个函数的方法了。

    不过别灰心,今天逗比老师就是来帮大家解决这个问题的!要想知道这个问题怎么解决,首先我们要先知道为什么会发生这样的情况。

    C++是一个静态类型语言,也就是说,所有的变量类型都是在编译的时候就确定好的,不能等到运行的时候再确定。因此C++无法支持反射机制,无法支持运行时等特性,同样,C++也不支持泛型编程。“什么?逗比老师你又逗比了!咱今天不就在讲模板吗?模板难道不是泛型吗?要是C++都不支持泛型,那还会有今天这些事啊?”别急!听我把话说完。所谓的“泛型编程”其实就是在编译的时候不能确定一些变量的类型,在运行时根据某些因素来决定的。因此,泛型其实已经就是动态类型了。既然C++是静态类型,那么一定无法支持泛型。而我们所谓的模板其实也就是泛型的代替品,让我在不能使用泛型的情况下,还能完成一些类似于泛型的事情。

    既然是叫“类似”,那么,肯定还是有本质区别的。即使我们使用模板,在编译阶段,所有变量的类型也都是确定的。这就意味着,你在写一个模板之后,是不能生成对应的机器码的。因为,编译器怎么知道这个模板以后会被实例化成什么类型呢?又不可能把所有的类型都生成一份,一来是浪费资源,二来自定义类型是未知的,无穷多的,不可能全部提前生成。于是,C++编译器想了个办法,就是你在写这个模板代码的时候,不做任何事情(当然,IDE还是会做一些静态检查的,只不过,内容非常有限,很多语法错误都没办法检测出来,比如这里的乘号,到底实例化后支不支持乘法,现在不知道)。然后,当你实例化这个模板的时候,再根据你传入的类型去生成对应的代码。

    换句话说,在我们的例子当中,这个aera<T>()的实现是不对应任何二进制代码的,只有当我们写了aera<int>()或者aera<std::string>()之后,才会生成对应类型的代码。怎么生成呢?就是根据写模板的标准来生成。这也就解释了一个问题,那就是,为什么所有模板的代码都需要写在同一个头文件中,不能把一部分(比如函数实现)放到cpp中。模板其实我们可以理解成一种特殊的宏,预编译阶段会进行替换,写宏总不能拆开写吧?

    那么,同样地,我们也就解释了,为什么模板函数写在头文件中,不会发生重定义问题。因为根本没有代码,而在每个文件 实际调用的时候,临时生成的一个类型(有点像匿名的结构体直接生成了一个变量那种效果),所以生成的代码都是局部的,对跨文件的部分不会产生影响。

   可是,为什么当我们写模板特例的时候,就出现问题了呢?是因为,模板特例本身其实已经不是模板了,类型既然已经确定了,那么,它应该成为一个普通函数才对。既然是普通函数,那么理所应当,声明放到头文件中,实现放到源文件中。当我们引入这个头文件时,就得到了通用模板的完整定义,以及,模板特例的函数声明。这样编译器就知道怎么去做了,如果是特例类型,因为已经有了模板特例的声明,那么,就会到其他cpp文件中找它的定义,最后链接起来;而如果不是特例类型,就会根据通用模板单独生成一个新的局部的类型然后使用。

    所以,我们正确的代码应该长下面这样:

// aera.hpp
#ifndef aera_h
#define aera_h

#include <cmath>
#include <string>

// 通用模板
template <typename T>
T aera(const T &radium) {
    return M_PI * radium * radium;
}

// 模板特例函数的声明
template <>
std::string aera<std::string>(const std::string &radium);

#endif /* aera_h */
           
// aera.cpp
#include "aera.hpp"

// 模板特例函数的实现
template <>
std::string aera<std::string>(const std::string &radium) {
    double r = std::stod(radium);
    double squ = M_PI * r * r;
    return std::to_string(squ);
}
           
// test.cpp
#include "test.hpp"
#include "aera.hpp"

void test() {
    int a1 = aera<int>(5);
    std::string a2 = aera<std::string>("6");
}
           
// main.cpp
#include <iostream>
#include "aera.hpp"

int main(int argc, const char * argv[]) {
    
    int a1 = aera<int>(7);
    std::string a2 = aera<std::string>("2");
    
    return 0;
}
           

    现在我们的代码已经可以正常运行了。

    所以使用模板要避开的坑就是,通用模板和模板特例要分别对待。对待通用模板要把它当做宏一样去对待,所有的内容都应该放在同一个头文件中,让需要的部分去包含它,在预编译阶段会替换成对应的代码。而对待模板特例,要把它当做普通的函数一样去对待,声明部分放到头文件中,实现部分放到单独的源文件中。(也就是说,通用模板的声明和模板特例的声明是不可以共用的!)

    当然,还有一种不太常遇见的情况就是,我们想定义一个模板,但是这个模板并没有通用的定义,而是只存在几个特例。比如说,我写的这个aera只想让它支持double和std::string类型,其他类型都不支持,并且,支持的这两种类型实现方法还不同。这种时候,可以使用两个模板特例加一个公用的模板声明。代码如下:

// aera.hpp
#ifndef aera_h
#define aera_h

#include <cmath>
#include <string>

// 模板声明(不含通用模板)
template <typename T>
T aera(const T &radium);

#endif /* aera_h */
           
// aera.cpp
#include "aera.hpp"

// 模板特例函数的实现
template <>
std::string aera<std::string>(const std::string &radium) {
    double r = std::stod(radium);
    double squ = M_PI * r * r;
    return std::to_string(squ);
}

template <>
double aera<double>(const double &radium) {
    return M_PI * radium * radium;
}
           

    在这种情况下,编译器发现模板声明是空的,就会知道,这个模板类型不含有通用实现,都是用特例来完成的,于是,就会按照普通函数的方式来编译了。注意!只有在通用模板是空的情况下,才可以省略模板特例的声明,否则,一旦省略了,就会在预编译阶段按照通用方式来生成代码,而不是去找实际模板特例对应的函数了。

    哦, 当然了,如果你在aera.hpp中还是把两个模板特例的声明给写上了,那也没什么问题。反倒是这样还更有利于代码可读性。那么,在这种通用模板为空的这种用法中,其实并不是用到了模板的特性,倒更像是写了一组重载函数而已,只不过把类型给显示写出来了。不过还是有一点区别,就是重载函数各是各的声明,而纯特例的模板函数可以共用一个声明。(这里有一点要注意,纯特例的模板中,每个模板的特例声明可写可不写,但是,即便所有的模板特例声明都写出来了,仍然需要保留那个空的通用模板声明,否则将会编译报错。可以把这个空的通用模板声明理解成一个函数簇,有了这个函数簇才能有里面的函数。)

    坑的地方已经给大家讲解完毕了,相信大家使用的时候可以避开这些坑了。不过模板语法确实比较奇怪,而且还分个通用和特例两种情况,写的时候确实容易把人搞晕,接下来逗比老师就给大家展示一些实例,模板函数的例子上面已经有了,下面是一些包括模板类、模板成员函数以及它们的特例的写法,供参考:

    模板类、成员模板函数,以及特例的写法示例

// example.hpp
#ifndef example_hpp
#define example_hpp

// 通用模板
template <typename T>
class Example {
public:
    // 模板类中的普通函数
    void test();
    // 模板类中的模板函数(嵌套的模板函数)
    template <typename S>
    S test2();
};

// 通用模板的函数实现(可以写到类外,但是不能写到别的文件去)
template <typename S> // 类型名可以与之前的不同,但是需要对应
void Example<S>::test() {
}

// 通用模板中的模板函数(同样,不能写到别的文件)
template <typename T>
template <typename S>
S Example<T>::test2() {
    return 0;
}

// 模板特例声明(相当于普通类)
template <>
class Example<int> {
// 这里甚至都可以写和通用模板毫无关系的实现
public:
    // 一个普通函数(相当于普通成员函数)
    void another_test();
    // 模板特例中的模板函数(相当于普通的模板函数)
    template <typename T>
    void test2();
};

// 模板函数就必须在当前文件里定义
template <typename T>
void Example<int>::test2() {
}

#endif /* example_hpp */
           
// example.cpp
#include "example.hpp"

// 模板特例函数实现
// 注意这里,类是个模板,但是函数不是,所以这里不需要加template <>
void Example<int>::another_test() {
}
           

    总之就是把持住一点,只要是模板,就要像宏一样写在同一个文件中,如果是特例,就要像普通函数(或者类)一样,声明写在头文件中,实现写在源文件中。

    在以前,我们只能把一个模板实例进行重命名,比如:

// 重命名一个模板实例
typedef std::vector<int> Vec_int;
           

    但是对于模板类型,或者不完全实体化的模板类型就不可以了。不过在C++11以后,就可行了,例如下面的例子:

// 重命名一个模板类型
template <typename T>
using Vec = std::vector<T>;

// 重命名一个部分实例化的模板类型
template <typename T>
using Map = std::map<int, T>;
           

    好啦!以上就是关于C++模板的全部内容了。如果同学们还有什么问题,欢迎留言,欢迎讨论!

【本文为原创文章,归逗比老师全权拥有,允许转发,但请在转发时注明“转发”字样并注明原作者。请勿恶意复制或篡改本文的全部或部分内容。】

继续阅读