Bootstrap

【C++初阶】模版入门看这一篇就够了


1. 泛型编程

在C语言中,如果我们要实现交换函数swap,为了让这个函数能兼容所有的类型,我们需要swap_intswap_double等等,为每个类型单独实现一个对应的交换函数,还要起不同的名称防止冲突。
而在C++中,我们可以使用函数重载,不再需要使用其他函数名,但是似乎仍然需要为每个类型单独实现一个函数:

void Swap(int& left, int& right)
{
	int temp = left;
	left = right;
	right = temp;
}
void Swap(double& left, double& right)
{
	double temp = left;
	left = right;
	right = temp;
}
void Swap(char& left, char& right)
{
	char temp = left;
	left = right;
	right = temp;
}
//....

使用函数重载虽然可以实现需求,但是依然有一下几个不好的地方:

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

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

工业上生产不同颜色的同一种金属制品,会先制作一个模具,然后通过往模具中加入实现准备好的不同颜色的液态金属就可以制作出不同的成品。

如果在C++中,也能够存在这样一个模具,通过给这个模具中填充不同材料(类型),来获得不同材料的铸件(即生成具体类型的代码),那将会节省许多不必要的代码。
巧的是前人早已将树栽好,我们只需在此乘凉,这便是泛型编程模板

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

模板有两种:
模板
我们来一一介绍。

2. 函数模板

2. 1 函数模板概念

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

2. 2 函数模板格式

template<typename T1, typename T2,..,typename Tn>
返回值类型 函数名(参数列表)
{}

swap函数为例:

template<typename T>
void swap(T& a, T& b)
{
	T temp = a;
	a = b;
	a = temp;
}

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

2. 3 函数模板的原理

函数模板是一个蓝图它本身并不是函数,是编译器用使用方式产生特定具体类型函数的模具。
所以其实模板就是将本来应该我们做的重复的事情交给了编译器。

模板使用
在编译器编译阶段,对于模板函数的使用,编译器需要根据传入的实参类型来推演生成对应类型的函数以供调用。比如:当用double类型使用函数模板时,编译器通过对实参类型的推演将T确定为double类型,然后产生一份专门处理double类型的代码,对于字符类型也是如此。

2. 4 函数模板的实例化

用不同类型的参数使用函数模板时,称为函数模板的实例化。板参数实例化分为隐式实例化显式实例化

  1. 隐式实例化:让编译器根据实参推演模板参数的实际类型。
#include<iostream>
using namespace std;
template<class T>
T Add(T A, T B)
{
	return A + B;
}


int main()
{
	int a = 10;
	char b = 'a';
	cout << Add(a, b) << endl;
	return 0;
}

该语句不能通过编译,因为在编译期间,当编译器看到该实例化时,需要推演其实参类型通过实参aT推演为int,通过实参bT推演为char类型,但模板参数列表中只有一个T,编译器无法确定此处到底该将T确定为int 还是char类型而报错。
注意在模板中,编译器一般不会进行类型转换操作。
这时候有两种解决办法:

  1. 手动进行强制类型转换
Add(a, (int)b);

这样就可以了,形参接收到的是强制类型转换后的数值。

  1. 显式实例化:在函数名后的<>中指定模板参数的实际类型。
int main()
{
	int a = 10;
	char b = 'a';
	cout << Add<int>(a, b) << endl;
	return 0;
}

这个写法可以先给Add的模板的Tint,使其生成对应的函数后再接收参数,那么如果类型不匹配,编译器会尝试进行隐式类型转换,如果无法转换成功编译器将会报错。

但是要注意,对于swap函数来说,上面的两种做法都不能使swap的两个参数不同,第一个做法中,强制类型转换产生的是一个临时对象,具有常性,不可修改;第二个中隐式类型转换产生的也是一个临时对象,不可修改。除非写一个两个实参类型不同的函数模板,比如:

#include<iostream>
using namespace std;

template<class T,class Y>
void swap(T& a, Y& b)
{
	//交换函数的实现
}

因为这样的函数模板没有什么实际意义,所以就不实现了。
不过顺带一提,对于这样的函数,如果我们想在调用时指定其两个参数的类型,可以这么写:

#include<iostream>
//using namespace std;	//这里注意:std命名空间中有一个swap函数模版

template<class T,class Y>
void swap(T& a, Y& b)
{
	//交换函数的实现
}

int main()
{
	int a = 10;
	char b = 'a';
	swap<int, char>(a, b);	//<>中不同的类型名用 , 隔开
	std::cout << a << ' ' << b << std::endl;
	return 0;
}

代码中也提到了全局的swap函数模板,所以在实践中我们不需要手动去实现swap函数模板,直接使用库里的就行了(库函数的swap不支持不同类型变量的交换)。

2. 5 模板参数的匹配原则

  1. 一个非模板函数可以和一个同名的函数模板同时存在,即使该函数模板可以被实例化为这个非模板函数。
#include<iostream>
using namespace std;

// 显式写出 int 的Add函数
int Add(const int& a, const int& b)
{
	return a + b;
}

// 这个函数模板也可以实例化出 int 的Add函数
template<class T>
T Add(const T& a, const T& b)
{
	return a + b;
}

int main()
{
	cout << Add(1, 2) << " " << Add(10.2, 15.0) << endl;
	return 0;
}

对于这种情况,编译器会优先使用显式写出来的那个int类型的Add函数,而不会使用函数模板去重新生成一个。

  1. 对于非模板函数和同名函数模板,如果其他条件都相同,在调动时会优先调用非模板函数而不会从该模板产生出一个实例。如果模板可以产生一个具有更好匹配的函数,那么将选择模板。
#include<iostream>
using namespace std;

// 显式写出 int 的 Add 函数
int Add(const int& a, const int& b)
{
	return a + b;
}

//这个函数模板也能实例化出 int 的 Add 函数
template<class T,class Y>
T Add(const T& a, const Y& b)
{
	return a + b;
}

int main()
{
	cout << Add(1, 2.0) << endl;
	return 0;
}

这种情况下,编译器就不会使用上面显式写出来的函数,而是使用函数模板实例化出来一个新的函数。

  1. 模板函数不允许自动类型转换,但普通函数可以进行自动类型转换。
    这一条实际上在2.4中已经介绍过了,不再赘述。

2. 6 补充:使用调试功能观察函数调用

我们怎么能知道函数调用的是哪个模板,或者说哪个显式写出来的函数呢?
虽然可以通过在函数内部写一条输出语句输出不同的内容实现,但这在实际开发中显得有些麻烦,要给所有的可能的模板都加上输出,所以一般不采用。
我们可以使用调试功能来观察,这里以VS2022的调试功能为例:
图例

当程序运行到函数调用的这一行时,按F11(逐语句),就可以进入调用的函数内部:
图例
可以发现,这个函数调用的就是这个函数模板(实例化出来的函数),如果是调用的其他模板或函数,程序就会执行到相应的位置。

3. 类模板

3 .1 类模板的定义格式

template<class T1, class T2, ..., class Tn>
class 类模板名
{
	// 类内成员定义
};

举例:

#include<iostream>
using namespace std;
template<typename T>
class Stack
{
public:
	Stack(size_t capacity = 4)
		:_size(0)
		,_capacity(capacity)
		//,_data(new T[capacity])	//注意不能这么写,详情请看类和对象(下)
	{
		_data = new T[_capacity];
	}
	// 析构函数略
	void push_back(const T& temp)
	{
		if (_size == _capacity)
		{
			T* newdata = new int[2 * _capacity];
			for (int i = 0; i < _size; i++)
			{
				newdata[i] = _data[i];
			}
			delete[] _data;
			_data = newdata;
		}
		_data[_size++] = temp;
	}

	void Print()
	{
		for (int i = 0; i < _size; i++)
		{
			cout << _data[i] << " ";
		}
		cout << endl;
	}

private:
	T* _data;
	size_t _size;
	size_t _capacity;
};

int main()
{
	Stack<int> a;
	a.push_back(1);
	a.push_back(2);
	a.push_back(3);
	a.push_back(4);
	a.push_back(5);
	a.Print();
	return 0;
}

对于Stack类中的_data数组,它的类型完全是由模板参数控制的,在创建时可以根据需要自由选择种类
同时,对Print函数来说,cout这一非格式化输出也是很有意义的,因为它不可能使用printf函数去输出。

另外,对于类模板,不建议把成员函数的声明和定义拆分到不同的文件(.h和.cpp)中,会导致编译错误。
原因有二:

  1. 多重定义错误
    如果将模板的定义放在.cpp文件中,并且不在头文件中声明这些成员函数,则在每个包含该头文件的编译单元中都需要重新定义这些成员函数。这会导致链接错误,因为每个翻译单元都会看到一个不同的定义。
  2. 编译时实例化需求
    当编译器遇到模板类的使用时,它需要知道整个模板类的定义,以便它可以为特定的模板参数实例化模板。如果成员函数的定义不在同一个文件中,编译器就无法生成正确的代码。

3. 2 类模板的实例化

类模板实例化与函数模板实例化不同,类模板实例化需要在类模板名字后跟<>,然后将实例化的类型放在<>中即可,类模板名字不是真正的类,而实例化的结果才是真正的类

也就是说

Stack<int> a;

类模板创建类的时候,<int>是必须的,而模板函数在一些情况下不是必须的。

谢谢你的阅读,喜欢的话来个点赞收藏评论关注吧!
我会持续更新更多优质文章

;