您现在的位置是:首页 >技术教程 >【C++】模板网站首页技术教程

【C++】模板

LeePlace 2023-07-11 16:00:02
简介【C++】模板

泛型编程(Generic Programming)

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;
}

...

这样代码显得太冗余了…

多一种类型就要多写一个函数。

有没有一种方法,写一个通用的函数呢?

模板就此而生。

利用模板可以实现与类型无关的通用代码,高效实现代码复用。

而这,就是泛型编程的基础。

什么代码会与类型有关呢?

很显然,函数和类。

所以就有了函数模板和类模板。

下面对它们一一介绍。


函数模板

使用方法

首先将上面实现两个变量的交换函数Swap写成函数模板的形式:

template <class T> //class与typename在这里是等价的
void Swap(T& a, T& b) {
    T tmp = a;
    a = b;
    b = tmp;
}

模板的用法就是如此,其中模板列表可以有多个参数,并可以设置缺省参数:

template <class T1, class T2 = int, class T3 = double>

前面说了,这里的 classtypename 都是用来定义模板参数的关键字,要与其他地方的用法区别开。


函数模板实例化

当我们调用上面的函数时:

int a = 0;
int b = 1;
Swap(a, b);

在编译阶段,编译器会根据传入的实参类型来推演生成对应类型的函数以供调用。

比如这里会识别实参类型 int,并生成一个具体的函数:

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

此时模板参数 T 就被替换成了 int,生成一个可以交换两个 int 变量的函数。

当函数模板识别到参数生成一个具体的模板函数时,这一步就是函数模板的实例化。

函数模板实例化分为隐式和显式。

上面这种调用方法就属于隐式实例化,让编译器根据实参推演模板参数的实际类型。

除此之外还有显示实例化的方法,如下:

int a = 0;
int b = 1;
Swap<int>(a, b);

显示实例化其实是模板的一种特殊用法 —— 特化,后面会进行涉及。

需要注意,一个模板参数只能实例化一种类型:

image-20221129171653302

image-20221129175019717

模板参数匹配原则

  1. 一个非模板函数可以和一个同名的函数模板同时存在,如果其他条件都相同,在调用时会优先调用非模板函数,而如果模板可以产生一个

    int Add(int a, int b) {
    	return a + b;
    }
    
    template <class T>
    T Add(T a, T b) {
        return a + b;
    }
    
    void test() {
        Add(1, 2);    //会调用非模板函数
        Add(1.0, 2.0);//会调用模板函数
    }
    
  2. 一个非模板函数可以和一个同名的函数模板同时存在,而且该函数模板还可以被显示实例化为这个非模板函数

int Add(int left, int right) {
	return left + right;
}

template <class T>
T Add(T left, T right) {
	return left + right;
}

void Test()	{
	Add(1, 2);      //与非模板函数匹配,编译器不需要特化
	Add<int>(1, 2); //调用编译器特化的Add版本
}
  1. 模板函数不允许自动类型转换,但普通函数可以进行自动类型转换

    template <class T>
    void Swap(T& a, T& b) {
        T tmp = a;
        a = b;
        b = tmp;
    }
    
    void Swap(int& a, float& b) {
        float tmp = a;
        a = b;
        b = tmp;
    }
    
    int main() {
        int a = 1;
        float b = 0;
        Swap(a, b);
    	return 0;
    }
    

    如果调用上面的模板函数,那就会报错,因为一个模板参数只能实例化成一种类型。

    而如果调用下面的非模板函数则没问题,因为普通函数编译器可以进行类型转换。


类模板

除了函数模板,还有类模板。

举一个简单的例子,vector 就是一个典型的类模板,可以存放各种数据类型。

那它是怎么做到的呢?

类模板的使用

vector为例,简单介绍一下类模板的语法规则。

template<class T>
class vector {
public:
    vector() {...}
    ~vector() {...}
    void push_back(const T& x) {...}
    void pop_back() {...}
private:
	T* _start;  //指向第一个变量的起始地址
	T* _finish; //指向最后一个变量的起始地址
	T* _end_of_storage; //指向能容纳的最后一个变量的起始地址
}

简单理解,其实就是把变量类型换成 T

当然这是在类内部实现函数的定义,如果在类里面声明在类外面定义的话还需要加上模板参数列表:

template<class T>
vector<T>::vector() {...}

template<class T>
vector<T>::~vector() {...}
    
template<class T>
void vector<T>::push_back(const T& x) {...}
    
template<class T>
void vector<T>::pop_back() {...}

模板参数列表同样可以写多个,并赋缺省值。

另外,类模板的成员函数也能是函数模板。

比如 vector 的一个拷贝构造函数是通过传过来的一段迭代器区间进行初始化赋值的,

此时的参数类型是不固定的,因为传过来的既可能是 vector<int> 的迭代器区间,也有可能是 list<int> 的区间,甚至可能是 vector<vector<int>> 的迭代器区间,

所以只知道此时的参数类型是迭代器类型,但并不知道是谁的迭代器,所以只能用模板:

template <InputIterator> {
    vector(InputIterator first, InputIterator last)
    {
        //...
    }
}

类模板实例化

类模板实例化与函数模板实例化不同,

类模板实例化需要在类模板名字后跟**<>**,

然后将实例化的类型放在**<>**中即可,

类模板名字不是真正的类,实例化的结果才是真正的类:

//vector是类模板
//vector<int>是模板类
vector<int> v1;
vector<double> v2;

我们用类模板创建对象的过程具体来说就是:

先用类模板实例化出一个模板类,

然后由这个模板类实例化出一个对象。

做个形象的比喻就是,

把类模板比喻成饺子的模子,

把模板类比喻成饺子皮,

把对象比喻成饺子馅。


非类型模板参数

模板参数分为类型形参与非类型形参。

像前面用关键字 classtypename 修饰的就是类型形参,

它们在后面充当类型名的作用。

非类型形参,就是用一个常量性质的整型作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用:

template<class T, size_t N = 10>
class array {
private:
	T _array[N];
	size_t _size;	
}

虽然很少见这样用…

需要注意:

  1. 浮点数、类对象以及字符串是不允许作为非类型模板参数的

  2. 非类型的模板参数必须在编译期就能确认结果


模板的特化

概念引入

现在实现了一个比较大小的函数模板:

template<class T>
bool Less(const T& left, const T& right) {
	return left < right;
}

还实现了一个Date类:

class Date {
public:
	Date(int year = 2022, int month = 11, int day = 30) 
		:_year(year)
		,_month(month)
		,_day(day)
	{}

	bool operator<(Date& d) {
		return _year < d._year || _month < d._month || _day < d._day;
	}

private:
	int _year;
	int _month;
	int _day;
};

然后创建两个Date类型的变量:

int main() {
    Date d1(2022, 2, 1);
	Date d2(2022, 3, 1);
    return 0;
}

现在用两种方式比较一下:

int main() {
	Date d1(2022, 2, 1);
	Date d2(2022, 3, 1);
	cout << Less(d1, d2) << endl;
	cout << Less(&d1, &d2) << endl;
	return 0;
}

输出结果如下:

image-20221129210029603

很奇怪,分析一下。

第一次调用,模板实例化成了比较两个Date类型变量的比较函数,此时在Less函数 内部又会去调用 Date 的运算符重载,就是按照我们定的比较日期的方式比较的 d1d2

第二次调用,编译器会识别模板参数的类型为 Date*,是指针类型,而指针是可以直接进行比较的,d1 先声明的,相对低地址,d2 后声明的,相对高地址(因为栈是向下生长的),所以最终结果也没问题。

但是第二次调用就与我们想要的结果产生了偏差。

此时函数模板是真的无可奈何了,因为它并没有做错什么。

所以这就引出了模板特化。


函数模板特化

模板特化是在原模板类的基础上,针对特殊类型所进行特殊化的实现方式

模板特化中分为函数模板特化类模板特化

函数模板特化的使用方法如下:

  1. 必须要先有一个基础的函数模板;

  2. 关键字template后面接一对空的尖括号<>

  3. 函数名后跟一对尖括号,尖括号中指定需要特化的类型;

  4. 函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇怪的错误。

下面是对 Less 函数模板特化成比较两个指针的使用实例:

template <class T>
bool Less(T left, T right)	{
	return left < right;
}

// 对Less函数模板进行特化
template<>
bool Less<Date*>(Date* left, Date* right) {
	return *left < *right;
}

此时再比较两个 Date* 时就会调用特化过的这个版本,就没问题了:

image-20221130005841718

当然,相比特化,直接给出处理相应功能的函数似乎显得更为简洁…

bool Less(Date* left, Date* right) {
	return *left < *right;
}

该种实现简单明了,代码的可读性高,容易书写,因此函数模板不建议特化。


类模板特化

全特化

全特化即是将模板参数列表中所有的参数都确定化:

template <class T1, class T2>
class Data {
public:
    Data() {
        cout << (T1, T2) << endl;
    }
    //...
    
private:
    T1 _d1;
    T2 _d2;
};

template <>
class Data<int, char> {
public:
    Data() {
        cout << (int, char) << endl;
    }
    //...
    
private:
    int _d1;
    char _d2;
}

看上去有点像重载,但是一定要分清,

无论这里的参数列表全特化还是偏特化,都没有引入一个全新的模板,

只是对原来的泛型模板中已经隐式声明的实例提供一种定义。


偏特化

偏特化是任何针对模版参数进一步进行条件限制设计的特化版本

偏特化可以是将模板参数列表中的一部分参数特化

简单来说就是将部分模板参数确定化:

template <class T1>
class Data<T1, int>
{
public:
	Data() { cout<< "Data<T1, int>" << endl; }
    
private:
    T1 _d1;
    int _d2;
};

偏特化还可以针对模板参数更进一步的条件限制所设计出来的一个特化版本,

对于上面的Data类我们可以这么偏特化:

template <typename T1, typename T2>
class Data <T1*, T2*>
{
public:
	Data()  {cout << "Data<T1*, T2*>" << endl; }
    
private:
    T1 _d1;
    T2 _d2;
};

这样就将两个参数偏特化为指针类型。

我们还可以将两个模板参数偏特化为引用类型:

template <typename T1, typename T2>
class Data <T1&, T2&>
{
public:
	Data(const T1& d1, const T2& d2)  
        :_d1(d1), _d2(d2)
    { cout << "Data<T1&, T2&>" << endl; }
    
private:
    T1 _d1;
    T2 _d2;
};

只不过成员都是引用类型的时候要对构造函数进行特殊处理。

那么对于下面几种实例化对象的方式就会调用相应的特化版本:

Data<double, int> d1;      // 调用偏特化的int版本

Data<int, double> d2;      // 调用基础的模板

Data<int*, int*> d3;       // 调用偏特化的指针版本

int a = 1, b = 2;
Data<int&, int&> d4(a, b); // 调用偏特化的引用版本

模板特化应用场景

在概念引入部分我们举了一个Less函数的例子,

如果待比较类型是日期类的话没有问题,

但是如果待比较类型是日期类指针的话就出现了问题,

此时我们需要提供指针特化版本。

现在问题来了,

现在要求我们使用stl算法库中的sort函数一个日期类数组进行排序,

我们又该怎样做呢?

排序就要进行比较,

我们当然可以用此前的函数模板特化来解决这个问题,

直接给sort函数传Less函数过去:

sort(arr.begin(), arr.end(), Less<Date>);

或者是sort(arr.begin(), arr.end(), Less<Date*>);

但stl标准提供的sort函数是通过仿函数来进行比较的。

这就不得不引出一个仿函数的概念了:

仿函数(functor),就是使一个类的使用看上去像一个函数。

其实现就是类中实现一个operator()

这个类就有了类似函数的行为,

就是一个仿函数类了。

比如下面这个类就是一个仿函数:

template<class T>
struct Less
{
    bool operator()(const T& x, const T& y) const
    {
    	return x < y;
    }
};

我们还有一个日期类对象数组:

Date d1(2023, 5, 1);
Date d2(2023, 4, 30);
Date d3(2023, 5, 2);
vector<Date> v1;
v1.push_back(d1);
v1.push_back(d2);
v1.push_back(d3);

想要调用sort函数进行排序就可以这么调用:

sort(v1.begin(), v1.end(), Less<Date>());

这就是仿函数最基本的用法。

所以问题也就来了,

我们现在同样需要对一个日期类指针数组进行排序:

vector<Date*> v2;
v2.push_back(&d1);
v2.push_back(&d2);
v2.push_back(&d3);

此时就要这么调用:

sort(v2.begin(), v2.end(), Less<Date*>());

这意味这我们需要对Less特化一个比较指针的特化版本:

template<>
struct Less<Date*>
{
    bool operator()(Date* x, Date* y) const
    {
    	return *x < *y;
    }
};

template<class T>
struct Less<T*>
{
    bool operator()(const T* x, const T* y) const
    {
    	return x < y;
    }
};

这样上面的调用就没问题了。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。