C++函数模板
在C++中,数据的类型也可以通过参数来传递,在函数定义时可以不指明具体的数据类型,当发生函数调用时,编译器可以根据传入的实参自动推断数据类型。这就是类型的参数化。
值(Value)和类型(Type)是数据的两个主要特征,它们在C++中都可以被参数化。
所谓函数模板,实际上是建立一个通用函数,它所用到的数据的类型(包括返回值类型、形参类型、局部变量类型)可以不具体指定,而是用一个虚拟的类型来代替(实际上是用一个标识符来占位),等发生函数调用时再根据传入的实参来逆推出真正的类型。这个通用函数就称为函数模板(Function Template)。
在函数模板中,数据的值和类型都被参数化了,发生函数调用时编译器会根据传入的实参来推演形参的值和类型。换个角度说,函数模板除了支持值的参数化,还支持类型的参数化。
一但定义了函数模板,就可以将类型参数用于函数定义和函数声明了。说得直白一点,原来使用 int、float、char 等内置类型的地方,都可以用类型参数来代替。
模板函数的语法:
template <typename 类型参数1 , typename 类型参数2 , ...> 返回值类型 函数名(形参列表){
//在函数体中可以使用类型参数
}
类型参数可以有多个,它们之间以逗号,
分隔。类型参数列表以< >
包围,形式参数列表以( )
包围。
typename
关键字也可以使用class
关键字替代,它们没有任何区别。C++ 早期对模板的支持并不严谨,没有引入新的关键字,而是用 class 来指明类型参数,但是 class 关键字本来已经用在类的定义中了,这样做显得不太友好,所以后来 C++ 又引入了一个新的关键字 typename,专门用来定义类型参数。不过至今仍然有很多代码在使用 class 关键字,包括 C++ 标准库、一些开源程序等。
下面我们就来实践一下,将上面的四个Swap() 函数压缩为一个函数模板:
#include <iostream>
using namespace std;
//函数模板的定义
template<typename T> void Swap(T *a, T *b){
T temp = *a;
*a = *b;
*b = temp;
}
int main(){
//交换 int 变量的值
int n1 = 100, n2 = 200;
Swap(&n1, &n2); //函数模板的调用
cout<<n1<<", "<<n2<<endl;
//交换 float 变量的值
float f1 = 12.5, f2 = 56.93;
Swap(&f1, &f2);
cout<<f1<<", "<<f2<<endl;
//交换 char 变量的值
char c1 = 'A', c2 = 'B';
Swap(&c1, &c2);
cout<<c1<<", "<<c2<<endl;
//交换 bool 变量的值
bool b1 = false, b2 = true;
Swap(&b1, &b2);
cout<<b1<<", "<<b2<<endl;
return 0;
}
运行结果:
200, 100
56.93, 12.5
B, A
1, 0
请读者重点关注第 4 行代码。template
是定义函数模板的关键字,它后面紧跟尖括号<>
,尖括号包围的是类型参数(也可以说是虚拟的类型,或者说是类型占位符)。typename
是另外一个关键字,用来声明具体的类型参数,这里的类型参数就是T
。从整体上看,template<typename T>
被称为模板头。
模板头中包含的类型参数可以用在函数定义的各个位置,包括返回值、形参列表和函数体;本例我们在形参列表和函数体中使用了类型参数T
。
类型参数的命名规则跟其他标识符的命名规则一样,不过使用 T、T1、T2、Type 等已经成为了一种惯例。
定义了函数模板后,就可以像调用普通函数一样来调用它们了。
重载函数模板
当需要对不同的类型使用同一种算法(同一个函数体)时,为了避免定义多个功能重复的函数,可以使用模板。然而,并非所有的类型都使用同一种算法,有些特定的类型需要单独处理,为了满足这种需求,C++ 允许对函数模板进行重载,程序员可以像重载常规函数那样重载模板定义。
下面是一个重载函数模板的完整示例:
#include <iostream>
using namespace std;
template<class T> void Swap(T &a, T &b); //模板①:交换基本类型的值
template<typename T> void Swap(T a[], T b[], int len); //模板②:交换两个数组
void printArray(int arr[], int len); //打印数组元素
int main(){
//交换基本类型的值
int m = 10, n = 99;
Swap(m, n); //匹配模板①
cout<<m<<", "<<n<<endl;
//交换两个数组
int a[5] = { 1, 2, 3, 4, 5 };
int b[5] = { 10, 20, 30, 40, 50 };
int len = sizeof(a) / sizeof(int); //数组长度
Swap(a, b, len); //匹配模板②
printArray(a, len);
printArray(b, len);
return 0;
}
template<class T> void Swap(T &a, T &b){
T temp = a;
a = b;
b = temp;
}
template<typename T> void Swap(T a[], T b[], int len){
T temp;
for(int i=0; i<len; i++){
temp = a[i];
a[i] = b[i];
b[i] = temp;
}
}
void printArray(int arr[], int len){
for(int i=0; i<len; i++){
if(i == len-1){
cout<<arr[i]<<endl;
}else{
cout<<arr[i]<<", ";
}
}
}
运行结果:
99, 10
10, 20, 30, 40, 50
1, 2, 3, 4, 5
模板实参推断过程中的类型转换
对于普通函数(非模板函数),发生函数调用时会对实参的类型进行适当的转换,以适应形参的类型。这些转换包括:
- 算数转换:例如 int 转换为 float,char 转换为 int,double 转换为 int 等。
- 派生类向基类的转换:也就是向上转型。
- const 转换:也即将非 const 类型转换为 const 类型,例如将 char * 转换为 const char *。
- 数组或函数指针转换:如果函数形参不是引用类型,那么数组名会转换为数组指针,函数名也会转换为函数指针。
- 用户自定的类型转换。
对于函数模板,类型转换则受到了更多的限制,仅能进行「const 转换」和「数组或函数指针转换」,其他的都不能应用于函数模板。
为函数模板显式地指明实参(也就是具体的类型)
函数模板的实参推断是指「在函数调用过程中根据实参的类型来寻找类型参数的具体类型」的过程,这在大部分情况下是奏效的,但是当类型参数的个数较多时,就会有个别的类型无法推断出来,这个时候就必须显式地指明实参。
下面是一个实参推断失败的例子:
template<typename T1, typename T2> void func(T1 a){
T2 b;
}
func(10); //函数调用
func() 有两个类型参数,分别是 T1 和 T2,但是编译器只能从函数调用中推断出 T1 的类型来,不能推断出 T2 的类型来,所以这种调用是失败的,这个时候就必须显式地指明 T1、T2 的具体类型。
「为函数模板显式地指明实参」和「为类模板显式地指明实参」的形式是类似的,就是在函数名后面添加尖括号< >
,里面包含具体的类型。例如对于上面的 func(),我们要这样来指明实参:
func<int, int>(10);
显式指明的模板实参会按照从左到右的顺序与对应的模板参数匹配:第一个实参与第一个模板参数匹配,第二个实参与第二个模板参数匹配,以此类推。只有尾部(最右)的类型参数的实参可以省略,而且前提是它们可以从传递给函数的实参中推断出来。
在函数模板中使用非类型参数
模板是一种泛型技术,目的是将数据的类型参数化,以增强 C++ 语言(强类型语言)的灵活性。C++ 对模板的支持非常自由,模板中除了可以包含类型参数,还可以包含非类型参数,例如:
template<typename T, int N> class Demo{ };
template<class T, int N> void func(T (&arr)[N]);
template<typename T> void Swap(T a[], T b[], int len);
T 是一个类型参数,它通过class
或typename
关键字指定。N 是一个非类型参数,用来传递数据的值,而不是类型,它和普通函数的形参一样,都需要指明具体的类型。类型参数和非类型参数都可以用在函数体或者类体中。
当调用一个函数模板或者通过一个类模板创建对象时,非类型参数会被用户提供的、或者编译器推断出的值所取代。
C++类模板
C++除了支持函数模板,还支持类模板(Class Template)。函数模板中定义的类型参数可以用在函数声明和函数定义中,类模板中定义的类型参数可以用在类声明和类实现中。类模板的目的同样是将数据的类型参数化。
声明类模板的语法为:
template<typename 类型参数1 , typename 类型参数2 , …>
class 类名{
//TODO:
};
类模板和函数模板都是以 template 开头(当然也可以使用 class,目前来讲它们没有任何区别),后跟类型参数;类型参数不能为空,多个类型参数用逗号隔开。
一但声明了类模板,就可以将类型参数用于类的成员函数和成员变量了。换句话说,原来使用 int、float、char 等内置类型的地方,都可以用类型参数来代替。
请看下面的代码:
template<typename T1, typename T2> //这里不能有分号
class Point{
public:
Point(T1 x, T2 y): m_x(x), m_y(y){ }
public:
T1 getX() const; //获取x坐标
void setX(T1 x); //设置x坐标
T2 getY() const; //获取y坐标
void setY(T2 y); //设置y坐标
private:
T1 m_x; //x坐标
T2 m_y; //y坐标
};
上面的代码仅仅是类的声明,我们还需要在类外定义成员函数。在类外定义成员函数时仍然需要带上模板头,格式为:
template<typename 类型参数1 , typename 类型参数2 , …>
返回值类型 类名<类型参数1 , 类型参数2, ...>::函数名(形参列表){
//TODO:
}
第一行是模板头,第二行是函数头,它们可以合并到一行,不过为了让代码格式更加清晰,一般是将它们分成两行。
下面就对 Point 类的成员函数进行定义:
template<typename T1, typename T2> //模板头
T1 Point<T1, T2>::getX() const /*函数头*/ {
return m_x;
}
template<typename T1, typename T2>
void Point<T1, T2>::setX(T1 x){
m_x = x;
}
template<typename T1, typename T2>
T2 Point<T1, T2>::getY() const{
return m_y;
}
template<typename T1, typename T2>
void Point<T1, T2>::setY(T2 y){
m_y = y;
}
观察代码,除了 template 关键字后面要指明类型参数,类名 Point 后面也要带上类型参数,只是不加 typename 关键字了。另外需要注意的是,在类外定义成员函数时,template 后面的类型参数要和类声明时的一致。
使用类模板创建对象
上面的两段代码完成了类的定义,接下来就可以使用该类创建对象了。使用类模板创建对象时,需要指明具体的数据类型。请看下面的代码:
Point<int, int> p1(10, 20);
Point<int, float> p2(10, 15.5);
Point<float, char*> p3(12.4, "东经180度");
与函数模板不同的是,类模板在实例化时必须显式地指明数据类型,编译器不能根据给定的数据推演出数据类型。
除了对象变量,我们也可以使用对象指针的方式来实例化:
Point<float, float> *p1 = new Point<float, float>(10.6, 109.3);
Point<char*, char*> *p = new Point<char*, char*>("东经180度", "北纬210度");
需要注意的是,赋值号两边都要指明具体的数据类型,且要保持一致。
实例:用类模板实现可变长数组
#include <iostream>
#include <cstring>
using namespace std;
template <class T>
class CArray
{
int size; //数组元素的个数
T *ptr; //指向动态分配的数组
public:
CArray(int s = 0); //s代表数组元素的个数
CArray(CArray & a);
~CArray();
void push_back(const T & v); //用于在数组尾部添加一个元素v
CArray & operator=(const CArray & a); //用于数组对象间的赋值
int length() { return size; }
T & operator[](int i)
{//用以支持根据下标访问数组元素,如a[i] = 4;和n = a[i]这样的语句
return ptr[i];
}
};
template<class T>
CArray<T>::CArray(int s):size(s)
{
if(s == 0)
ptr = NULL;
else
ptr = new T[s];
}
template<class T>
CArray<T>::CArray(CArray & a)
{
if(!a.ptr) {
ptr = NULL;
size = 0;
return;
}
ptr = new T[a.size];
memcpy(ptr, a.ptr, sizeof(T ) * a.size);
size = a.size;
}
template <class T>
CArray<T>::~CArray()
{
if(ptr) delete [] ptr;
}
template <class T>
CArray<T> & CArray<T>::operator=(const CArray & a)
{ //赋值号的作用是使"="左边对象里存放的数组,大小和内容都和右边的对象一样
if(this == & a) //防止a=a这样的赋值导致出错
return * this;
if(a.ptr == NULL) { //如果a里面的数组是空的
if( ptr )
delete [] ptr;
ptr = NULL;
size = 0;
return * this;
}
if(size < a.size) { //如果原有空间够大,就不用分配新的空间
if(ptr)
delete [] ptr;
ptr = new T[a.size];
}
memcpy(ptr,a.ptr,sizeof(T)*a.size);
size = a.size;
return *this;
}
template <class T>
void CArray<T>::push_back(const T & v)
{ //在数组尾部添加一个元素
if(ptr) {
T *tmpPtr = new T[size+1]; //重新分配空间
memcpy(tmpPtr,ptr,sizeof(T)*size); //拷贝原数组内容
delete []ptr;
ptr = tmpPtr;
}
else //数组本来是空的
ptr = new T[1];
ptr[size++] = v; //加入新的数组元素
}
int main()
{
CArray<int> a;
for(int i = 0;i < 5;++i)
a.push_back(i);
for(int i = 0; i < a.length(); ++i)
cout << a[i] << " ";
return 0;
}
模板所支持的类型是宽泛的,没有限制的,我们可以使用任意类型来替换,这种编程方式称为泛型编程(Generic Programming)。相应地,可以将参数 T 看做是一个泛型,而将 int、float、string 等看做是一种具体的类型。
C++ 模板有着复杂的语法,可不仅仅是前面两节讲到的那么简单,它的话题可以写一本书。C++ 模板也非常重要,整个标准库几乎都是使用模板来开发的,STL 更是经典之作。
STL(Standard Template Library,标准模板库)就是 C++ 对数据结构进行封装后的称呼。
在类模板中使用非类型参数
C/C++ 规定,数组一旦定义后,它的长度就不能改变了;换句话说,数组容量不能动态地增大或者减小。这样的数组称为静态数组(Static array)。静态数组有时候会给编码代码不便,我们可以通过自定义的 Array 类来实现动态数组(Dynamic array)。所谓动态数组,是指数组容量能够在使用的过程中随时增大或减小。
动态数组的完整实现代码如下
#include <iostream>
#include <cstring>
#include <cstdlib>
using namespace std;
template<typename T, int N>
class Array{
public:
Array();
~Array();
public:
T & operator[](int i); //重载下标运算符[]
int length() const { return m_length; } //获取数组长度
bool capacity(int n); //改变数组容量
private:
int m_length; //数组的当前长度
int m_capacity; //当前内存的容量(能容乃的元素的个数)
T *m_p; //指向数组内存的指针
};
template<typename T, int N>
Array<T, N>::Array(){
m_p = new T[N];
m_capacity = m_length = N;
}
template<typename T, int N>
Array<T, N>::~Array(){
delete[] m_p;
}
template<typename T, int N>
T & Array<T, N>::operator[](int i){
if(i<0 || i>=m_length){
cout<<"Exception: Array index out of bounds!"<<endl;
}
return m_p[i];
}
template<typename T, int N>
bool Array<T, N>::capacity(int n){
if(n > 0){ //增大数组
int len = m_length + n; //增大后的数组长度
if(len <= m_capacity){ //现有内存足以容纳增大后的数组
m_length = len;
return true;
}else{ //现有内存不能容纳增大后的数组
T *pTemp = new T[m_length + 2 * n * sizeof(T)]; //增加的内存足以容纳 2*n 个元素
if(pTemp == NULL){ //内存分配失败
cout<<"Exception: Failed to allocate memory!"<<endl;
return false;
}else{ //内存分配成功
memcpy( pTemp, m_p, m_length*sizeof(T) );
delete[] m_p;
m_p = pTemp;
m_capacity = m_length = len;
}
}
}else{ //收缩数组
int len = m_length - abs(n); //收缩后的数组长度
if(len < 0){
cout<<"Exception: Array length is too small!"<<endl;
return false;
}else{
m_length = len;
return true;
}
}
}
int main(){
Array<int, 5> arr;
//为数组元素赋值
for(int i=0, len=arr.length(); i<len; i++){
arr[i] = 2*i;
}
//第一次打印数组
for(int i=0, len=arr.length(); i<len; i++){
cout<<arr[i]<<" ";
}
cout<<endl;
//扩大容量并为增加的元素赋值
arr.capacity(8);
for(int i=5, len=arr.length(); i<len; i++){
arr[i] = 2*i;
}
//第二次打印数组
for(int i=0, len=arr.length(); i<len; i++){
cout<<arr[i]<<" ";
}
cout<<endl;
//收缩容量
arr.capacity(-4);
//第三次打印数组
for(int i=0, len=arr.length(); i<len; i++){
cout<<arr[i]<<" ";
}
cout<<endl;
return 0;
}
模板实例化
模板(Templet)并不是真正的函数或类,它仅仅是编译器用来生成函数或类的一张“图纸”。模板不会占用内存,最终生成的函数或者类才会占用内存。由模板生成函数或类的过程叫做模板的实例化(Instantiate),相应地,针对某个类型生成的特定版本的函数或类叫做模板的一个实例(Instantiation)。
在学习模板以前,如果想针对不同的类型使用相同的算法,就必须定义多个极其相似的函数或类,这样不但做了很多重复性的工作,还导致代码维护困难,用于交换两个变量的值的 Swap() 函数就是一个典型的代表。而有了模板后,这些工作都可以交给编译器了,编译器会帮助我们自动地生成这些代码。从这个角度理解,模板也可以看做是编译器的一组指令,它命令编译器生成我们想要的代码。
模板的实例化是按需进行的,用到哪个类型就生成针对哪个类型的函数或类,不会提前生成过多的代码。也就是说,编译器会根据传递给类型参数的实参(也可以是编译器自己推演出来的实参)来生成一个特定版本的函数或类,并且相同的类型只生成一次。实例化的过程也很简单,就是将所有的类型参数用实参代替。
例如,给定下面的函数模板和函数调用:
template<typename T> void Swap(T &a, T &b){
T temp = a;
a = b;
b = temp;
}
int main(){
int n1 = 100, n2 = 200, n3 = 300, n4 = 400;
float f1 = 12.5, f2 = 56.93;
Swap(n1, n2); //T为int,实例化出 void Swap(int &a, int &b);
Swap(f1, f2); //T为float,实例化出 void Swap(float &a, float &b);
Swap(n3, n4); //T为int,调用刚才生成的 void Swap(int &a, int &b);
return 0;
}
编译器会根据不同的实参实例化出不同版本的 Swap() 函数。对于Swap(n1, n2)
调用,编译器会生成并编译一个 Swap() 版本,其中 T 被替换为 int:
void Swap(int &a, int &b){
int temp = a;
a = b;
b = temp;
}
对于Swap(f1, f2)
调用,编译器会生成另一个 Swap() 版本,其中 T 被替换为 float。对于Swap(n3, n4)
调用,编译器不会再生成新版本的 Swap() 了,因为刚才已经针对 int 生成了一个版本,直接拿来使用即可。
另外需要注意的是类模板的实例化,通过类模板创建对象时并不会实例化所有的成员函数,只有等到真正调用它们时才会被实例化;如果一个成员函数永远不会被调用,那它就永远不会被实例化。这说明类的实例化是延迟的、局部的,编译器并不着急生成所有的代码。
基于传统的编程思维,初学者往往也会将模板(函数模板和类模板)的声明和定义分散到不同的文件中,以期达到「模块化编程」的目的。但事实证明这种做法是不对的,程序员惯用的做法是将模板的声明和定义都放到头文件中。
模板并不是真正的函数或类,它仅仅是用来生成函数或类的一张“图纸”,在这个生成过程中有三点需要明确:
- 模板的实例化是按需进行的,用到哪个类型就生成针对哪个类型的函数或类,不会提前生成过多的代码;
- 模板的实例化是由编译器完成的,而不是由链接器完成的;
- 在实例化过程中需要知道模板的所有细节,包含声明和定义。
「不能将模板的声明和定义分散到多个文件中」的根本原因是:模板的实例化是由编译器完成的,而不是由链接器完成的,这可能会导致在链接期间找不到对应的实例。
类模板的显式实例化
类模板的显式实例化和函数模板类似。以上节的 Point 类为例,针对char*
类型的显式实例化(定义形式)代码为:
template class Point<char*, char*>;
相应地,它的声明形式为:
extern template class Point<char*, char*>;
不管是声明还是定义,都要带上class
关键字,以表明这是针对类模板的。
另外需要注意的是,显式实例化一个类模板时,会一次性实例化该类的所有成员,包括成员变量和成员函数。
有了类模板的显式实例化,就可以将类模板的声明和定义分散到不同的文件中了,下面我们就来修复上节的代码。
point.cpp 源文件:
#include <iostream>
#include "point.h"
using namespace std;
template<class T1, class T2>
void Point<T1, T2>::display() const{
cout<<"x="<<m_x<<", y="<<m_y<<endl;
}
//显式实例化定义
template class Point<char*, char*>;
template class Point<int, int>;
point.h 源码:
#ifndef _POINT_H
#define _POINT_H
template<class T1, class T2>
class Point{
public:
Point(T1 x, T2 y): m_x(x), m_y(y){ }
public:
T1 getX() const{ return m_x; }
void setX(T1 x){ m_x = x; }
T2 getY() const{ return m_y; };
void setY(T2 y){ m_y = y; };
void display() const;
private:
T1 m_x;
T2 m_y;
};
#endif
main.cpp 源码:
#include <iostream>
#include "point.h"
using namespace std;
//显式实例化声明(也可以不写)
extern template class Point<char*, char*>;
extern template class Point<int, int>;
int main(){
Point<int, int> p1(10, 20);
p1.setX(40);
p1.setY(50);
cout<<"x="<<p1.getX()<<", y="<<p1.getY()<<endl;
Point<char*, char*> p2("东京180度", "北纬210度");
p2.display();
return 0;
}
总结
函数模板和类模板的实例化语法是类似的,我们不妨对它们做一下总结:
extern template declaration; //实例化声明
template declaration; //实例化定义
对于函数模板来说,declaration 就是一个函数原型;对于类模板来说,declaration 就是一个类声明。