可以这么说,泛型编程是面向对象编程最为强大的编程方式,它是一种不考虑具体数据数据类型的编程方式。c++的泛型编程有函数模板和类模板,先来看看函数模板。
1. 函数模板的引入
以交换值为例:我们要交换int型、char型和double型的两值,需要写3个函数:
void swap(int& a, int& b)
{
a ^= b;
b ^= a;
a ^= b;
}
void swap(char& a, char& b)
{
a ^= b;
b ^= a;
a ^= b;
}
void swap(double& a, double& b)
{
a ^= b;
b ^= a;
a ^= b;
}
函数体的实现是一样的,但是基于不同的数据类型就要定义不同的函数原型,这显然很繁琐。特别是强调代码复用的c++语言。(当然可以用宏定义的方式实现二值交换,姑且不论)
在c++的模板函数中,上面n个函数可以写成:
template <typename T>
void swap(T& a, T& b)
{
a ^= b;
b ^= a;
a ^= b;
}
//或者:
template <typename T>
void swap(T& a, T& b)
{
T tmp = a;
a = b;
b = a;
}
swap()函数是一个模板函数,T不是一个具体的数据类型,而是泛指任意的数据类型。函数模板是一种特殊的函数,可用于不同的数据类型的调用。
template关键字用于声明开始进行泛型编程,typename关键字则是用于声明泛指的数据类型。
函数模板的调用,可以使用自动类型推导和指定具体类型调用:
int main(void)
{
//编译器自动推导数据类型
int a = 1;
int b = 6;
swap(a, b);
//用户指定数据类型
char c1 = 12;
char c2 = 19;
swap<char>(c1, c2);
return 0;
}
2. 函数模板的本质
(1) 编译器通过函数模板的具体调用类型产生不同的函数,即编译器会对函数模板会进行两次编译:第一次是对模板函数代码进行编译,第二次则是对泛指任意的数据类型的T替换为实际调用类型的代码进行编译。
定义模板函数:
template <typename T>
void swap(T& a, T& b)
{
T tmp = a;
a = b;
b = tmp;
}
假设T为int类型,那么编译器将会根据函数模板生成一个原型为”void swap_int(int a, int b);”的函数,同理,若T为double类型,那么会产生一个原型为”void swap_double(double a, double b);”的函数(函数名可能不是这样子),也就是说两个函数的地址将会不相同,前者的地址类型为:
typedef void (*swap_int)(int&, int&);
后者的地址类型为:
typedef void (*swap_double)(double&, double&);
打印这两个函数的地址:
int main(void)
{
swap_int pint = swap; //注意,编译器会根据函数指针的类型推导出T为int类型
swap_double pdouble = swap; //T为double类型
printf("pint = %p\n", pint);
printf("pdouble = %p\n", pdouble);
return 0;
}
编译运行,函数地址确实不同:
(2) 函数模板还可以定义任意多个不同的泛指数据类型,如:
template<typename T1, typename T2, typename T3>
T1 add(T2 a, T3 b)
{
return static_cast<int>(a + b);
}
int main(void)
{
int result = add<int, double, double>(0.5, 0.8);
printf("result = %d\n", result);
return 0;
}
编译运行:
前面说到编译器可以自动推导数据类型,那编译器是否会自动推导返回值的数据类型?
template<typename T1, typename T2, typename T3>
T1 add(T2 a, T3 b)
{
return static_cast<int>(a + b);
}
int main(void)
{
int result = add(0.5, 0.8);
printf("result = %d\n", result);
return 0;
}
编译运行:
无法推断模板参数“T1”类型,可见:编译器无法自动推导返回值的类型。编译器无法自动推导返回值的类型,但是可以自动推导形参的类型,那么这样一来,不就是说函数模板可以指定部分类型参数咯?确实如此,但是要从左至右进行部分指定,以上面为例,可以只指定T1类型:
template<typename T1, typename T2, typename T3>
T1 add(T2 a, T3 b)
{
return static_cast<int>(a + b);
}
int main(void)
{
int result = add<int>(0.5, 0.8);
printf("result = %d\n", result);
return 0;
}
编译运行:
也可以只指定T1、T2参数:
int main(void)
{
int result = add<int, float>(0.5, 0.8);
printf("result = %d\n", result);
return 0;
}
因为函数模板的返回值类型不能被编译器自动推导,且函数模板的部分自动推导需要从左至右,所以实际工程中会将函数返回值类型作为函数模板的第一个类型参数。
(3) 函数模板与函数重载
若代码中出现函数模板与重载函数,编译器会优先调用Who?
template <typename T>
T add(T a, T b)
{
printf("T add(T a, T b)\n");
return a + b;
}
int add(int a, int b)
{
printf("add(int a, int b)\n");
return a + b;
}
template <typename T>
T add(T a, T b, T c)
{
printf("T add(T a, T b, T c)\n");
return a + b + c;
}
int main(void)
{
add(1, 2); //普通函数
add(4, 5, 6); //函数模板
add<>(1, 2); //函数模板
add<>(6, 6, 2); //函数模板
add('a', 2); //普通函数
//add<>('a', 2);//编译出错,函数模板不支持隐式类型转换
return 0;
}
编译运行:
得出结论:
a. 函数模板可以像普通函数一样被重载,当普通函数和模板函数都支持具备被调用的匹配规则时,编译器会优先考虑普通函数
b. 若限定编译器只匹配函数模板,可以通过空模板实参列表符号<>指定
(4) 函数模板的特化
函数模板在某种特定类型下的特定实现称之为函数模板的特化。以如下函数模板为例:
template <typename T>
T add(T a, T b)
{
printf("T add(T a, T b)\n");
return a + b;
}
这个函数模板只能支持常见的int、double、char等普通数据类型和类类型,并不支持指针类型,如int*:假设T为int*类型,那么a + b 等价于对两个地址相加:
所以需要对这种类型进行特化:
template < >
//int* add(int* pa, int* pb) 这样也可以
int* add<int*>(int* pa, int* pb)
{
printf("T add(const int* pa, const int* pb)\n");
*pa += *pb;
return pa;
}
int main(void)
{
int a = 8, b = 6;
add<>(&a, &b); //<>,编译器会自动推导数据类型为int*
return 0;
}
编译运行:
注意,原先函数的模板的参数和返回值都是T,在这里还是要遵循这个规则,参数类型和返回值都是int*。函数模板特化后,当函数被调用时编译器发现特化后的函数匹配,会优先调用特化后的函数。
(5) 函数模板参数除了可以是类型参数,还可以是数值型参数,如:
//template <typename T, double n> //数值型参数不能为浮点数
template <typename T, int n>
void func()
{
T a[n] = {}; //用模板参数定义局部数组
}
int main(void)
{
int a = 5;
func<int, 10>; //正确
func<int, a>; //error,a为变量
return 0;
}
但是要注意,数值型的模板参数存在限制:变量、浮点数、类对象不能作为模板的数值参数。因为模板参数是在编译阶段就需要被确定的的元素,也就说说在编译阶段就要确定唯一值。