关于C++当中泛型编程, 模板初阶(函数模板, 类模板)详解

C++泛型编程
在C++当中, 泛型编程是一个十分重要的概念, 那么泛型编程到底是什么? 而它的作用及优点在哪里? 下面我们将一步步解释C++当中的泛型编程, 函数模板和类模板

举个简单的例子, 在我们C语言当中, 如果我们想要实现一个交换函数, 比如下面这样:

void Swap(int* a, int* b)
{
	int tmp = *a;
	*a = *b;
	*b = tmp;
}

这是一个简单的交换函数, 但是它只可以交换int类型的数据.
如果现在我们需要交换两个double类型的数据, 比如

double a = 1.1, b = 2.2;
Swap(&a, &b);

这样调用Swap函数, 编译会报错, 如图
在这里插入图片描述
那么好, 既然需要交换double类型的数据, 那我们再实现一个交换函数就好了, 如果是C语言, 那么我们还要考虑不可以出现同名函数, 但是C++就不同了, C++当中实现了函数重载, 即可以出现同名函数, 但函数的参数不能相同.

void Swap(int* a, int* b)
{
	int tmp = *a;
	*a = *b;
	*b = tmp;
}

void Swap(double* a, double* b)
{
	double tmp = *a;
	*a = *b;
	*b = tmp;
}

void Swap(char* a, char* b)
{
	char tmp = *a;
	*a = *b;
	*b = tmp;
}

利用函数重载, 我们可以实现对不同类型的交换, 与此同时存在的问题也就出现了:

  1. 首先我们实现的重载函数就只是类型发生了变化, 只要出现新的类型, 就要实现对应的函数, 这样使得代码复用率变得很低, 实现上也带来了不必要的麻烦;
  2. 因为类型的不同, 导致相应函数增多, 也让代码的维护性变得比较低, 有可能一个出错, 导致所有的重载函数均出错.

也正因为这些问题的存在, C++当中实现了泛型编程.
泛型编程: 其实可以理解为字面的意思广泛类型(各种类型), 也就是编写与类型无关的代码, 是一种代码复用的手段. 而模板就是泛型编程的基础.
其中模板又分为: 函数模板和类模板

函数模板

函数模板代表了一个函数家族, 这个函数模板和类型无关, 只有在被使用时才会根据实参的类型来实例化对应的函数

函数模板的格式

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

一般常用写为

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

在函数模板的基础上, 我们再来实现一个与类型无关的交换函数模板

template<class T>
void Swap(T& a, T& b)
{
	T tmp = a;
	a = b;
	b = tmp;
}

之前我们已经说过, 泛型编程就是编写与类型无关的函数, 为什么说它与类型无关呢?
我们来看这样一段代码

#include <iostream>

using namespace std;

template<class T>
void Swap(T& a, T& b)
{
	T tmp = a;
	a = b;
	b = tmp //注意这里的语法错误, 少了分号
}

int main()
{
	double a = 1.1, b = 2.2;

	system("pause");
	return 0;
}

既然语法出现了错误, 那编译应该报错才对, 但实际上代码成功编译运行了.
在这里插入图片描述
再来改一下, 我们来调这个函数

#include <iostream>

using namespace std;

template<class T>
void Swap(T& a, T& b)
{
	T tmp = a;
	a = b;
	b = tmp //注意这里的语法错误, 少了分号
}

int main()
{
	double a = 1.1, b = 2.2;
	Swap(a, b);
	system("pause");
	return 0;
}

我们在调用这个函数时, 报错了
在这里插入图片描述
这就对应了之前的东西与类型无关!
也就是说, 根据实际参数真正实例化对应的函数时, 才会生成相关的代码. 因此我们调用之后编译器帮我们检查出了这个语法错误, 不调用, 编译器是不会管模板函数中的东西的. 当然我们要保证模板语法的正确, 虽然不调用编译器不管里面, 模板语法还是会检查的

#include <iostream>

using namespace std;

template<class T>
void Swap(T& a, T& b)
{
	T tmp = a;
	a = b;
	b = tmp; 
//这里少了一个大括号  }

int main()
{
	double a = 1.1, b = 2.2;
	//仍然没有调用
	system("pause");
	return 0;
}

在这里插入图片描述
这个例子其实就是在说明函数模板的原理, 函数模板其实就类似于一个模具, 编译器能够根据这个模具来产生特定具体类型的函数. 换个角度来说, 本来是实现这些对应不同数据类型的函数应该是我们自己去做的, 而现在编译器帮我们做了这个重复的事情.

在编译器的编译阶段, 编译器会根据传入的实参类型来自己推导出对应类型的函数以供使用, 因此也就看到了上面例子中的不调用, 也就不实现对应函数, 也就不管函数模板中的内容!
还按照double举例来说明, 当我们用double类型使用函数模板的时候, 编译器通过实参类型的推导, 将T确定为double类型, 然后产生了一份专门处理double类型的代码, 对于其他的类型也是这样.

接下来, 我们来说明模板的隐式实例化和显式实例化

同样, 先看代码:

#include <iostream>

using namespace std;

template<class T>
void Swap(T& a, T& b)
{
	T tmp = a;
	a = b;
	b = tmp;
}

int main()
{
	double a = 1.1, b = 2.2;
	Swap(a, b);
	cout << a << ' ' << b << endl;

	int c = 1, d = 2;
	Swap(c, d);
	cout << c << ' ' << d << endl;

	char e = 'a', f = 'b';
	Swap(e, f);
	cout << e << ' ' << f << endl;

	system("pause");
	return 0;
}

上面其实就是隐式实例化, 编译器根据实参类型推导模板参数的实际类型T.
上面的代码也可以显式实例化

double a = 1.1, b = 2.2;
Swap<double>(a, b);
cout << a << ' ' << b << endl;

int c = 1, d = 2;
Swap<int>(c, d);
cout << c << ' ' << d << endl;

char e = 'a', f = 'b';
Swap<char>(e, f);
cout << e << ' ' << f << endl;		

当然, 上面这样的情况无论我们隐式实例化还是显式实例化, 不会产生太大问题, 换个例子来看

#include <iostream>

using namespace std;

template<class T>
T Add(T& a, T& b)
{
	return a + b;
}

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

	system("pause");
	return 0;
}

这样调用就会产生问题, 编译会报错, 因为编译器看到Add(a1, d1)时, 首先根据a1将T推导为int, 然后有根据d1将T推导为 double, 但是模板的参数列表中只有一个T, 这样就使得编译器无法确定T到底是int还是double.

注意: 在模板当中, 编译器并不会进行隐式类型转化.

出现这样的情况时, 我们就不得不进行显示实例化了

Add<int>(a, b);

这样就用显示实例化来解决这个问题, 但是这就又牵扯到了别的问题.
编译仍然报错

在这里插入图片描述
我们指定了编译器去实例化int类型的函数, 也就是下面这样一个函数

int Add(int& a, int& b)
{
	return a + b;
}

也就是说, 我们要把d1的值传给b, 注意我们用的是引用.
这里有这样一个过程

//隐式类型转化将d1的值赋给一个int类型的临时变量
int tmp = (int)d1;
//也就是说 给这个临时变量起了一个别名(引用)
int& b = tmp; 
// 注意这一步是无法完成的, 因为tmp是一个临时变量, 而临时变量具有常性, 本来我tmp是一个只读的临时变量, 不能因为我给他起了别名b, 我就可以修改这个tmp变量了, 这样显然不合理.

因此, 应该是

int Add(const int& a, const int& b)
{
	return a + b;
}

这样, 编译就通过了, 就可以显示实例化完成调用了, 完整代码:

#include <iostream>

using namespace std;

template<class T>
T Add(T& a, const T& b)
{
	return a + b;
}

int main()
{
	int a1 = 10, a2 = 20;
	double d1 = 1.1, d2 = 2.2;
	Add<int>(a1, d1);
	system("pause");
	return 0;
}

除了显示实例化调用之外, 还可以我们自己完成类型转化

Add(a1, (int)d1);

类模板

有了上面函数模板的基础, 我们再来说类模板, 其实大体上是一样的, 这里我们主要来说明C++中实现类模板的出现帮助我们解决了哪些问题.

这里就拿数据结构栈来举例说明, 假如我们是C语言当中想要实现一个栈, 一般会这么去写

typedef struct Stack_c
{
	int* _a;
	int _capacity;
	int _size;//记录栈顶top
}Stack_c;

void Stack_c_init(Stack_c* s);
void Stack_c_push(Stack_c* s, int val);
void Stack_c_pop(Stack_c* s);
void Stack_destroy(Stack_c* s);

这里只是举例, 并没实现功能.

紧接着我们就可以定义自己的栈了

int main()
{
	Stack_c s;
	Stack_c_init(&s);//首先要给栈初始化
	Stack_c_push(&s, 1);
	Stack_c_push(&s, 2);
	Stack_c_push(&s, 3);
	Stack_c_push(&s, 4);
	Stack_c_push(&s, 5);
	Stack_c_push(&s, 6);
	Stack_destroy(&s);//不用了要销毁

	system("pause");
	return 0;
}

但是在C语言当中这样的一个栈会存在很多问题.

  1. 忘记调用 init 初始化栈, 或者不用的时候忘记调用 destroy 销毁栈.

  2. 没有封装, 谁都可以修改数据, 比如:

    s._capacity = 0;//非法修改, 不被允许的操作

  3. 为了让我们自己实现的栈可以存多种不同的类型, 我们可以

     typedef int SDataType;
     //typedef char SDataType;
     //typedef double SDataType;
     ...
     typedef struct Stack_c
     {
     	SDataType* _a;
     	int _capacity;
     	int _size;//记录栈顶top
     }Stack_c;
    

    但是, 如果我们需要定义一个存int类型的栈, 还需要一个存 char 类型的栈, 我们就必须将所有代码实现对应的再copy一份只修改类型.

接下来, 来看泛型编程类模板帮我们解决的问题, 首先来看一个 cpp 版本的自定义栈

template<class T>
class Stack_cpp
{
public:

	Stack_cpp();//可以在构造函数中实现栈的初始化
	void push(int val);
	void pop();
	~Stack_cpp();//可以在析构函数中实现栈的销毁

private:
	T* _a;
	int _capacity;
	int _size;
};

这样一个 cpp 版本的栈在定义时, 可以通过显式实例化生成对应数据类型的栈, 并且在定义时自动调用构造函数完成初始化, 程序退出时自动调用析构函数完成销毁清理.

Stack_cpp<int> s1;//实例化一个存储int类型数据的栈
Stack_cpp<char> s2;//实例化一个存储char类型数据的栈
Stack_cpp<double> s3;//实例化一个存储double类型数据的栈

这样的代码比起C版本是不是简洁了许多, 也不用考虑初始化和销毁的问题.

在cpp这样版本的一个栈里, 由于类域 + 访问限定符的存在, 使得我们无法访问类中的私有成员变量, 实现了封装, 禁止了类似C当中 s._capacity = 0 这样的非法操作.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章