目录
模板的基本概念
函数模板(模板的用法)
基础用法
模板的实例化
内容补充
类模板
非类型模板参数
模板特化
模板的分离编译
注意事项
模板(Templates)是一种泛型编程(Generic Programming)技术。泛型编程的核心思想是编写能够处理不同类型数据的通用代码,而不必为每种数据类型都编写独立的代码。这使得代码更加灵活、可重用和高效。模板可以在编写代码时将类型参数化,允许我们在不同的数据类型上执行相同的操作,从而提高了代码的通用性。它们在很多情况下都可以代替手动编写相似的代码,从而减少了冗余代码的数量。
然而,模板编程也可能引入一些复杂性。编译器在实例化模板时会生成特定类型的代码,因此模板可能导致编译时间增加。同时,错误消息也可能变得较为晦涩,因为模板的错误信息通常在编译器实例化时才会显示。而C++中的模板有两种主要形式:函数模板和类模板。
下面我们就以函数模板为基础来讲解C++中模板的一些基础用法,接着再介绍一下类模板以及其它的一些用法。
函数模板允许我们定义通用的函数,其中的参数类型是参数化的。这样,我们可以编写一次函数代码,然后在不同类型的参数上调用该函数,而无需为每种参数类型都编写一个单独的函数。函数模板使得我们能够编写一次算法代码,然后用于不同类型的数据。
template
返回值 函数名(参数列表)
{
// 函数体
}
/* 注释:
这里的typename也可以用class,typename是C++11标准支持的。
*/
用法示例
// 一个模板参数
template
void add(T& a, T& b)
{
return a + b;
}
// 两个模板参数
template
void add(T1& a, T2& b)
{
return a + b;
}
函数模板本身并不是函数实体,是编译器根据具体的使用方式产生特定类型函数的模具。所以模板其实就是将本来应该我们做的重复的事情(比如定义多个类似的函数重载)交给了编译器。
在编译器的编译阶段,编译器会根据传入的实参类型来生成对应类型的函数实体以供调用。比如,当用double类型使用函数模板时,编译器通过对实参类型的推演,将T确定为double类型,然后生成一份专门处理double类型的代码,这样我们在使用double类型代码时就会找到对应的代码入口,而不是要找函数模板的位置。也就是说,当我们使用了两种类型的模板时,比如T为int和T为double的情况,这两种其实生成的是两个代码,只不过这些工作都由编译器为我们做了。
也就是说,模板的定义就相当于是一个模具,传入的类型数据就相当于是模具中的材料,根据不同的类型生成实体的过程就相当于是根据不同的材料在同一个模具中生成指定物品。而且在生成的可重定位目标文件(.o或者.obj文件)中,是没有模板的定义的,这就像是灌注好物品之后,摸具就被搁置一旁了,我们要用的是灌注好的物品,这个模板只是一个中间工具罢了。也就是说,模板就相当于是一个中间工具,它负责在编译阶段生成一些指定类型的函数/类的实体,然后它的任务就完成了,所以在生成的可重定位目标文件中就没有必要再存放着模板的定义了。
用不同类型的参数使用函数模板就被称为函数模板的实例化。模板参数实例化分为隐式实例化和显式实例化。隐式实例化,让编译器根据实参推演模板参数的实际类型;显式实例化,在函数名后的<>中指定模板参数的实际类型。
假定有如下的类模板:
template
T Add(const T& left, const T& right)
{
return left + right;
}
那么对应的示例如下:
Add(7,8); // 隐式实例化
Add(2,5); // 显示实例化
// 隐式实例化可能会出现类型自洽,比如下面这种
Add(10, 3.14159); //error
// 相应的问题显示实例化就不大容易出现
// 因为果类型不匹配,编译器会尝试进行隐式类型转换,如果无法转换成功,编译器将会报错
// 下面这句代码就是3.14向指定的int算数转换,且可以转换成功,故代码正常运行
Add(20, 3.14); //OK
- 一个非模板函数可以和一个同名的函数模板同时存在,而且该函数模板还可以被实例化为这个非模板函数。
- 匹配规则大致为:先匹配非模板的,如果没有就尝试匹配特例化的模板。如果也没有就尝试匹配模板,进而通过模板实例化出具体的类(函数)实体。
- 模板函数不允许自动类型转换,但普通函数可以进行自动类型转换。
示例如下:
// 专门处理int的加法函数
int Add(int left, int right)
{
return left + right;
}
// 通用加法函数
template
T Add(T left, T right)
{
return left + right;
}
void Test()
{
Add(1, 2); // 与非模板函数匹配,编译器不需要特化
Add(1, 2); // 调用编译器特化的Add版本
}
// 专门处理int的加法函数
int Add(int left, int right)
{
return left + right;
}
// 通用加法函数
template
T1 Add(T1 left, T2 right)
{
return left + right;
}
void Test()
{
Add(1, 2); // 与非函数模板类型完全匹配,不需要函数模板实例化
Add(1, 2.0); // 模板函数可以生成更加匹配的版本,编译器根据实参生成更加匹配的Add函数
}
类模板允许我们定义通用的类,其中的数据类型和一些行为是参数化的。通过在实例化时提供具体的类型参数,我们可以创建一个特定类型的类。这使得我们能够编写一次代码,然后在多种不同数据类型上使用。类模板允许我们创建具有通用行为的类,而不必为每种类型都创建一个新类。
写法格式
template
class 类模板名
{
// 类内成员定义
};
/* 注释:
这里的typename也可以用class,typename是C++11标准支持的。
*/
类模板的实例化
类模板与函数模板基本类似,所以就不再展开过多篇幅叙述了。只不过类模板的实例化不同于函数模板的实例化,类模板的实例化只能显示实例化,即使用时必须显示的指定类型,编译器无法对类模板进行类型推导。
注意事项
对于模板类来说,类的名称叫做类名,具体的模板类才叫做类型。例如,设有如下的类:
template
class MyTemplateClass
{
// ... code
}
// 那么,MyTemplateClass就是类名,形如 MyTemplateClass 的才叫做类型。
C++11之后支持非类型模板参数,非类型模板参数即模板参数不是类型而是一些常量数据,这些常量数据在类(函数)中直接使用。用法示例如下:
template
void func(……)
{
……
}
注意事项:
- 非类型的模板参数必须在编译期就能确定其值。比如函数内的局部变量、堆区申请的动态内存、临时变量等都不能作为非类型模板参数。
非类型模板参数一般为如下类型:整型常量/枚举、指向对象/函数/成员变量的指针、对象/函数的左值引用、std::nullptr_t等。特别的,当传递对象的指针或者引用作为模板参数时,对象不能是字符串常量,临时变量或者数据成员以及其它子对象。由于C++17之前,C++版本的每次更新都会放宽以上限制,因此还有针对不同版本的限制:
在 C++11 中, 对象必须要有外部链接。
在 C++14 中, 对象必须是外部链接或者内部链接。
补充,这些连接属性的相关内容参考:C++ 符号的可见性。特别的,C++中const是具有内部链接属性的(结论源自:const的内部链接属性)
使用 auto 推断非类型模板参数
此部分内容节选自:c++11-17 模板核心知识(三)—— 非类型模板参数
从 C++17 起,可以使用 auto 推断非类型模板参数:
template
void f() { } f<10>(); // deduces int 如果没有 auto,想将非类型模板参数的类型也当做模板参数,那么必须声明两个模板参数:
template
constexpr Type TConstant = value; constexpr auto const MySuperConst = TConstant ; 从 c++17 开始,只需要一个 auto 即可:
template
constexpr auto TConstant = value; constexpr auto const MySuperConst = TConstant <100>; 在 auto 推导的的情况下获取类型:
template
T foo(); 或者:
template
struct Value { using ArgType = decltype(Val); };
template
也是可以的,这里 N 会被推断成引用类型:template
class C { ... }; int i; C<(i)> x; // N is int&
通常情况下,使用模板可以应对处理大多数的情况,但对于一些特殊类型的情况,比如一些指针,可能就需要特殊处理了,这时就需要用到模板特化了。
我们知道,模板就是在代码运行时为我们
模板特化的大致过程如下:将想要特化的参数从模板参数上“剥离”下来,用一个特化之后的用尖括号括起来的参数列表,放在下面的类/函数名后面。而且对于偏特化的情况,没有被特化的模板参数也要写上去。写法示例如下:
template
class myclass
{
……
};
// 特化T1为int,T2依旧是模板
typename
class myclass
{
……
}
函数模板特化
要点概述:
- 函数模板不支持偏特化(non-class, non-variable partial specialization is not allowed)。
- 特化之后的模板参数列表要紧跟函数名,位于函数参数之前。
- 一般情况下,人们都不会选择使用函数模板特化,而是直接定义对应的函数。
- 函数的匹配原则:先匹配非模板的函数,如果没有就尝试匹配特例化的模板,如果也没有就尝试匹配模板,然后通过模板实例化出具体的类(函数)实体。
写法示例:
template
void func(T val)
{
……
}
// 对func函数进行特化
void func(int val)
{
……
}
// 一般情况下,不会选择使用函数模板特化,而是直接定义对应的函数
void func(int val)
{
……
}
类模板特化
要点概述:
- 类模板的特化分为全特化和偏特化。全特化就是类模板的所有参数都特化,偏特化就是将部分参数特化。
- 偏特化时,也要写全特化的参数列表,未特化的部分就继续用模板,例如:
// 偏特化 template
class person { …… }; - 偏特化不仅是指特化部分参数,也是针对模板参数更进一步的条件限制所设计出来的一个版本,例如下面的这段代码就是针对指针类型设计的特化:
//两个参数偏特化为指针类型 template
class Data { …… };
写法示例:
template
class person
{
public:
void show()
{
cout << "template" << endl;
}
};
// 全特化
template<>
class person
{
public:
void show()
{
cout << "class person" << endl;
}
};
// 偏特化
template
class person
{
public:
void show()
{
cout << "class person__T1" << endl;
}
};
在学习模板之前,我们经常习惯把方法的声明和定义分离编译,即把方法的声明都放到一个头文件中,把方法的定义都放到另一个文件中,这样做能够有效避免函数或类的重复定义等问题。
但在模板这里,分离编译是会报错的,例如运行下面这段代码(忽略相关的头文件包含):
//---------------test.h-------------------//
// 模板的声明
template
class myclass
{
public:
void test();
};
//---------------test.cpp--------------//
// 模板的定义
template
void myclass::test()
{
std::cout << "test over!" << std::endl;
}
//---------------main_fun.cpp--------------//
int main()
{
myclass().test();
return 0;
}
这里就是很典型的将模板的声明和定义分离,但编译运行时会出现"未识别标识符"的错误。对此的解释如下:
C++的每个文件都是单独编译的,所以如上的main_fun.cpp文件和test.cpp文件在链接阶段之前是相互独立的(编译和链接相关的内容参考:浅析编译和链接)。而test.cpp内只有模板的定义,没有具体的方法实例,所以在汇编阶段结束以后,生成的可重定位目标文件是没有这个模板的定义的(参考"模板的实例化"部分)。那么接下来的链接阶段,链接器在执行符号表的合并和重定位相关的操作时,就会发现main函数中调用的"myclass
().test()"没有定义,也就是说找不到具体的函数实体,所以就会出现"未识别标识符"的错误。
上述部分的内容参考:为什么C++编译器不能支持对模板的分离式编译-CSDN博客
那么综上所述,为什么分离编译不行呢?简单来说就是因为没有实体化的方法,找不到方法的定义,进而编译器会出现"未识别标识符XXX"的错误。
这种问题我们有如下两个方法可以解决:
- 定义出方法的实体,也就是模板的特例化。例如把上面的test.cpp文件改为
那么此时再调用T为int的方法就不会出现报错了。但是,这样有一个明显的缺陷,就是如果当T为double或者其它非int的类型时,就又不行了。所以这种方法仅限于学习,不适合使用。首先是如果我们想要正常使用模板的话,就需要把所有类型的方法都特例化一遍,光是效率方面就已经大打折扣了,而且还无法做到适配一些未知的自定义类型等情况。而且,这样做的就使得模板原本的目的和初衷毫无意义。// int的模板实例,有实体,不会报错 template<> void myclass
::test() { std::cout << "test over!" << std::endl; } - 干脆就不要分离编译,将模板的声明和定义都放到同一个文件中。这样做之所以可行,是因为头文件中就存在模板的定义,在编译阶段,如果有涉及到的方法,那么就会紧接着生成对应的类/函数特例化的实体,所以就不会出现对应的"未定义标识符XXX"等问题,因为编译阶段就已经有了方法实体了。简单来说,为什么声明和定义放在一个文件可行?因为有了方法实体,有具体的定义。
.hpp文件
如果在C++的头文件中存在声明和定义放在一起的模板的话,一般会选择使用.hpp作为后缀。这个.hpp后缀其实.h后缀并没有任何实质性的区别,只有命名的区别,只是为了起到区分作用。
模板只有在使用时才会创建实体
模板的定义并不是这个类或者函数的实体,只有在使用时,编译器才会为我们生成对应的类或者函数实体。也就是说,编译器其实就相当于是一个工具人,把我们需要手动定义的很多功能类似的类或函数的任务通过模板的形式解决掉,当我们需要模板为哪个类型的时候,编译器就为我们生成对应的实体。所以模板在定义时会有一些隐含的错误不会被发现,这是因为类模板或者函数模板在定义时是没有实体的,所以此时只会进行语法检查,并不会进行很严格的检查。所以很多时候我们在定义模板的时候并没有报错,但具体使用时却会出现莫名其妙的报错。
typename相较class的特殊功能
class和typename在模板参数的声明使用时是没有区别的,但typename相较class还有一个额外的功能,就是可以声明内嵌类型。内嵌类型是指在一个类型内部定义的类型。有时编译器无法识别内嵌类型,这是因为编译器无法确定在模板参数化或嵌套作用域中,一个标识符是否代表一个类型,因此需要显式使用 typename 来帮助编译器理解其含义。下面,让我们结合例子来理解:
#include
using namespace std;
// 用于设置内嵌类型的class
template
class MyCls
{
public:
using type = T;
};
// 用于测试typename的class
template
class FunCls
{
public:
//此时没有typename声明,编译器可能正常无法把MyCls::type当作类型来处理
MyCls::type val;
};
int main()
{
FunCls obj;
return 0;
}
当对这段代码进行编译时,编译器会出现报错。而当为其加上typename时就可以正常运行了。
//有了typename声明,此时编译器就理所当然的将MyCls::type当作一个类型来处理
typename MyCls::type val;
简而言之,typename声明内嵌类就是编译器不认识当前的这个东西,编译器不知道这是变量还是类型,然后加一个typename就是告诉编译器这是个类型,让编译器将其当作类型来处理。