12 | 编译期多态:泛型编程和模板入门
面向对象和多态
在面向对象的开发里,最基本的一个特性就是“多态” ——用相同的代码得到不同结果。以我们在[第 1 讲] 提到过的 shape 类为例,它可能会定义一些通用的功能,然后在子类里进行实现或覆盖:
class shape {
public:
…
virtual void draw(const position&) = 0;
};
上面的类定义意味着所有的子类必须实现 draw 函数,所以可以认为 shape 是定义了一个接口(按 Java 的概念)。在面向对象的设计里,接口抽象了一些基本的行为,实现类里则去具体实现这些功能。当我们有着接口类的指针或引用时,我们实际可以唤起具体的实现类里的逻辑。比如,在一个绘图程序里,我们可以在用户选择一种形状时,把形状赋给一个 shape 的(智能)指针,在用户点击绘图区域时,执行 draw 操作。根据指针指向的形状不同,实际绘制出的可能是圆,可能是三角形,也可能是其他形状。
但这种面向对象的方式,并不是唯一一种实现多态的方式。在很多动态类型语言里,有所谓的“鸭子”类型:
如果一只鸟走起来像鸭子、游起泳来像鸭子、叫起来也像鸭子,那么这只鸟就可以被当作鸭子。
在这样的语言里,你可以不需要继承来实现 circle、triangle 等类,然后可以直接在这个类型的变量上调用 draw 方法。如果这个类型的对象没有 draw 方法,你就会在执行到 draw() 语句的时候得到一个错误(或异常)。
回想一下python
鸭子类型使得开发者可以不使用继承体系来灵活地实现一些“约定”,尤其是使得混合不同来源、使用不同对象继承体系的代码成为可能。唯一的要求只是,这些不同的对象有“共通”的成员函数。这些成员函数应当有相同的名字和相同结构的参数(并不要求参数类型相同)。
容器类的共性
容器类是有很多共性的。其中,一个最最普遍的共性就是,容器类都有 begin 和 end 成员函数——这使得通用地遍历一个容器成为可能。容器类不必继承一个共同的 Container 基类,而我们仍然可以写出通用的遍历容器的代码,如使用基于范围的循环。
大部分容器是有 size 成员函数的,在“泛型”编程中,我们同样可以取得一个容器的大小,而不要求容器继承一个叫 SizeableContainer 的基类。
很多容器具有 push_back 成员函数,可以在尾部插入数据。同样,我们不需要一个叫 BackPushableContainer 的基类。在这个例子里,push_back 函数的参数显然是都不一样的,但明显,所有的 push_back 函数都只接收一个参数。
我们可以清晰看到的是,虽然 C++ 的标准容器没有对象继承关系,但彼此之间有着很多的同构性。这些同构性很难用继承体系来表达,也完全不必要用继承来表达。C++ 的模板,已经足够表达这些鸭子类型。
当然,作为一种静态类型语言,C++ 是不会在运行时才报告“没找到 draw 方法”这类问题的。这类错误可以在编译时直接捕获,更精确地来说,是在模板实例化的过程中。
C++ 模板
定义模板
学过算法的同学应该都知道求最大公约数的辗转相除法,代码大致如下:
int my_gcd(int a, int b)
{
while (b != 0) {
int r = a % b;
a = b;
b = r;
}
return a;
}
这里只有一个小小的问题,C++ 的整数类型可不止 int 一种啊。为了让这个算法对像长整型这样的类型也生效,我们需要把它定义成一个模板:
template <typename E>
E my_gcd(E a, E b) //可以拷贝(构造和赋值)
{
while (b != E(0)) { //可以通过常量 0 来构造 可以作不等于的比较
E r = a % b; //可以进行取余数的操作
a = b;
b = r;
}
return a;
}
这个代码里,基本上就是把 int 替换成了模板参数 E,并在函数的开头添加了模板的声明。我们对于“整数”这只鸭子的要求实际上是:
- 可以通过常量 0 来构造
- 可以拷贝(构造和赋值)
- 可以作不等于的比较
- 可以进行取余数的操作
对于标准的 int、long、long long 等类型及其对应的无符号类型,以上代码都能正常工作,并能得到正确的结果。
实例化模板
不管是类模板还是函数模板,编译器在看到其定义时只能做最基本的语法检查,真正的类型检查要在实例化(instantiation)的时候才能做。一般而言,这也是编译器会报错的时候。
实例化失败的话,编译当然就出错退出了。如果成功的话,模板的实例就产生了。在整个的编译过程中,可能产生多个这样的(相同)实例,但最后链接时,会只剩下一个实例。这也是为什么 C++ 会有一个单一定义的规则:如果不同的编译单元看到不同的定义的话,那链接时使用哪个定义是不确定的,结果就可能会让人吃惊。
其实在c++中还包括类的成员函数也仅有一份的规定,具体放在那个文件中,可以参考《深度探索c++对象模型》
模板还可以显式实例化和外部实例化。
类似的,当我们在使用 vector
特化模板
我们需要使用的模板参数类型,不能完全满足模板的要求,应该怎么办?
我们实际上有好几个选择:
- 添加代码,让那个类型支持所需要的操作(对成员函数无效)。
- 对于函数模板,可以直接针对那个类型进行重载。
- 对于类模板和函数模板,可以针对那个类型进行特化。
特化和重载在行为上没有本质的区别。就一般而言,特化是一种更通用的技巧,最主要的原因是特化可以用在类模板和函数模板上,而重载只能用于函数。
通用而言,Herb Sutter 给出了明确的建议:对函数使用重载,对类模板进行特化。
展示特化的更好的例子是 C++11 之前的静态断言。使用特化技巧可以大致实现 static_assert 的功能:
template <bool>
struct compile_time_error;
template <>
struct compile_time_error<true> {};
#define STATIC_ASSERT(Expr, Msg) \
{ \
compile_time_error<bool(Expr)> \
ERROR_##_Msg; \
(void)ERROR_##_Msg; \
}
上面首先声明了一个 struct 模板,然后仅对 true 的情况进行了特化,产生了一个 struct 的定义。这样。如果遇到 compile_time_error
“动态”多态和“静态”多态的对比
我前面描述了面向对象的“动态”多态,也描述了 C++ 里基于泛型编程的“静态”多态。需要看到的是,两者解决的实际上是不太一样的问题。“动态”多态解决的是运行时的行为变化——就如我前面提到的,选择了一个形状之后,再选择在某个地方绘制这个形状——这个是无法在编译时确定的。“静态”多态或者“泛型”——解决的是很不同的问题,让适用于不同类型的“同构”算法可以用同一套代码来实现,实际上强调的是对代码的复用。C++ 里提供了很多标准算法,都一样只作出了基本的约定,然后对任何满足约定的类型都可以工作。以排序为例,C++ 里的标准 sort 算法(以两参数的重载为例)只要求:
- 参数满足随机访问迭代器的要求。
- 迭代器指向的对象之间可以使用 < 来比较大小,满足严格弱序关系。
- 迭代器指向的对象可以被移动。
它的性能超出 C 的 qsort,因为编译器可以内联(inline)对象的比较操作;而在 C 里面比较只能通过一个额外的函数调用来实现。此外,C 的 qsort 函数要求数组指向的内容是可按比特复制的,C++ 的 sort 则要求迭代器指向的内容是可移动的,可适用于更广的情况。
C++ 里目前有大量这样的泛型算法。随便列举几个:
- sort:排序
- reverse:反转
- count:计数
- find:查找
- max:最大值
- min:最小值
- minmax:最小值和最大值
- next_permutation:下一个排列
- gcd:最大公约数
- lcm:最小公倍数