泛型编程——模板【C++】

文章目录

  • 1. 泛型编程
    • 引例
  • 2. 函数模板
    • 2.1 概念
    • 2.2 格式
    • 2.3 原理
    • 2.4 实例化函数模板
      • 隐式实例化
      • 显式实例化
    • 2.5 模板匹配原则
  • 3. 类模板
    • 3.1 概念
    • 3.2 格式
    • 3.3 实例化类模板
  • 4. 非类型模板参数
  • 5. 模板的特化
    • 5.1 概念
    • 5.2 函数模板特化
      • 函数模板特化步骤
    • 5.3 类模板特化
      • 全特化
      • 偏特化
        • 部分特化
        • 进一步限制参数
      • 5.4 类模板的应用
  • 6. 模板的分离编译
    • 6.1 概念
    • 6.2 模板的分离编译
  • 7. 总结
    • 优点
    • 缺点

1. 泛型编程

引例

就之前没有了解过泛型编程而言,我们用C语言实现两数交换通常是这样的:

void Swapi(int* p1, int* p2)
{
	int tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}

但是这样只能交换两个int类型变量,如果是float型呢?我们会再写一个:

void Swapd(double* p1, double* p2)
{
	double tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}

难道要给每一种类型都要写一个Swap吗?

C语言不支持函数重载,所以我们用C++能解决这个问题吗?

// int类型
void Swap(int& x, int& y)
{
	int tmp = x;
	x = y;
	y = tmp;
}
// float类型
void Swap(double& x, double& y)
{
	double tmp = x;
	x = y;
	y = tmp;
}

然而这么做并不奏效,依然要写大量重复的代码。C++函数重载的意义是让名字相同的函数做相同的事情,并没有上面的问题。

泛型编程

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

这个“类型”用一个符号代替,等定义时再规定类型,编译时会替换这个符号。是

模板是泛型编程的基础。模板分为函数模板和类模板

2. 函数模板

2.1 概念

我们可以定义一个通用的函数模板(function template),而不是为每个类型都定义一个新函数。一个函数模板就是一个公式,可用来生成针对特定类型的函数版本。

2.2 格式

模板定义以关键字template开始,后跟一个模板参数列表(template parameter list),这是一个逗号分隔的一个或多个模板参数(template parameter)的列表,用小于号(<)和大于号(>)包围起来。

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

例如:

template<typename T>
void Swap(T& x, T& y)
{
	T tmp = x;
	x = y;
	y = tmp;
}

typename是用来定义模板参数的关键字,也可以用class代替,但是不能用struct代替。

模板参数列表不能为空。

模板参数遵循普通的作用域规则。一个模板参数名的可用范围是在其声明之后,至模板声明或定义结束之前。

2.3 原理

模板参数列表的作用很像函数参数列表。函数参数列表定义了若干特定类型的局部变量,但并未指出如何初始化它们。在运行时,调用者提供实参来初始化形参。

类似地,模板参数表示在类或函数定义中用到的类型或值。当使用模板时,我们(隐式地或显式地)指定模板实参(template argument),将其绑定到模板参数上。

函数模板是一个蓝图,它本身并不是函数。是编译器产生特定具体类型函数的模具。所以其实模板就是将本来应该我们做的重复的事情交给了编译器。在编译器编译阶段,对于函数模板的使用,编译器需要根据传入的实参类型来推演生成对应类型的函数以供调用。

我们的Swap函数声明了一个名为T的类型参数。在Swap中,我们用名字T表示一个类型。而T表示的实际类型则在编译时根据Swap的使用情况来确定。

类模板的原理也是类似的。

2.4 实例化函数模板

当我们调用一个函数模板时,编译器(通常)用函数实参来为我们推断模板实参。即,当我们调用compare时,编译器使用实参的类型来确定绑定到模板参数T的类型。

隐式实例化

例如下面的调用:

Swap(1, 2) // T 为int

实参类型是int。编译器会推断出模板实参为int,并将它绑定到模板参数T。

编译器用推断出的模板参数来为我们实例化(instantiate)一个特定版本的函数。当编译器实例化一个模板时,它使用实际的模板实参代替对应的模板参数来创建出模板的一个新“实例”。

显式实例化

上面的例子是编译器隐式地推断模板实参,像vector等容器,在使用需要显式地指定模板实参。

#include 
using namespace std;
template<typename T>
T Add(const T& x, const T& y)
{
	return x + y;
}
int main()
{
	int a = 10;
	double b = 1.1;
	int c = Add<int>(a, b); //指定模板参数的实际类型为int
    double d = Add<double>(a, b); //指定模板参数的实际类型为int
	return 0;
}

对于c和d对应的函数,编译器会生成两份不同的版本,其中T分别被替换为int和double。

可以简单地认为,实例化的过程就是【图】

2.5 模板匹配原则

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

比如:

#include 
using namespace std;
//非模板函数
int Add(const int& x, const int& y)
{
	return x + y;
}
//函数模板
template<typename T>
T Add(const T& x, const T& y)
{
	return x + y;
}
int main()
{
	int a = 1, b = 2;
	int c = Add(a, b); 		//调用非模板函数,编译器不需要实例化
	int d = Add<int>(a, b); //调用编译器实例化的Add函数
	return 0;
}

2. 对于非模板函数和同名的函数模板,如果其他部分都相同,在调用时会优先调用非模板函数,而不会从该模板产生出一个实例。如果模板可以产生一个具有更好匹配的函数,那么选择模板函数。

总的来说,**编译器会从这两种函数中匹配它认为最合适的,匹配的主要依据是数据类型。**如果找不到对应参数列表的非模板参数,那么编译器很有可能会调用模板参数,而这常常是函数模板出错的原因。

模板函数的报错常常令人难以发现。不过,关于模板一般在编译阶段报错。

#include 
using namespace std;
//非模板函数
int Add(const int& x, const int& y)
{
	return x + y;
}
//函数模板
template<typename T>
T Add(const T& x, const T& y)
{
	return x + y;
}
int main()
{
	float a = 1.1, b = 2.2;
    int A = 1, B = 2;
    
	int c = Add(a, b); 		//调用模板函数
	int d = Add(A, B); 		//调用非模板参数
	return 0;
}
  • c对应模板函数。因为这个文件中没有参数列表是(float, float)的函数,只能调用模板函数实例化出一个。
  • d对应非模板函数。因为这个文件中有一个函数的参数列表是(int, int)。

3. 模板函数不允许自动类型转换,但普通函数可以进行自动类型转换

#include 
using namespace std;
template<typename T>
T Add(const T& x, const T& y)
{
	return x + y;
}
int main()
{
	int a = Add(2, 2.2); // 编译失败
	return 0;
}
  • 由于这里的T只表示一个模板参数,所以只能替换一次,比如只能都是int,或者都为float。
  • 对于例中的情况,可以在模板参数列表中再增加一个模板参数。
#include 
using namespace std;
template<typename T, typename L>
T Add(const T& x, const L& y)
{
    return (x + y);
}
int main()
{
    int a = Add(2, 2.2); // 编译成功
    cout << a << endl;
    return 0;
}

然而,编译器无法推断返回值的类型,因为参数列表的类型不确定。

3. 类模板

3.1 概念

类模板(class template)是用来生成类的蓝图的。与函数模板的不同之处是,编译器不能为类模板推断模板参数类型。如上面所说,为了使用类模板,我们必须在模板名后的尖括号中提供参数类型,用来代替模板参数的模板实参列表。

3.2 格式

template<class T1,class T2,,class Tn>
class 类模板名
{
	//类内成员声明
};

例如:

template <class T>
class BookList
{
private:
    vector<T> list;
};

【注意】

  • 类模板中的成员函数如果在类外部定义,要加上模板参数列表。

如:

template <class T>
class BookList
{
private:
    vector<T> list;
};
template <class T>
void test()
{
    BookList<T> bl;
}

类模板不支持声明和定义分离编译。原因会在下面解释。

3.3 实例化类模板

首先要明确,类模板不是类,是类的蓝图,实例化的对象是类。

实例化类模板的方式和使用vector等容器的方式一样,因为这些容器本质上也是用类封装的。

就像:

#include 
template <class T>
void test()
{
    vector<int> v1;
    vector<float> v2;
}

这两个定义会实例化出两个不同的类。v1的定义创建了一个v1类,它的成员变量中的数据类型是int,v2类似。

一个类模板的每个实例都会形成一个独立的类。每个类之间没有关联,各自的成员类型也无关系。

4. 非类型模板参数

根据模板参数的类型可以把模板参数分为:

  • 类型形参:出现在模板参数列表中,跟在class或typename关键字之后的参数类型名称。
  • 非类型形参: 用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用。

例如,定义一个静态数组的类,就需要传入长度:

template<class T, size_t N> // N是非类型模板参数
class StaticArray			// 定义一个静态数组类
{
public:
	size_t arraysize()
	{
		return N;
	}
private:
	T _array[N]; 			//非类型模板参数指定静态数组的大小
};

定义静态数组的类,可以在实例化对象的同时指定数组的大小。

int main()
{
	StaticArray<int, 5> a1; //定义一个大小为5的静态数组
	cout << a1.arraysize() << endl; //5
	StaticArray<int, 100> a2; //定义一个大小为100的静态数组
	cout << a2.arraysize() << endl; //100
	return 0;
}

【注意】

  • 非类型模板参数只允许使用整型家族,其他数据类型无法作为非类型模板参数。
  • 非类型的模板参数在编译期就需要确认类型,因为编译器在编译阶段就需要根据传入的非类型模板参数生成对应的类或函数。

5. 模板的特化

5.1 概念

特化,即特例化。编写单一模板,使之对任何可能的模板实参都是最适合的,都能实例化,这并不总是能办到。在某些情况下,通用模板的定义对特定类型是不适合的:通用定义可能编译失败或做得不正确。其他时候,我们也可以利用某些特定知识来编写更高效的代码,而不是从通用模板实例化。当我们不能(或不希望)使用模板版本时,可以定义类或函数模板的一个特例化版本。

例如,我们有一个比较数据类型的函数Compare:

template<class T>
bool Compare(T x, T y)
{
	return x == y;
}

我们分别用整型、浮点型和字符串测试:

int main() {
    cout << Compare(1, 1) << endl;		// 1
    cout << Compare(1.1, 1.1) << endl;	// 1

    char str1[] = "1";
    char str2[] = "1";

    cout << Compare(str1, str2) << endl;	// 0
    return 0;
}

输出:

1

1

0

对于前两次调用,我们这是符合我们的预期的,然而第三次却没有。原因是str1和str2是两个char*的指针,调用传参的是这两个地址,自然不同。而我们的本意是比较这两个指针指向的内容是否相同,该怎么做呢?

5.2 函数模板特化

对于上面Compare函数的调用,如果我们想让他指针指向的字符串的内容,我们可以单独为char*这个数据类型把函数模板特例化。例如字符串比较需要使用strcmp函数:

//对于char*类型的特化
template<>
bool Compare<char*>(char* x, char* y)
{
	return strcmp(x, y) == 0;
}

函数模板特化步骤

  • 首先必须要有一个基础的函数模板。
  • 关键字template后面接一对空的尖括号<>。
  • 函数名后跟一对尖括号,尖括号中指定需要特化的类型。
  • 函数形参表必须要和模板函数的基础参数类型完全相同,否则不同的编译器可能会报一些奇怪的错误。

如果函数模板不能正确推断该类型,我们可以直接写出对应该类型函数的重载:

bool Compare(char* x, char* y)
{
	return strcmp(x, y) == 0;
}

5.3 类模板特化

与函数模板不同,类模板的特例化不必为所有模板参数提供实参。类模板的特例化,根据特例化参数的个数,可以分为:

  • 全特化:将模板参数列表中所有的参数都确定化。
  • 半特化:即部分特例化。

全特化

特化步骤:

  • 首先必须要有一个基础的类模板。
  • 关键字template后面接一对空的尖括号<>。
  • 类名后跟一对尖括号,尖括号中指定需要特化的类型。

对于以下类模板:

template<class T1, class T2>
class AA
{
public:
    // 构造函数
    AA()
    {
        cout << "AA" << endl;
    }
private:
    T1 _t1;
    T2 _t2;
};

如果我们要对这样的组合实例化出特别的类,这个类可能有模板没有的行为,那么我们可以这么做:

template<>
class AA<int, float>
{
public:
    // 构造函数
    AA()
    {
        cout << "AA" << endl;
    }
    // 通用类模板里没有的行为
    // ...
private:
    int _t1;
    float _t2;
};

测试:

#include 
using namespace std;
int main()
{
    AA<int, double> a1;
    AA<int, float> a2;

    return 0;
}

输出:

AA
AA

偏特化

我们可以只指定一部分而非所有模板参数,或是参数的一部分而非全部特性。一个类模板的部分特例化(partial specialization)本身是一个模板,使用它时用户还必须为那些在特例化版本中未指定的模板参数提供实参。

偏特化根据对参数的处理行为,还能分成:

  • 部分特化:仅对模板参数列表中的部分参数进行确定化。
  • 对参数进一步限制:不仅仅是指特化部分参数,而是针对模板参数进一步的条件限制所设计出来的一个特化版本。

部分特化

例如我们只对T1特化为int:

template<class int, class T2>
class AA
{
public:
    // 构造函数
    AA()
    {
        cout << "AA" << endl;
    }
private:
    int _t1;
    T2 _t2;
};

进一步限制参数

其实就是将参数实例化为某种类型,比如指针类型,引用类型等。

//两个参数偏特化为引用类型
template<class T1, class T2>
class AA<T1&, T2&>
{
public:
    // 构造函数
    AA()
    {
        cout << "AA" << endl;
    }
private:
    T1 _t1;
    T2 _t2;
};
//两个参数偏特化为指针类型
template<class T1, class T2>
class AA<T1*, T2*>
{
public:
    // 构造函数
    AA()
    {
        cout << "AA" << endl;
    }
private:
    T1 _t1;
    T2 _t2;
};

由于是“进一步”,所以要在原有的模板的基础上再指定模板的类型。所以要有例子中的两个<>限制类型。

测试

#include 
using namespace std;
int main()
{
    AA<int*, double*> a3;
    AA<int&, float&> a4;

    return 0;
}

输出:

AA
AA

5.4 类模板的应用

例如,标准库中的sort(在头文件中)函数:

// 默认
template <class RandomAccessIterator>  
void sort (RandomAccessIterator first, RandomAccessIterator last);
// 自定义比较方式
template <class RandomAccessIterator, class Compare>  
void sort (RandomAccessIterator first, RandomAccessIterator last, Compare comp);

通过查看函数原型可以看到,默认版本是比自定义版本少了一个参数Compare的。Compare是一个类,默认版本其实是有一个Compare的缺省参数的,只是没有显式地写出来,这也是sort默认升序的原因。

首先简要介绍一下sort的几种使用方法:

#include 
#include 
#include 
using namespace std;

// 以普通函数的方式实现自定义排序规则
bool mycomp(int i, int j)
{
    return (i < j);
}
// 以函数对象的方式实现自定义排序规则
// 实际上就是仿函数
class mycomp2
{
public:
    bool operator() (int i, int j)
    {
        return (i < j);
    }
};

int main()
{
    // 直接调用sort,默认升序
    vector<int> v1 = {9, 5, 2, 1, 3, 0, 8, 6, 7, 4};
    sort(v1.begin(), v1.end());
    for(int i = 0; i < v1.size(); i++)
    {
        cout << v1[i] << " ";
    }
    cout << endl;
    // 直接调用sort,传入greater(),指定降序排序
    // 这是标准库中内置的,一个用类实现的仿函数
    vector<int> v2 = {9, 5, 2, 1, 3, 0, 8, 6, 7, 4};
    sort(v2.begin(), v2.end(), greater<int>());
    for(int i = 0; i < v2.size(); i++)
    {
        cout << v2[i] << " ";
    }
    cout << endl;
    // 调用自定义的比较函数,升序
    vector<int> v3 = {9, 5, 2, 1, 3, 0, 8, 6, 7, 4};
    sort(v3.begin(), v3.end(), mycomp);
    for(int i = 0; i < v3.size(); i++)
    {
        cout << v3[i] << " ";
    }
    cout << endl;
    // 调用自定义的mycomp2类,升序
    // 实际上重载了()操作符,它是一个仿函数
    vector<int> v4 = {9, 5, 2, 1, 3, 0, 8, 6, 7, 4};
    sort(v4.begin(), v4.end(), mycomp2());
    for(int i = 0; i < v4.size(); i++)
    {
        cout << v4[i] << " ";
    }
    cout << endl;
    
    return 0;
}

输出:

0 1 2 3 4 5 6 7 8 9
9 8 7 6 5 4 3 2 1 0
0 1 2 3 4 5 6 7 8 9
0 1 2 3 4 5 6 7 8 9

我们可以看到,传入函数和传入对象,都能起到相同的作用。注意到v2数组中,给sort传入的对象是greater,而且还传入了模板参数int,就说明这个对象是一个模板类实例化出来的。

既然传入T类型的greater对象才是降序,那么sort的默认版本的最后一个参数(Compare)的缺省值就是T类型的less对象。

那么就Compare类,如何特化一个类模板,让实例化出的类能够具有比较两个指针指向的字符串的行为呢?

#include 
using namespace std;

// 对less类模板按照指针方式特化
template<>
struct less<char*>
{
    bool operator()(char* x, char* y) const
    {
        return *x < *y;
    }
};

int main()
{
    vector<char*> a = {"5678", "1234"};// 降序
	// 升序打印
    sort(a.begin(), a.end(),less<char*>());
    for(int i = 0; i < a.size(); i++)
    {
        cout << a[i] << " ";
    }
    cout << endl;
    return 0;
}

输出

1234 5678

【注意】

  • 这里的class要换成struct。

6. 模板的分离编译

6.1 概念

一个程序(项目)由若干个源文件共同实现,而每个源文件单独编译生成目标文件,最后将所有目标文件连接起来形成单一的可执行文件的过程。

分离编译是一种模式。

6.2 模板的分离编译

如果按照之前写程序的思路,两个.cpp文件会放测试代码和实现代码,而.h头文件会放各种声明,在这里我们特别关注模板的声明。

Add.h

#include 
using namespace std;
template<class T>
T Add(const T& x, const T& y);

Add.cpp

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

test.cpp

#include"Add.h"
int main() 
{
    cout << Add(1, 2) << endl;
    cout << Add(1.0, 2.0) << endl;
	return 0;
}

如果对上面的代码进行测试,无法编译成功。

回忆编译的整个过程(大概):

  1. 预处理: 包含头文件、删除注释、符号和宏的替换、条件编译等。
  2. 编译: 检查代码的规范性:是否有语法、词法、语义错误,然后将代码翻译成汇编语言。
  3. 汇编: 将编译产生的汇编代码翻译为机器能直接接收的二进制指令(机器指令)。
  4. 链接: 将生成的各个目标文件进行链接,生成可执行文件。

原因:

泛型编程——模板【C++】_第1张图片

由图可以知道,前三个步骤都没问题,出错的在第四个步骤:链接。

由于函数模板是一个「蓝图」,而不是实际存在的模板。也就是说只有用这个蓝图「实例化」,才能得到一个真正的函数,它的存在形式在我们看来就是实际存在的代码。

原因是当实例化一个模板时,编译器必须看到确切的定义,而不仅仅是它的声明,因为声明无法推断参数类型。在test.cpp中,两个Add函数都没有被实例化成功,所以两个Add函数都没有在Add.cpp中找到真实存在的代码。

链接阶段,是通过函数的地址调用函数(call)的,如果连实际的代码都不存在,那么函数的地址一定也找不到。

解决分离编译的方法有两种:

  • 在Add.cpp中自己实现对应参数列表的函数。这就让模板失去了它应有的价值了,不推荐;
  • 不论是类模板还是函数模板,将模板和声明放在一个文件。这也是STL中,把模板的声明和定义写在一起的原因。

附上刘未鹏大佬在2003年对模板分离编译的解释:为什么C++编译器不能支持对模板的分离式编译。

7. 总结

优点

  1. 模板复用了代码,节省资源,更快的迭代开发,C++的标准模板库(STL)因此而产生。
  2. 增强了代码的灵活性。

缺点

  1. 模板会导致代码膨胀问题,也会导致编译时间变长。
  2. 出现模板编译错误时,错误信息非常凌乱,不易定位错误。

你可能感兴趣的:(C++,c++,算法,数据结构)