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;
}
相关推荐
Scott9999HH4 小时前
【IIoT流量实战】蒸汽管道阀门全关却仍有流量?用 Python 实现涡街信号 FFT 频谱分析与温压全补偿积算网关,深度拆解靠谱的涡街流量计厂家硬核技术标准
开发语言·python
2401_841495645 小时前
【操作系统】进程同步与互斥实验报告
c++·算法·操作系统·进程·并发·同步·互斥
fqbqrr5 小时前
2607C++,soui与安卓
c++·soui
码智社5 小时前
AES加密原理详解及Java实现加解密实战
java·开发语言
AI云海5 小时前
python 列表、元组、集合和字典
开发语言·python
克里斯蒂亚诺更新5 小时前
ruoyi角色权限的对应关系
笔记
萧瑟余晖6 小时前
JDK 26 新特性详解
java·开发语言
马优晨6 小时前
Freemarker 完整讲解(后端 Java 模板引擎)
java·开发语言·freemarker·freemarker 完整讲解·freemarker模板引擎
a1117768 小时前
2FA 验证码生成器(github登录验证 app)
笔记·学习
fqbqrr8 小时前
2607C++,使用微软detours勾挂工具
c++