C++中的活字印刷术——模板·初阶

模板【初阶】

  • [1. 泛型编程](#1. 泛型编程)
  • [2. 函数模板](#2. 函数模板)
    • [2.1 函数模板的概念](#2.1 函数模板的概念)
    • [2.2 函数模板的格式](#2.2 函数模板的格式)
    • [2.3 函数模板的原理](#2.3 函数模板的原理)
    • [2.4 函数模板的实例化](#2.4 函数模板的实例化)
      • [2.4.1 隐式实例化](#2.4.1 隐式实例化)
      • [2.4.2 显式实例化](#2.4.2 显式实例化)
      • [2.4.3 隐式实例化的类型冲突与解决方案](#2.4.3 隐式实例化的类型冲突与解决方案)
    • [2.5 模板参数的匹配原则](#2.5 模板参数的匹配原则)
  • [3. 类模板](#3. 类模板)
    • [3.1 类模板的定义与扩容特性](#3.1 类模板的定义与扩容特性)
    • [3.2 类模板的实例化](#3.2 类模板的实例化)
    • [3.3 类模板使用示例(栈)](#3.3 类模板使用示例(栈))
    • [3.4 思考:tmp 指针不 delete 会内存泄漏吗?](#3.4 思考:tmp 指针不 delete 会内存泄漏吗?)
    • [3.5 类模板的声明与定义分离](#3.5 类模板的声明与定义分离)

1. 泛型编程

首先,让我们来思考这样一个问题:如何实现一个通用的交换函数呢?

cpp 复制代码
void Swap(int& x, int& y)
{
	int tmp = x;
	x = y;
	y = tmp;
}

void Swap(double& x, double& y)
{
	double tmp = x;
	x = y;
	y = tmp;
}

void Swap(char& x, char& y)
{
	char tmp = x;
	x = y;
	y = tmp;
}

虽然我们可以通过上文所示的函数重载实现,但是有几个不好的地方:

  1. 重载的函数仅仅是类型不同,代码复用率比较低,只要有新类型出现,就需要用户自己增加对应的函数。
  2. 代码的可维护性较低,一个出错可能所有的重载均出错。

那么,能否告诉编译器一个模子,让编译器根据不同的类型,利用该模子来生成代码呢?

模板的特点:让C++实现半自动化

泛型编程:编写与类型无关的通用代码,是代码复用的一种手段。

模板是泛型编程的基础

2. 函数模板

2.1 函数模板的概念

函数模板代表了一个函数家族,该函数模板与类型无关,在使用时被参数化,根据实参类型产生函数的特定类型版本。

2.2 函数模板的格式

template< typename T1, typename T2, ......, typename Tn >

返回值类型 函数名(参数列表){}

📃使用代码如下:

cpp 复制代码
template<typename T>
void Swap(T& x, T& y)
{
	T tmp = x;
	x = y;
	y = tmp;
}

📌注意:typename 是用来定义模板参数的关键字 ,也可以使用class(切记:不能使用struct 代替class)

template< typename T1, typename T2, ......, typename Tn >
typename 的数量 = 需要由模板参数表示的"独立类型"的个数

模板参数列表< typename 类型1, typename 类型2 >

类比

函数参数列表< 类型 变量1, 类型 变量2 >

2.3 函数模板的原理

函数模板是一个蓝图,它本身并不是函数,是编译器产生特定具体函数的模具。

对于模板函数的使用,编译器通过参数传递,让实参传递给形参,在这个编译过程中,编译器会推出形参变量的类型,然后利用模板生成具体的函数,用以调用。

比如:当用double类型使用函数模板时,编译器通过对实参类型的推演,将T确定为double类型,然后产生一份专门处理double类型的代码,对于字符类型也是如此。

2.4 函数模板的实例化

用函数模板生成对应的函数,称为函数模板的实例化。

模板参数实例化分为:隐式实例化和显式实例化

2.4.1 隐式实例化

隐式实例化:让编译器根据实参推演参数的实际类型

cpp 复制代码
template<class T>
T Add(const T& left, const T& right)
{
	return left + right;
}

int main()
{
	int a1 = 10, a2 = 20;
	double d1 = 1.1, d2 = 2.2;

	// 隐式实例化
	Add(a1, a2);
	Add(d1, d2);

	return 0;
}

2.4.2 显式实例化

显式实例化:在函数名后的 <> 指定模板参数的实际类型

🔧调用格式:

cpp 复制代码
函数名<实际类型>(实参1, 实参2......);

🔎作用:

强制指定模板类型,避免推演产生歧义。

可以理解为:告诉编译器,T究竟是什么类型。

📃演示代码

cpp 复制代码
int main()
{
	int a1 = 10, a2 = 20;
	double d1 = 1.1, d2 = 2.2;

	// 显式实例化
	Add<int>(a1, a2);
	Add<double>(d1, d2);

	return 0;
}

还有一种情况,必须使用显式实例化:

若T不做对应的形参 ,就直接在函数里使用,编译器就无法推导T的类型,此时就必须显式实例化。

📃如下代码所示:

cpp 复制代码
template<class T>
T* func1(int n)
{
	return new T[n];
}

❌️错误调用:无显式实例化

cpp 复制代码
int main()
{
	func1(10);// 程序报错

	return 0;
}

✅️正确调用:显式实例化,让编译器知道T是什么类型

cpp 复制代码
int main()
{
	// 使用显式实例化,让编译器知道T是什么类型
	double* p1 = func1<double>(10);
	
	return 0;
}

2.4.3 隐式实例化的类型冲突与解决方案

由2.4.1中的代码可以得知,传相同类型的变量肯定是没问题的。

🤔若此时传入的变量是不同类型呢?

答:编译报错

在隐式实例化中,如果传入不同类型的实参(如Add(a1, d1) ),编译器会出现 类型推导歧义:

  • a1 -> 推演出 T = int
  • d1 -> 推演出 T = double

但是,模板参数列表中只有一个T,无法同时满足两种类型,因此直接报错。

我们可以采用以下三种解决方法:

方法1:手动强制类型转换

cpp 复制代码
// 显式将 d1强转为 int,使编译器推演为 int
cout << Add(a1, (int)d1) << endl;
// 显式将 a1强转为 double,使编译器推演为 double
cout << Add((double)a1, d1) << endl;
  • ✅️优点:简单粗暴
  • ❌️缺点:调用手动转易错
  1. 使用显式实例化:

直接在<> 中指定数据类型

cpp 复制代码
cout << Add<int>(a1, d1) << endl;
cout << Add<double>(a1, d1) << endl;
  1. 再写另外一个模板:
cpp 复制代码
template<class T1, class T2>
T1 Add(const T1& left, const T2& right)
{
	return left + right;
}

2.5 模板参数的匹配原则

  1. 一个非模板函数,可以和一个同名的函数模板同时存在 ,而且该函数模板还可以被实例化为这个非模板函数。

📃代码展示:

cpp 复制代码
// 专门处理int的加法函数
int Add(const int& left, const int& right)
{
    return left + right;
}

// 通用加法函数模板
template<class T>
T Add(const T& left, const T& right)
{
    return left + right;
}

void Test()
{
    Add(1, 2);// 与非模板函数匹配,编译器不需要特化
    Add<int>(1, 2);// 显式指定模板参数,调用编译器特化的Add模板函数
}
  1. 对于非函数模板和同名函数模板,如果其他条件相同,在调用时会优先调用非模板函数,而不会从该模板产生出一个实例。如果模板可以产生一个具有更好匹配的函数,那么将选择模板。
cpp 复制代码
// 专门处理int的加法函数
int Add(const int& left, const int& right)
{
    return left + right;
}

// 通用加法函数模板
template<class T>
T Add(const T& left, const T& right)
{
    return left + right;
}

void Test()
{
    Add(1, 2);// 与非模板函数匹配,不需要函数模板实例化
    Add(1, 2.0);// 函数模板可以生成更匹配的版本,编译器根据实参,生成更加匹配的Add函数
}
  1. 模板函数不允许自动类型转换,但是,普通函数可以进行自动类型转换。

匹配原则总结

  • 同名的非模板函数(普通函数)可以和模板函数同时存在。
  • 普通函数和实参完全匹配 -> 优先调用普通函数。
  • 普通函数不匹配 -> 编译器自动调用模板。
  • 在函数名后加上< 类型 > -> 显式实例化模板,编译器强制使用模板。

3. 类模板

3.1 类模板的定义与扩容特性

  1. 定义格式
cpp 复制代码
template<class T1, class T2, ..., class Tn>
class 类模板名
{
	// 类内成员定义
};

模板形参建议:

在类模板中,如果函数形参是模板类型(如 T),推荐使用常引用(const T&),以避免不必要的拷贝开销。

  1. C++扩容特性
    C++ 没有像 C语言中 realloc 那样可以自动就地或异地扩容的 renew 机制。原因在于:
  • realloc 只管内存拷贝(浅拷贝),不感知对象。

  • C++ 的自定义类型可能包含构造和析构逻辑,直接移动内存会导致对象状态损坏。因此,C++ 必须手动开辟新空间 -> 拷贝/移动数据 -> 释放旧空间。

3.2 类模板的实例化

  1. 类模板必须显式实例化

    编译器不会对类模板进行隐式类型推导,必须手动指定类型 。如:Stack<int> st1;

    但是函数模板可以进行隐式推导。

  2. 类型独立性:

    虽然来自同一个类模板,但若给不同参数的模板实例化(如Stack<int>Stack<double>)就是完全不同的两个类型,它们之间不能直接赋值。

3.3 类模板使用示例(栈)

cpp 复制代码
template<typename T>
class Stack
{
public:
	// 构造函数
	Stack(int n = 4)
		:_array(new T[n])
		,_size(0)
		,_capacity(n)
	{}
	
	// 析构函数
	~Stack()
	{
		delete[] _array;
		_array = nullptr;
		_size = _capacity = 0;
	}

	void Push(const T& x)
	{
		if (_size == _capacity)
		{
			// 手动扩容,开辟新空间
			T* tmp = new T[2 * _capacity];
			// 拷贝旧空间
			memcpy(tmp, _array, sizeof(T) * _size);
			// 释放旧空间
			delete[] _array;

			_array = tmp;
			_capacity *= 2;
		}

		_array[_size++] = x;
	}
private:
	T* _array;
	size_t _capacity;
	size_t _size;
};

3.4 思考:tmp 指针不 delete 会内存泄漏吗?

🤔疑问:没有delete[] tmp会不会造成内存泄露呢?

✅️结论:不会。

💡原理:

  1. new 出来的堆内存,不会自动释放,必须手动delete
  2. tmp是一个局部指针变量(存放在栈区),相当于一个存地址的"小纸条",函数结束时自己会自动销毁。
  3. 内存泄露的本质:丢失内存地址,无法释放 。只要地址被安全地转移并保存下来,就不会泄漏。
    这里我们把 tmp 保存的新地址赋值给了成员变量 _array,地址没有丢失,析构函数会统一释放。

🏠场景类比

可以理解为,我们在堆上租了(new)一间房子:

  • 房子本身 = 堆内存
  • 写有房子地址的纸条 = 指针tmp
  • 永久本子 = 成员指针_array
  • 房子退租 = 释放内存

情况1:地址只写在局部纸条 tmp 上

函数结束 -> 纸条(tmp)被销毁

但是房子还在,却没人知道地址,无法退租

👉 这就是内存泄露

情况2:把地址抄到永久本子 _array

  1. 先把房子地址写在临时纸条 tmp
  2. 再把地址抄到成员指针 _array(永久本子)
  3. 函数结束,临时纸条 tmp 撕碎了
  4. 但家里永久本子 _array 还记着房子地址
  5. 后面析构函数里拿着 _array 地址 delete[],把房子退租

👉 地址还在,就能释放,就不叫泄漏

3.5 类模板的声明与定义分离

  1. 作用域限制

    写一个函数模板,这里定义的模板参数,只能给当前的函数使用,类也是同理。

  2. 模板不支持传统的声明与定义分离

    ⚠️注意:类模板通常不支持将声明写在 .h 文件、定义写在 .cpp 文件的传统分离方式。这会导致链接错误 (Link Error),因为编译器在编译 .cpp 时不知道具体的实例化类型,无法生成具体的代码。

    虽然也能用特殊的方法分离,但会非常麻烦,因此推荐模板只放在一个文件。

  3. 正确的类外定义写法

    如果要在同一个文件内将类的成员函数移到类外实现,必须重新声明模板参数,不然编译器无法得知"T"是什么。并且类名 要带上类型参数 <T>

📃代码演示

cpp 复制代码
// 在类外实现 Push
template<class T>
void Stack<T>::Push(const T& x)
{
	// 手动扩容,开辟新空间
	if (_size == _capacity)	
	{
		T* tmp = new T[2 * _capacity];
		// 拷贝旧空间
		memcpy(tmp, _array, sizeof(T) * _size);
		// 释放旧空间
		delete[] _array;

		_array = tmp;
		_capacity *= 2;
	}

	_array[_size++] = x;
}
相关推荐
小白|1 小时前
cmake:昇腾CANN构建系统完全指南
java·c++·算法
在角落发呆1 小时前
跨越网络鸿沟:传统文件传输与现代内网穿透的奇妙交响
开发语言·php
王老师青少年编程1 小时前
2026年全国青少年信息素养大赛“算法应用主题赛”(初赛)【C++考点大纲】(全场景、组别):文末附备考秘籍!
c++·全国青少年信息素养大赛·初赛·2026年·算法应用主题赛·考点大纲
Season4501 小时前
C++之模板元编程(前置知识 constexpr)
开发语言·c++
AI玫瑰助手1 小时前
Python运算符:比较运算符(等于不等等于大于小于)与返回值
android·开发语言·python
大明者省1 小时前
Ubuntu22.04 宝塔面板与 XFCE 远程桌面端口兼容性分析
运维·服务器·数据库·笔记
咩咦1 小时前
C++学习笔记22:前置后置 ++/-- 和日期减日期
c++·学习笔记·运算符重载·日期类·前置++·后置++·日期减日期
哆哆啦ss2 小时前
使用 Obsidian + GitHub Actions + GitHub Pages 搭建内容发布流
笔记
计算机安禾2 小时前
【c++面向对象编程】第40篇:单例模式(Singleton)的多种C++实现
开发语言·c++·单例模式