现代C++:编译期多态:泛型编程和模板入门
引言相信你对多态这个面向对象的特性应该是很熟悉了。我们今天来讲一个非常 C 的话题编译期多态及其相关的 C 概念。面向对象和多态在面向对象的开发里最基本的一个特性就是“多态” ——用相同的代码得到不同结果。以我们之前提到过的 shape 类为例它可能会定义一些通用的功能然后在子类里进行实现或覆盖class shape { public: … virtual void draw(const position) 0; };上面的类定义意味着所有的子类必须实现 draw 函数所以可以认为 shape 是定义了一个接口按 Java 的概念。在面向对象的设计里接口抽象了一些基本的行为实现类里则去具体实现这些功能。当我们有着接口类的指针或引用时我们实际可以唤起具体的实现类里的逻辑。比如在一个绘图程序里我们可以在用户选择一种形状时把形状赋给一个 shape 的智能指针在用户点击绘图区域时执行 draw 操作。根据指针指向的形状不同实际绘制出的可能是圆可能是三角形也可能是其他形状。但这种面向对象的方式并不是唯一一种实现多态的方式。在很多动态类型语言里有所谓的“鸭子”类型如果一只鸟走起来像鸭子、游起泳来像鸭子、叫起来也像鸭子那么这只鸟就可以被当作鸭子。在这样的语言里你可以不需要继承来实现 circle、triangle 等类然后可以直接在这个类型的变量上调用 draw 方法。如果这个类型的对象没有 draw 方法你就会在执行到 draw() 语句的时候得到一个错误或异常。鸭子类型使得开发者可以不使用继承体系来灵活地实现一些“约定”尤其是使得混合不同来源、使用不同对象继承体系的代码成为可能。唯一的要求只是这些不同的对象有“共通”的成员函数。这些成员函数应当有相同的名字和相同结构的参数并不要求参数类型相同。听起来很抽象我们来看一下 C 中的具体例子。容器类的共性容器类是有很多共性的。其中一个最最普遍的共性就是容器类都有 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)) { E r a % b; a b; b r; } return a; }这个代码里基本上就是把 int 替换成了模板参数 E并在函数的开头添加了模板的声明。我们对于“整数”这只鸭子的要求实际上是可以通过常量 0 来构造可以拷贝构造和赋值可以作不等于的比较可以进行取余数的操作对于标准的 int、long、long long 等类型及其对应的无符号类型以上代码都能正常工作并能得到正确的结果。至于类模板的例子我们可以直接参考之前说的智能指针这儿就不再重复了。实例化模板不管是类模板还是函数模板编译器在看到其定义时只能做最基本的语法检查真正的类型检查要在实例化instantiation的时候才能做。一般而言这也是编译器会报错的时候。对于我们上面 my_gcd 的情况如果提供的是一般的整数类型那是不会有问题的。但如果我们提供一些其他类型的时候就有可能出问题了。以 CLN一个高精度数字库为例注我并不是推荐大家使用这个库如果我们使用它的 cl_I 高精度整数类型来调用 my_gcd 的话出错信息大致如下其原因是虽然它的整数类 cl_I 设计得很像普通的整数但这个类的对象不支持 % 运算符。出错的第 20 行是我们调用 my_gcd 的位置而第 9 行是函数模板定义中执行取余数操作的位置。实例化失败的话编译当然就出错退出了。如果成功的话模板的实例就产生了。在整个的编译过程中可能产生多个这样的相同实例但最后链接时会只剩下一个实例。这也是为什么 C 会有一个单一定义的规则如果不同的编译单元看到不同的定义的话那链接时使用哪个定义是不确定的结果就可能会让人吃惊。模板还可以显式实例化和外部实例化。如果我们在调用 my_gcd 之前进行显式实例化——即使用 template 关键字并给出完整的类型来声明函数template cln::cl_I my_gcd(cln::cl_I, cln::cl_I);那出错信息中的第二行就会显示要求实例化的位置。如果在显式实例化的形式之前加上 extern 的话编译器就会认为这个模板已经在其他某个地方实例化从而不再产生其定义但代码用到的内联函数仍可能会导致实例化的发生这个会随编译器和优化选项不同而变化。在我们这个例子里就意味着不会产生上面的编译错误信息了。当然我们仍然会在链接时得到错误因为我们并没有真正实例化这个模板。类似的当我们在使用 vector 这样的表达式时我们就在隐式地实例化 vector。我们同样也可以选择用 template class vector; 来显式实例化或使用 extern template class vector; 来告诉编译器不需要实例化。显式实例化和外部实例化通常在大型项目中可以用来集中模板的实例化从而加速编译过程——不需要在每个用到模板的地方都进行实例化了——但这种方式有额外的管理开销如果实例化了不必要实例化的模板的话反而会导致可执行文件变大。因而显式实例化和外部实例化应当谨慎使用。特化模板如果遇到像前面 CLN 那样的情况我们需要使用的模板参数类型不能完全满足模板的要求应该怎么办我们实际上有好几个选择添加代码让那个类型支持所需要的操作对成员函数无效。对于函数模板可以直接针对那个类型进行重载。对于类模板和函数模板可以针对那个类型进行特化。对于 cln::cl_I 不支持 % 运算符这种情况恰好上面的三种方法我们都可以用。一、添加 operator% 的实现cln::cl_I operator%(const cln::cl_I lhs, const cln::cl_I rhs) { return mod(lhs, rhs); }在这个例子这可能是最简单的解决方案了。但在很多情况下尤其是对对象的成员函数有要求的情况下这个方法不可行。二、针对 cl_I 进行重载为通用起见我不直接使用 cl_I 的 mod 函数而用 my_mod 把 my_gcd 改造如下template typename E E my_gcd(E a, E b) { while (b ! E(0)) { E r my_mod(a, b); a b; b r; } return a; }然后一般情况的 my_mod 显然就是template typename E E my_mod(const E lhs, const E rhs) { return lhs % rhs; }最后针对 cl_I 类我们可以重载overloadcln::cl_I my_mod(const cln::cl_I lhs, const cln::cl_I rhs) { return mod(lhs, rhs); }三、针对 cl_I 进行特化同二类似但我们提供的不是一个重载而是特化specializationtemplate cln::cl_I my_modcln::cl_I( const cln::cl_I lhs, const cln::cl_I rhs) { return mod(lhs, rhs); }这个例子比较简单特化和重载在行为上没有本质的区别。就一般而言特化是一种更通用的技巧最主要的原因是特化可以用在类模板和函数模板上而重载只能用于函数。不过我只是展示了一种可能性而已。通用而言Herb Sutter 给出了明确的建议对函数使用重载对类模板进行特化。展示特化的更好的例子是 C11 之前的静态断言。使用特化技巧可以大致实现 static_assert 的功能template bool struct compile_time_error; template struct compile_time_errortrue {}; #define STATIC_ASSERT(Expr, Msg) \ { \ compile_time_errorbool(Expr) \ ERROR_##_Msg; \ (void)ERROR_##_Msg; \ }上面首先声明了一个 struct 模板然后仅对 true 的情况进行了特化产生了一个 struct 的定义。这样。如果遇到 compile_time_error 的情况——也就是下面静态断言里的 Expr 不为真的情况——编译就会失败报错因为 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最小公倍数等等内容小结本讲我们对模板、泛型编程和静态多态做了最基本的描述并和动态多态做了一定的比较。如果你不熟悉模板和泛型编程的话应该在本讲之后已经对其有了初步的了解我们可以在下面几讲中进行更深入的讨论。