【C++】C++之模板入门
目录
1. 模板
1.1 模板概念
函数模板是一个蓝图,它本身并不是函数,是编译器用使用方式产生特定具体类型函数的模具。所以其实模板就是将本来应该我们做的重复的事情交给了编译器。
模板也是我们泛型编程的基础。泛型编程就是编写与类型无关的通用代码,是代码复用的一种手段。
而我们的模板分为:函数模板、类模板。
1.2 模板语法
语法:template < typename T1, typename T2.... typename Tn >
以前的时候typename写的是class,所以这个typename和class可以互换,两个混在一起也行。但是不建议这样写,最好统一起来。
我们的typename会自动根据我们传入的实参来推断这个typename或者class到底是什么。
但是有一个场景我们需要注意:
template <typename T>
T createDefault()
{ // 模板参数T未出现在函数参数中
return T(); // 返回T类型的默认值
}
// 调用时必须显式指定T,否则编译器无法推导
int a = createDefault<int>(); // 正确:显式指定T=int
// int b = createDefault(); // 错误:无法推导T的类型
在这个场景当中,如果模板参数T没有出现在形参,编译器是不知道返回什么类型的。前面提到的自动推导是因为我们传入了实参,但是这里没有,所以我们得显式的去指明数据类型。
对于函数模板而言,我们完全可以传了一个int类型,一个double类型上去。前提是我们实参有多少,模板的里头的typename有多少。
1.3 模板实例化
1.3.1 自动实例化
就是我们前面所说的,当你调用函数模板时,编译器会根据实参的类型自动推导 T 的值,并生成对应类型的函数代码。而这个过程就是自动实例化了。
如下:
template <typename T>
void swap(T& a, T& b)
{
T temp = a;
a = b;
b = temp;
}
int main()
{
// 1. 调用时传入int类型,编译器自动实例化 swap<int>
int x = 10, y = 20;
swap(x, y); // 触发 swap<int> 的实例化
// 2. 调用时传入double类型,编译器自动实例化 swap<double>
double m = 3.14, n = 9.8;
swap(m, n); // 触发 swap<double> 的实例化
return 0;
}
这个过程中,编译器做了什么呢? 当你调用 swap(x, y)(x、y 是 int),编译器会自动生成一份针对 int 的 swap 函数:
// 编译器自动生成的 swap<int>(你看不到源码,但会进入可执行程序)
void swap<int>(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
同理,调用 swap(m, n) 时,会生成针对 double 的 swap<double> 函数。
1.3.2 显式实例化
显式实例化其实就是一个主动指定的一个过程。
当函数模板的 T 无法通过实参推导(比如 T 没出现在函数参数中),或你想提前生成某类的代码时,需要手动指定 T 的类型,强制编译器实例化。
示例:
// 函数模板:返回T类型的默认值(T未出现在参数中,无法自动推导)
template <typename T>
T createDefault()
{
return T(); // int默认0,double默认0.0,string默认空串
}
// 显式实例化:主动让编译器生成 createDefault<int> 的代码
template int createDefault<int>();
int main()
{
// 调用显式实例化后的函数
int a = createDefault<int>(); // 正确:使用已实例化的 createDefault<int>
return 0;
}
而这个过程也正是我们在模板语法中提到的需要显式的去指明数据类型。
1.3.3 按需实例化
按需实例化也叫做隐式实例化。简单说:编译器只会为 “真正被使用到的模板类型 / 成员” 生成代码,没用到的部分绝不浪费精力。
类比生活场景:你有一个 “ 万能蛋糕模具 ”(模板),可以做巧克力味、草莓味、抹茶味的蛋糕(不同类型的实例)。但你不会一次性把所有口味都做出来,而是客人点了哪种口味,你才做哪种(按需生成)。
例如:
template <typename T>
void print(T value)
{
cout << "Value: " << value << endl;
}
int main()
{
print(10); // 调用int类型,触发实例化print<int>
print(3.14); // 调用double类型,实例化print<double>
// 注意:string类型的print从未被调用,所以编译器不会生成print<string>的代码
return 0;
}
其实这个按需实例化其实就是我们模板正常的工作方式,不是什么特别的东西。
1.4 函数模板
语法:
template<typename T> // 或者 template<class T>
返回类型 函数名(参数列表)
{
// 函数体
}
template <typename T>
void swapValues(T& a, T& b)
{ // 用 T& 避免拷贝,且修改能影响外部
T temp = a;
a = b;
b = temp;
}
要传两个不同类型的参数,就得用两个人typename了。
1.5 类模板
语法如下:
template <typename T> // 模板参数声明(T为类型占位符,可替换为class)
class 类名
{
public:
// 成员函数(可使用T作为类型)
返回值类型 函数名(参数列表);
private:
// 成员变量(可使用T作为类型)
T 变量名;
};
// 类模板的成员函数必须在类外声明时,需重复模板参数
template <typename T>
返回值类型 类名<T>::函数名(参数列表)
{
// 函数实现
}
你需要一批 “箱子” 来装东西,但要装的东西类型不一样:
-
装苹果(
Apple类型) -
装书本(
Book类型) -
装手机(
Phone类型)
// 专门装苹果的箱子
class AppleBox
{
private:
Apple item; // 只能放Apple
public:
void put(Apple a) { item = a; } // 只能放Apple
Apple take() { return item; } // 只能取Apple
};
// 专门装书本的箱子(复制粘贴AppleBox,改了Apple→Book)
class BookBox
{
private:
Book item; // 只能放Book
public:
void put(Book b) { item = b; } // 只能放Book
Book take() { return item; } // 只能取Book
};
// 专门装手机的箱子(又复制粘贴,改了Apple→Phone)
class PhoneBox
{
// ... 逻辑完全一样,只是类型变了
};
但是我们如果有了模板的话:
// 类模板:万能箱子,T是要装的东西的类型
template <typename T>
class Box
{
private:
T item; // 可以放T类型的任何东西
public:
void put(T thing)
{ // 放T类型的东西
item = thing;
}
T take()
{ // 取T类型的东西
return item;
}
};
Box<T> 是基于类模板 Node 生成的 “具体类型”—— 当你用 Box<int>、Box<string> 这样的形式指定 T 时,Box<T> 就成为了一个实实在在的类。
可以这样理解:
-
Box是 “模板”(类似饼干模具); -
T是 “原料类型”(如巧克力、黄油); -
Box<T>是 “用模具 + 原料做出的具体产品”(如巧克力饼干、黄油饼干)。
但是我们的类模板也分为类内定义和类外定义,类内的话Box< T > 和 Box 是一样的,编译器认得出来,但是类外定义就不能这样了。
1.5.1 类内定义
template <typename T>
class Box
{
private:
T content;
public:
// 类内直接实现
void put(T item)
{
content = item;
}
T get()
{
return content;
}
};
1.5.1 类外定义
template <typename T>
class Box
{
private:
T content;
public:
// 仅声明
void put(T item);
T get();
};
// 类外实现
template <typename T>
void Box<T>::put(T item)
{
content = item;
}
template <typename T>
T Box<T>::get()
{
return content;
}
1.6 模板实例
template<typename T>
inline bool is_Valid(const T& val)
{
return true;
}
inline bool is_Valid(const string& val)
{
return !val.empty();
}
template<typename T>
class Node
{
public:
Node(const T& val) :
next(nullptr)
{
if (is_Valid(val))
{
this->val = val;
}
else
{
throw invalid_argument("这不是一个有效的值。");
}
}
~Node() = default;
private:
T val;
Node<T>* next;
};
如上就是通过模板写了一个链表,它可以存整形,浮点型,甚至是字符型的数值val。假设我们输入了一个string类的val,那么初始化就会检查,然后定位到全局函数的专门检查string的is_Valid函数。但是模板函数不也可以转换成string吗?编译器怎么知道用谁?原来因为我们有单独的string判断函数所以会优先调用,如果输入int类型,编译器就会因为找不到就去用模板函数。而这就牵扯到了 C++ 重载决议了。
但是肯定会有个疑问就是为啥不这么写,
template<typename T>
class Node
{
...
private:
// 1. 通用版本(参数为T类型)
bool is_Valid(const T& val) const
{
return true;
}
// 2. 字符串版本(参数为string类型)
bool is_Valid(const string& val) const
{
return !val.empty();
}
};
这么写最大的问题就是函数重载根本实现不了!如果我输入了一个string类型的数值,那么我的类模板专门的判断函数就会自动转换成判断string类,那这样的话我的两个函数都是针对string类的,一个返回真,一个判断不为空,编译器根本不知道用谁。
但是我们把那两个函数不设置成成员函数,做成类模板内的函数也是可以的,但是我们就需要用一个新的知识,也就是模板特化了。
1.7 模板特化
我们之所以需要特化,是因为类模板的成员函数无法通过简单的重载来区分 “通用类型” 和 “特定类型(如 string)”,而特化能为特定类型单独定制逻辑,避免冲突。
我们模板实例当中就是这个问题,当我们输入string类的val后,发现函数重载失败了!通用类型和特定类型有冲突。那么模板特化怎么解决这个问题呢?
模板特化的作用:为特定类型 “量身定制” 类。
1.7.1 全特化
全特化就是即 “完全特化”,为模板的所有模板参数指定具体类型,是对模板的 “彻底定制”。
特点:
-
必须为所有模板参数指定具体类型(如果有多个参数)。
-
全特化版本是一个独立的类 / 函数,与通用模板无继承关系,但调用时会被优先选择。
因此解决如下:
// 1. 通用模板(处理int、double等非string类型)
template<typename T>
class Node
{
...
private:
bool is_Valid(const T& val) const { return true; } // 通用验证
public:
Node(const T& val) { /* 使用通用验证 */ }
};
// 2. string类型的特化版本(单独实现)
template<> // 特化标记
class Node<string>
{ // 明确指定为string类型
private:
bool is_Valid(const string& val) const { return !val.empty(); } // 字符串验证
public:
Node(const string& val) { /* 使用字符串验证 */ }
};
当然了,模板特化的方式肯定要更加标准和通用,只是对于实例的问题而言,模板特化相对设置成全局函数没有看着那么简单罢了。
最终解决如下:
template<typename T>
class Node
{
public:
Node(const T& val) :
next(nullptr)
{
if (is_Valid(val))
{
this->val = val;
}
else
{
throw invalid_argument("这不是一个有效的值。");
}
}
~Node() = default;
private:
T val;
Node<T>* next;
inline bool is_Valid(const T& val)
{
return true;
}
};
template<>
class Node<string>
{
public:
Node(const string& val) :
next(nullptr)
{
if (is_Valid(val))
{
this->val = val;
}
else
{
throw invalid_argument("输入字符为空。");
}
}
private:
inline bool is_Valid(const string& val)
{
return !val.empty();
}
string val;
Node<string>* next;
};
我这个为什么是全特化?
全特化的核心特征是:为模板的所有模板参数都指定了具体类型,你的代码完全符合这一点:
-
通用模板
Node<T>有一个模板参数T -
特化版本
Node<string>为T明确指定了具体类型string(覆盖了所有模板参数)
因为:
-
有明确的全特化标记
template<>(尖括号中为空,表示所有参数都已确定) -
类名明确指定为
Node<string>,将唯一的模板参数T替换成了具体类型string -
特化版本是一个独立的类实现,为
string类型定制了专属逻辑(字符串非空验证)
1.7.2 偏特化
偏特化(Partial Partial Specialization)是 C++ 模板中一种灵活的定制机制,核心思想是:不对模板的所有参数做完全固定,而是对部分参数进行限制(或指定部分参数),让模板能针对 “某一类相关类型” 定制逻辑。
一句话总结:全特化是 “完全定制一个具体类型”,偏特化是 “定制一类相关类型”。
偏特化的两种常见形式:
-
限制参数的 “形式”(最常用)
针对 “指针、引用、数组、容器” 等特定形式的类型进行定制,不管它们具体指向什么类型。
示例:为所有指针类型定制逻辑
// 通用模板(处理普通类型,如int、string)
template<typename T>
class Box
{
public:
Box(T val)
{
cout << "存储普通类型: " << val << endl;
}
};
// 偏特化:处理“任意类型的指针 T*”(不管T是int还是string)
template<typename T> // 仍保留模板参数T(指针指向的类型)
class Box<T*>
{ // 重点:T* 表示“所有指针类型”
public:
Box(T* val)
{
if (val == nullptr)
{
throw runtime_error("指针不能为null!");
}
cout << "存储指针,指向的值: " << *val << endl;
}
};
-
固定多参数模板中的部分参数
当模板有多个参数时,固定其中几个,剩下的参数仍保持通用。
示例:双参数模板的偏特化
// 通用模板(两个参数:T和U)
template<typename T, typename U>
class Pair
{
public:
Pair(T a, U b)
{
cout << "通用Pair: " << a << ", " << b << endl;
}
};
// 偏特化:固定第二个参数为int,第一个参数仍通用
template<typename T> // 只保留第一个参数T
class Pair<T, int>
{ // 第二个参数固定为int
public:
Pair(T a, int b)
{
cout << "偏特化Pair(第二个参数是int): " << a << ", " << b << endl;
}
};
1.8 模板实例(续)
1.8.1 多类型构造函数
那么如果有一个新的Link_Node类,需要开始做链表的操作的时候怎么办呢?
解决方案如下:
template<typename T>
class Link_Node
{
public:
Link_Node() :
head(new Node<T>(T())){ }
template<>
Link_Node<string>() :
head(new Node<string>("#")) {}
~Link_Node()
{
delete head;
}
private:
Node<T>* head;
};
我们这个时候有一个通用模板的构造函数,又有一个特化模板,也就是针对于字符串类型的构造函数。为什么我们会需要它呢?因为如果我们只有上面那个通用模板的构造函数,很明显字符串检查永远都过不了。我们仔细看看那个构造函数。
通过列表初始化,我们希望给head,new一个Node< T >类型的数据,然后把我这个模板里头的类型T传给Node< T >。(注意,T()是 C++ 标准定义的 “值初始化”(Value Initialization)语法,行为由类型本身决定。 因为我们用的基本上是内置数据类型,他们的构造函数都被写好了。当然了,如果我们传的是类模板,我们类的构造函数我们自己其实也写好了。)
但是呢如果是string类型数据,我们的构造函数T( ),里头可是空的,字符串判断函数那里永远都过不了,永远判断为空。因此才需要特别的给模板特化类型去写一个构造函数。当然了,为了让字符串判断函数能有效判断,我们自己手动加一个“ # ”占位符就好(小tips:#不是一个有效值,核心作用是 满足节点的有效性校验,同时不干扰实际业务数据。),反正哨兵节点我们遍历也好,写实际逻辑也好,我们都是默认从第一个节点也就是哨兵节点的下一个节点开始的 。不过如果我们还要去新建一个可能会用到Node类成员数据的类,也就是需要模板特化的时候一样也需要有两个构造函数,一个通用模板的,一个模板特化的。
当然了,对于这个全特化而言,我们更好的写法其实是模板全特化的这个类型类内声明,类外定义。
1.8.2 何时需要模板特化
通用模板构造函数里给Node<T>传的默认值,能不能通过Node<T>自己的is_Valid校验函数。如果通不过,就需要特化;如果能通过,就不需要特化。
正如实例中的问题一样,通用的模板类的检查中其他数据类型和string的检查出现了冲突,实在没办法所以才需要这个模板特化,当然了,如果不冲突的话,模板特化其实也是不需要的。
例子 1:处理字符串的特殊需求
通用模板对所有类型 “一视同仁”,但字符串需要特殊对待:
// 通用用模板:判断值是否有效(默认都有效)
template<typename T>
bool is_valid(T val) {
return true; // 对int、double等都没问题
}
// 特化:字符串不能为空(通用模板搞不定,单独定制)
template<>
bool is_valid(string val) {
return !val.empty(); // 只对string生效的特殊逻辑
}
// 使用
int main() {
cout << is_valid(0); // 通用逻辑:true(0是有效int)
cout << is_valid(""); // 特化逻辑:false(空字符串无效)
cout << is_valid("hello"); // 特化逻辑:true(非空有效)
}
例子 2:不同类型的 “加法” 不一样
通用模板的 “加法” 对数值合适,但字符串需要拼接:
// 通用模板:数值相加
template<typename T>
T add(T a, T b) {
return a + b; // 对int:1+2=3,正确
}
// 特化:字符串拼接(通用模板做不到,单独定制)
template<>
string add(string a, string b) {
return a + b; // 对string:"a"+"b"="ab",正确
}
// 使用
int main() {
cout << add(1, 2); // 通用:3
cout << add("hello", "!"); // 特化:"hello!"
}
例子 3:自定义类型的特殊规则
自定义的 “正整数” 类型,不能用通用模板的默认值。
// 自定义类型:只能是正数
struct PositiveInt {
int num;
PositiveInt(int n) : num(n) {}
};
// 通用模板:用0初始化(对int可以,但对PositiveInt不行)
template<typename T>
T get_default() {
return 0; // int的默认值0是合法的
}
// 特化:PositiveInt必须用正数初始化(通用模板的0无效)
template<>
PositiveInt get_default() {
return PositiveInt(1); // 单独定制一个合法的默认值
}
// 使用
int main() {
int a = get_default<int>(); // 通用:0(合法)
PositiveInt b = get_default<PositiveInt>(); // 特化:1(合法,避免了0)
}
核心:
当通用模板的逻辑,对某个类型 “不合适”“不工作”“不符合需求” 时,就用特化给这个类型单独写逻辑。
1.8.3 模板全特化标准写法
在Link_Node类内部写template<> Link_Node<string>(),本质是试图直接特化整个string版本的类,这不符合 C++ 标准 —— 类模板内部只能定义通用逻辑,不能直接嵌入特化类的定义,部分编译器(如 GCC)会直接报错
更稳妥的写法是采用 “类模板全特化”,即单独为string类型重新定义整个Link_Node类,示例如下
// 通用模板类
template<typename T>
class Link_Node
{
public:
Link_Node() : head(new Node<T>(T())) {}
~Link_Node() { delete head; }
private:
Node<T>* head;
};
// 对string类型全特化
template<>
class Link_Node<string>
{
public:
// 特化的构造函数,使用非空字符串初始化
Link_Node() : head(new Node<string>("#")) {}
~Link_Node() { delete head; }
private:
Node<string>* head;
};
这种写法兼容性更好,也更符合 C++ 模板特化的常规实践。
注意因为示例需要,我的实现全都在同一个源文件里头,而没有新开一个头文件, 所以我这里没有做到类内声明,类外定义。最标准的就是头文件用类定义好以后,再去做实现。
1.8.4 特化引发的代码冗余
但是我们会发现一个问题,就是我们有通用模板的Link_Node,又有模板特化的string的Link_Node,但是链表操作的逻辑是完全一致的,我们总不可能两个链表操作的类都写一大堆函数实现功能,这样的话代码就太长了。我们完全可以通过一个类模板做一个基类去实现链表操作,然后通用模板的Link_Node和特化模板的Link_Node对基类进行继承。
解决如下:
// 基类:封装通用链表操作(尾插、打印等)
template<typename T>
class Link_Node_Base
{
protected:
Node<T>* head;
public:
void tail_insert(const T& val) { /* 通用逻辑,只写一次 */ }
};
// 通用子类:仅初始化
template<typename T>
class Link_Node : public Link_Node_Base<T>
{
public:
Link_Node() { head = new Node<T>(T()); }
};
// 特化子类:仅特化初始化
template<>
class Link_Node<string> : public Link_Node_Base<string>
{
public:
Link_Node() { head = new Node<string>("#"); }
};
1.9 模板用途
模板干嘛用的?如下:
-
开发基础库 / 框架比如你需要写一个 “链表”“哈希表”“排序算法”,这些工具应该支持
int、double、string甚至用户自定义类型(如Person、Order)。这时模板是唯一选择 —— 总不能为每种可能的类型都写一个链表吧?C++ 标准库(vector、map、sort等)正是这样的通用工具,全靠模板实现。 -
避免 “复制粘贴式编程”如果你发现自己在写 “逻辑完全相同,只是类型不同” 的代码(比如
maxInt、maxDouble、maxString),这就是模板的典型用武之地。用模板能把多份重复代码合并成一份,既减少工作量,又避免 “改一处漏改其他处” 的 bug。 -
处理 “不确定的未来需求”比如你现在只需要处理
int,但不确定未来是否要支持long或自定义类型。用模板提前实现通用逻辑,未来扩展时无需修改核心代码,只需直接使用即可(如从Array<int>扩展到Array<long>)。
典型场景如下:
-
通用工具函数如 “交换两个变量”“求最大值”“打印数据” 等逻辑固定、仅类型不同的函数。
例:
// 通用交换函数(支持int、double、string等所有类型) template <typename T> void swap(T& a, T& b) { T temp = a; a = b; b = temp; } -
通用算法如排序、查找、累加等算法,逻辑与数据类型无关(只要类型支持比较、加减等操作)。例:
// 通用数组累加(支持int、double等数值类型) template <typename T> T sum(T arr[], int length) { T total = 0; for (int i = 0; i < length; i++) { total += arr[i]; } return total; }
因此,模板并不会是特别常用的东西,但是对于如上场景,没有模板却又会导致问题的解决变得更加复杂。
模板只需要了解核心框架,了解语法即可,更多的是需要了解其用途,使用场景,在实践中理解模板。
1.10 实例引发的C++重载问题
通过实例我们发现了一个新东西叫做C++ 重载决议(overload resolution),它机制的工作方式,核心规则是:更具体的函数会被优先选择。
具体到代码中:
-
当输入
string类型时:-
存在一个专门针对
string的非模板函数is_Valid(const string& val) -
同时,模板函数
is_Valid<T>也可以实例化为is_Valid<string>(参数为const string&) -
此时,编译器会优先选择 非模板函数(因为它更 “具体”,是专门为
string写的)
-
-
当输入
int/double等其他类型时:-
没有专门针对
int/double的非模板函数 -
模板函数
is_Valid<T>会被自动实例化为is_Valid<int>/is_Valid<double>(参数类型完全匹配) -
因此,编译器会选择 模板函数的实例化版本
-
这也就是为什么我们那样子写,编译器通过了的原因。
更多推荐


所有评论(0)