【C++泛型学习笔记】函数模板

提到C++的程序设计方法,最先想到的便是两种:面向过程和面向对象编程。但是当我们去阅读一些优秀的C++库源码时(比如CGAL),就会直接被其的泛型编程劝退。泛型编程也是C++程序设计方法中的一种,不同于上述两种设计方法,其最突出的特点是提高代码复用性和减少代码冗余,这也是大型开源项目使用泛型的原因之一。而我学习泛型编程的目的在于,能够看懂CGAL等库的源码和具备高复用性代码编写能力,并且为学习STL、Boost等库打下基础。

学习参考书籍:王健伟《C++新经典:模板与泛型编程》

函数模板

基本范例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
using namespace std;

// 函数重载
int Sub(int tv1,int tv2)
{
return tv1 - tv2;
}

double Sub(double tv1, double tv2)
{
return tv1 - tv2;
}

// 函数模板
template<typename T>
T Sub(T tv1, T tv2)
{
return tv1 - tv2;
}

int main()
{
int subv1 = Sub(3, 5);
cout << "subv1 = " << subv1 << endl;
double subv2 = Sub(4.7, 2.1);
cout << "subv2 = " << subv2 << endl;
}

对于C++这种强类型语言,我们创建一个Sub函数用于返回两数相减结果,如果希望该函数不仅支持整数相减还支持浮点数相减,那么我们会通过重载函数的方式,写两个不同参数类型的同名Sub函数。这时,如果我们定义一个模板函数Sub,便可以减少一半的代码量。通过定义一个通用的模板函数,避免为每个类型都定义一个不同的函数。

函数模板的定义范例如下:

1
2
3
4
5
template<typename T>
T Sub(T tv1, T tv2)
{
return tv1 - tv2;
}
  • T:称为模板参数,确切讲是一个类型模板参数,代表的是一个类型。“T”也可以换成其他标识符。

  • 模板的定义以关键字template开头。

  • 模板参数前面用typename进行修饰。

    也可以使用class代替typename对模板参数进行修饰。一般人们习惯用typename表明模板实参可以是任一类型,而class表明模板实参必须是一个类类型。

  • 模板参数及其修饰符都用一对**尖括号<>**括起来。

  • 虽然模板参数不限制类型,但是需要注意的是传入参数类型必须合法,即对函数中进行的操作是合法的,不然在编译阶段编译器将会判断出来,并报错。

模板实例化

我们会疑惑,既然没有声明函数参数的类型,那么程序是怎么计算出来的呢?其实在编译过程中,若我们调用了某个函数模板,那么编译器会对这个函数模板进行实例化,用具体的“类型”代替“类型模板参数”的过程叫做实例化(也称代码生成器)。

所以,我们可以认为虽然我们没有完成对函数参数进行类型声明,但是编译器却很智能的根据我们调用函数输入的参数类型自动对模板进行了实例化。为了证实这个想法,我们通过在Developer Command Prompt for VS中使用dumpbin工具将编译生成的.obj文件转换成.txt便于我们分析编译结果。

image-20221203172956885

注意需要用管理员权限打开命令行,并且进入到.obj所在文件夹下执行如上语句。

在生成的.txt文件中,我们可以找到如下字段:

image-20221203173317433

image-20221203173340176

如上则是Sub函数模板实例化的结果,实例化后的函数名分别为Sub<int>和Sub<double>。该函数名由三部分组成:模板名、一对尖括号<>和括号间一个具体类型。

模板参数推断

前面讲到,编译器能根据传入参数类型自动推断模板参数类型。但是如果出现下面这种情况。

1
2
3
4
5
6
7
8
9
10
11
template<typename T,typename U,typename V>
V Sub(T tv1, U tv2)
{
return tv1 - tv2;
}

int main()
{
double subv1 = Sub(3, 5.1);
cout << "subv1 = " << subv1 << endl;
}

模板参数有三个,分别是传入类型T、U和返回类型V,运行报错:error C2783: "V Sub(T,U)": 无法推导 "V" 的 模板 参数。虽然T、U的类型能够像之前那样根据传入参数类型推断出来,但是这里完全没有告诉任何返回值的类型,所以会报错。解决方法如下:

  • 手动指定类型。
1
double subv1 = Sub<int,double,double>(3, 5.1);

这里可以看到,我们为了指定第三个模板参数V的类型,将前两个都指定了。因为规定我们可以在调用时通过尖括号指定一部分模板参数,另一部分则可以由编译器去推断。规定语法为:一旦某一位置模板参数需要编译器推断,那么之后的所有参数都应该由编译器推断。故我们可以将需要推断的模板参数置后,需指定的参数置前,就得到如下写法:

1
2
3
4
5
6
7
8
9
10
11
template<typename V,typename T,typename U>
V Sub(T tv1, U tv2)
{
return tv1 - tv2;
}

int main()
{
double subv1 = Sub<double>(3, 5.1);
cout << "subv1 = " << subv1 << endl;
}

再者,还可以通过返回类型后置语法进行改写:

1
2
3
4
5
6
7
8
9
10
11
template<typename T,typename U>
auto Sub(T tv1, U tv2) -> decltype(tv1 + tv2)
{
return tv1 - tv2;
}

int main()
{
double subv1 = Sub(3, 5.1);
cout << "subv1 = " << subv1 << endl;
}

上述代码中,使用auto结合decltype完成返回类型推断。当然也能去掉 -> decltype(tv1 + tv2),使用auto关键字对类型进行自动推断。

空模板参数列表:<>,在调用函数时,在函数名后加上<>,可以明确所调用的对象为函数模板,而非同名的普通函数。

重载

函数模板重载概念:函数模板名字相同,但参数数量和参数类型不同。对于重载函数模板和同名普通函数,编译器会选择最合适的一个进行使用。当同名普通函数和函数模板都比较合适时,优先选择普通函数;在两个重载函数模板之间选择形参数量和类型最接近函数调用输入实参的一个。

泛化、全特化和偏特化

通常我们写的函数模板(如上面的示例)都是泛化的函数模板。而特化版本是从函数模板的泛化版本中抽出的一个子集

全特化:把泛化版本中所有的模板参数都用具体类型替代。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 函数模板(泛化版本)
template<typename T,typename U>
auto Sub(T tv1, U tv2)
{
printf("泛化版本");
return tv1 - tv2;
}

// 全特化版本
template<> // 全特化<>中为空,所有模板参数都用具体类型替代
auto Sub<int, double>(int tv1, double tv2)
{
printf("全特化版本");
return tv1 - tv2;
}

// 偏特化版本(局部特化)-模板参数数量上的偏特化
template<typename T>
auto Sub(T tv1, int tv2)
{
printf("偏特化版本");
return tv1 - tv2;
}

int main()
{
int a = 3, b = 5; // 调用偏特化版本
//double a = 3.0, b = 5.0; // 调用泛化版本
//int a = 3; double b = 5.0; // 调用全特化版本
cout << Sub(a, b) << endl;
}

函数调用顺序:同名普通函数>模板特化版本>模板泛化版本

默认参数

函数模板中的类型模板参数可以设置默认值。这样就可以不用指定形参类型。

1
2
3
4
5
6
7
8
9
10
template<typename V = double, typename T, typename U>
V Sub(T tv1, U tv2)
{
return tv1 - tv2;
}

int main()
{
cout << "subv1 = " << Sub(3, 5.1) << endl;
}

非类型模板参数

除了类型模板参数以外,函数模板还可以有非类型的普通模板参数。

1
2
3
4
5
6
7
8
9
10
template<int val, typename V = double, typename T, typename U>
V Sub(T tv1, U tv2)
{
return tv1 - tv2 - val;
}

int main()
{
cout << "subv1 = " << Sub<10>(3, 5.1) << endl;
}

同样,非类型模板参数val也能设置默认值。

Tips:

  • 非类型模板参数的值一般为常量
  • 并非任何类型的参数都能作为非类型模板参数。int类型可以,float、double或类类型不可以。

一些奇怪但合法的语法

  • ```c++
    template<typename, int>
    auto Add2()
    {

    return 100;
    

    }

    1
    2
    3
    4
    5
    6
    7
    8
    9

    > 模板参数未用到,可以省略标识符。

    - ```c++
    template<typename T, typename int val>
    auto Add2()
    {
    return 100;
    }

    第一个typename修饰类型模板参数T,第二个typename表示其后修饰的int是一个类型,虽然多余但是合法。