泛型编程:源起、实现与意义
By
刘未鹏
C++
的罗浮宫
(http://blog.csdn.net/pongba)
(
去年
12
月《程序员》的约稿
)
(
以前也写过一篇相关的文章:
Generic Programming - What Are You, anyway? )
为什么泛型
泛型编程(
Generic Programming
)最初提出时的动机很简单直接:发明一种语言机制,能够帮助实现一个通用的标准容器库。所谓通用的标准容器库,就是要能够做到,比如用一个
List
类存放所有可能类型的对象
,
这样的事情;熟悉一些其它面向对象的语言的人应该知道,如
Java
里面这是通过在
List
里面存放
Object
引用来实现的。
Java
的单根继承在这里起到了关键的作用。然而单根继承对
C++
这样的处在语言链底层的语言却是不能承受之重。此外使用单根继承来实现通用容器也会带来效率和类型安全方面的问题,两者都与
C++
的理念不相吻合。
于是
C++
另谋他法——除了单根继承之外,另一个实现通用容器的方案就是使用“参数化类型”。一个容器需要能够存放任何类型的对象,那干脆就把这个对象的类型“抽”出来,参数化它
[1]
:
template class vector {
T* v;
int sz;
public:
vector(int);
T& operator[](int);
T& elem(int i) { return v[i]; }
// ...
};
一般来说看到这个定义的时候,每个人都会想到
C
的宏。的确,模板和宏在精神上的确有相仿之处。而且的确,也有人使用
C
的宏来实现通用容器。模板是将一个定义里面的类型参数化出来,而宏也可以做到参数化类型。甚至某种意义上可以说宏是模板的超集——因为宏不仅可以参数化类型,宏实质上可以参数化一切文本,因为它本来就是一个文本替换工具。然而,跟模板相比,宏的最大的缺点就是它并不工作在
C++
的语法解析层面,宏是由预处理器来处理的,而在预处理器的眼里没有
C++
,只有一堆文本,因此
C++
的类型检查根本不起作用。比如上面的定义如果用宏来实现,那么就算你传进去的
T
不是一个类型,预处理器也不会报错;只有等到文本替换完了,到
C++
编译器工作的时候才会发现一堆莫名其妙的类型错误,但那个时候错误就已经到处都是了。往往最后会抛出一堆吓人的编译错误。更何况宏基本无法调试。
注
1
实际上,还有一种实现通用容器的办法。只不过它更糟糕:它要求任何能存放在容器内的类型都继承自一个NodeBase,NodeBase里面有pre和next指针,通过这种方式,就可以将任意类型链入一个链表内了。但这种方式的致命缺点是(1)它是侵入性的,每个能够放在该容器内的类型都必须继承自NodeBase基类。(2)它不支持基本内建类型(int、double等),因为内建类型并不,也不能继承自NodeBase。这还姑且不说它是类型不安全的,以及效率问题。
我们再来看一看通用算法,这是泛型的另一个动机。比如我们熟悉的
C
的
qsort
:
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
这个算法有如下几个问题:
1.
类型安全性:使用者必须自行保证
base
指向的数组的元素类型和
compar
的两个参数的类型是一致的;使用者必须自行保证
size
必须是数组元素类型的大小。
2.
通用性:
qsort
对参数数组的二进制接口有严格要求——它必须是一个内存连续的数组。如果你实现了一个巧妙的、分段连续的自定义数组,就没法使用
qsort
了。
3.
接口直观性:如果你有一个数组
char* arr = new arr[10];
那么该数组的元素类型其实就已经“透露”了它自己的大小。然而
qsort
把数组的元素类型给“
void
”掉了(
void *base
),于是丢失掉了这一信息,而只能让调用方手动提供一个
size
。为什么要把数组类型声明为
void*
?因为除此之外别无它法,声明为任意一个类型的指针都不妥(
compar
的参数类型也是如此)。
qsort
为了通用性,把类型信息丢掉了,进而导致了必须用额外的参数来提供类型大小信息。在这个特定的算法里问题还不明显,毕竟只多一个
size
参数而已,但一旦涉及的类型信息多了起来,其接口的可伸缩性(
scalability
)问题和直观性问题就会逐渐显现。
4.
效率:
compar
是通过函数指针调用的,这带来了一定的开销。但跟上面的其它问题比起来这个问题还不是最严重的。
泛型编程
泛型编程最初诞生于
C++
中,由
Alexander Stepanov[2]
和
David Musser[3]
创立。目的是为了实现
C++
的
STL
(标准模板库)。其语言支持机制就是模板(
Templates
)。模板的精神其实很简单:参数化类型。换句话说,把一个原本特定于某个类型的算法或类当中的类型信息抽掉,抽出来做成模板参数
T
。比如
qsort
泛化之后就变成了:
template
void sort(RandomAccessIterator first, RandomAccessIterator last,
Compare comp);
其中
first
,
last
这一对迭代器代表一个前闭后开区间,迭代器和前开后闭区间都是
STL
的核心概念。迭代器建模的是内建指针的接口(解引用、递增、递减等)、前开后闭区间是一个简单的数学概念,表示从
first
(含
first
)到
last
(不含
last
)的区间内的所有元素。此外,
comp
是一个仿函数(
functor
)。仿函数也是
STL
的核心概念,仿函数是建模的内建函数的接口,一个仿函数可以是一个内建的函数,也可以是一个重载了
operator()
的类对象,只要是支持函数调用的语法形式就可成为一个仿函数。
通过操作符重载,
C++
允许了自定义类型具有跟内建类型同样的使用接口;又通过模板这样的参数化类型机制,
C++
允许了一个算法或类定义,能够利用这样的接口一致性来对自身进行泛化。例如,一个原本操作内建指针的算法,被泛化为操纵一切迭代器的算法。一个原本使用内建函数指针的算法,被泛化为能够接受一切重载了函数调用操作符(
operator()
)的类对象的算法。
让我们来看一看模板是如何解决上面所说的
qsort
的各个问题的:
1.
类型安全性:如果你调用
std::sort(arr, arr + n, comp);
那么
comp
的类型就必须要和
arr
的数组元素类型一致,否则编译器就会帮你检查出来。而且
comp
的参数类型再也不用
const void*
这种不直观的表示了,而是可以直接声明为对应的数组元素的类型。
2.
通用性:这个刚才已经说过了。泛型的核心目的之一就是通用性。
std::sort
可以用于一切迭代器,其
compare
函数可以是一切支持函数调用语法的对象。如果你想要将
std::sort
用在你自己的容器上的话,你只要定义一个自己的迭代器类(严格来说是一个随机访问迭代器,
STL
对迭代器的访问能力有一些分类,随机访问迭代器具有建模的内建指针的访问能力),如果需要的话,再定义一个自己的仿函数类即可。
3.
接口直观性:跟
qsort
相比,
std::sort
的使用接口上没有多余的东西,也没有不直观的
size
参数。一个有待排序的区间,一个代表比较标准的仿函数,仅此而已
[4]
。
4.
效率:如果你传给
std::sort
的
compare
函数是一个自定义了
operator()
的仿函数。那么编译器就能够利用类型信息,将对该仿函数的
operatpr()
调用直接内联。消除函数调用开销。
动态多态与静态多态
泛型编程的核心活动是抽象:将一个特定于某些类型的算法中那些类型无关的共性抽象出来,比如,在
STL
的概念体系里面,管你是一个数组还是一个链表,反正都是一个区间,这就是一层抽象。管你是一个内建函数还是一个自定义类,反正都是一个
Callable
(可调用)的对象(在
C++
里面通过仿函数来表示),这就是一层抽象。泛型编程的过程就是一个不断将这些抽象提升(
lift
)出来的过程,最终的目的是形成一个最大程度上通用的算法或类。
有人肯定会问,既然同是抽象,那为什么不用基于多态的面向对象抽象呢?比如
STL
的
std::for_each
,用
Java
的多态机制也可以解决:
interface IUnaryFun
{
void invoke(Object o);
}
interface IInputIter
{
IInputIter preIncrement();
boolean equals(IInputIter otherIter);
… // other methods
}
IUnaryFun for_each(IInputIter first, IInputIter last, IUnaryFun func)
{
for(;!first.equals(last); first.preIncrement())
func.invoke(*first);
return func;
}
其实,这里最主要的原因很简单,效率。面向对象的多态引入了间接调用。当然,并不是说间接调用不好,有些时候,比如确实需要运行期多态的时候,只能诉诸继承这样的手段。但当能够利用编译期类型信息的时候,为什么要付出不必要的间接调用开销呢?比如这里的
for_each
,利用接口来实现其通用性,就付出了所谓的“抽象惩罚”(
abstraction penalty
)。而
C++
的模板,就是为了消除这样的抽象惩罚。利用模板编写的
std::for_each
,对于每一个特定的参数类型组合都有一个独立的,最高效的实例化版本,就跟你手写一个特定于这些类型的专门的
for_each
算法一样
[5]
。于是抽象惩罚消失了,而这也正是
C++
模板库能够真正被工业界广泛用在
C++
最擅长的领域(重视效率的领域)的重要原因之一。
另一方面,对于每一组参数类型组合实例化一个版本出来的做法增加了代码空间,这是一个典型的以空间换时间的例子,不过对于一门静态并追求效率的语言来说,这个代码空间的开销反正也是必不可少的,因为即便你手动为各种不同的参数类型组合编写特定的算法版本的话,也是付出一样的代码空间开销,而且还顺便违反了
DRY
原则
[6]
。此外,由于在抽象的时候不用总想着要建立的接口,所以泛型算法编写起来也更为直观。
C++
泛型的另一个好处就是,跟面向对象编程的基于继承和虚函数的运行时多态机制不同,
C++
模板是非侵入性的。你不需要让你的类继承自某个特定的接口才能用于某个算法,只要支持特定的语法接口就行了(比如只要支持
begin()
调用)。这也被称为结构一致性(
Structural Conformance
),意即只要语法结构一致即可。而另一方面,基于接口继承的面向对象多态则必须要显式地声明继承自一个接口,这就是所谓的名字一致性(
Named Conformance
)。
当然,泛型支持的静态多态和虚函数支持的动态多态并没有任何程度上绝对的优劣之分。它们适用于不同的场合。当类型信息可得的时候,利用编译期多态能够获得最大的效率和灵活性。当具体的类型信息不可得,就必须诉诸运行期多态了。
Bjarne Stroustrup
曾经用了一个典型的例子来澄清这个区别:
std::vector v;
… // fill v
std::for_each(v.begin(), v.end(), std::mem_fun(&Shape::draw));
这里,
v
里面到底将来会存放什么类型的
Shape
,编译期无法知道,因而必须求助于动态多态。另一方面,编译器倒的确知道它们都继承自
Shape
,利用这仅有的静态类型信息,我们使用了泛型算法
std::for_each
和泛型容器
std::vector
。这里尤其值得注意的是
for_each
的静态多态行为:
for_each
只有一份模板实现,然而根据传给它的第三个参数(本例中是
std::mem_fun(&Shape::draw)
)的不同,
for_each
的行为也不同(这里最终被
for_each
调用的是
Shape::draw
,但实际上你可以包装任何函数,只要这个函数接受一个
Shape*
型的参数),
for_each
这种“行为不同”是发生在编译期的,所以是静态多态。
前面说过,模板与接口继承比较,模板是非侵入的。这是
C++
泛型与面向对象的多态机制的本质区别之一。但实际上,面向对象未必就意味着一定要用接口来实现动态的多态。一些动态类型的脚本语言,如
Ruby
,它的多态机制就既是运行期(动态)的,又是非倾入性的(不用通过继承自某个特定的接口来达到复用)。人们把这个叫做
Duck Typing[7]
。如果不是因为效率问题,其实这样的多态机制才是最直观的,从使用方面来说,它既有非侵入性,又没有只能工作在编译期的限制。但效率至少在可见的将来、在某些领域仍是一个顾虑。因此像
C++
这种区分编译期和运行期多态的语言,仍有其独特的优势。
此外,泛型编程的类型安全优势也让它从
C++
走入了其它主流的静态类型语言当中,尤其是
C
家族的
Java
和
C#
,在前几年相继接纳泛型。
特化,图灵完备性,元编程
C++
的模板是支持特化的,这就给了它实现编译期控制结构的可能性,进而带来了一个图灵完备的子语言。模板特化的引入原本只是为了效率目的——针对不同的类型提供不同的实现。但后来却被发现能够实现编译期的
if/else
和递归等控制结构。
模板元编程最初由
Erwin Unruh
在
1994
年的一次会议上提出;当时他写了一个程序,在编译错误里面打印出一个素数序列。这个事件在
C++
历史上的地位就仿佛哥伦布发现新大陆。用
Bjarne Stroustrup
的话来说就是当时他当时和其他几个人觉得太神奇了。实际上,这个事情正标志了
C++
模板系统的图灵完备性被发现;后来
Todd Veldhuizen
写了一篇
paper
,用
C++
模板构建了一个元图灵机,从而第一次系统证明了
C++
模板的图灵完备性。接下来的事情就顺理成章了——一些
ad hoc
的模板元编程技巧不断被发掘出来,用于建造高效、高适应性的通用组件。最终,
David Abrahams
编写了
boost
库中的基本组件之一:
Boost.MPL
库。
Boost.MPL
以类型和编译期常量为数据,以模板特化为手段,实现了一个编译期的
STL
。你可以看到常见的
vector
,你可以看到
transform
算法,只不过算法操纵的对象和容器存放的对象不再是运行期的变量或对象,而是编译期的类型和常量。想知道模板元编程是如何用在库构建中的,可以打开一个
Boost
的子库(比如
Boost.Tuple
或
Boost.Variant
)看一看。
然而,毕竟
C++
的模板元编程是一门被发现而不是被发明的子语言。一方面,它在构建泛型库的时候极其有用。然而另一方面,由于它并非语言设计初始时考虑在内的东西,所以不仅在语法上面显得不那么
first-class
(比较笨拙);更重要的是,由于本不是一个
first-class
的语言特性,所以
C++
编译器并不知道
C++
元编程的存在。这就意味着,比如对下面这样一个编译期的
if/else
设施
[8]
:
template
struct if_ {
typedef X type; // use X if b is true
};
template
struct if_ {
typedef Y type; // use Y if b is false
};
typedef if_<(sizeof(Foobar)<40), Foo, Bar>::type type;
编译器并没有真的去进行
if/else
的分支选择,而是按部就班毫不知情地进行着模板的特化匹配。如果遇到
Boost.MPL
这样的模板元编程非常重的库,就会严重拖累编译速度,编译器进行了一重一重的特化匹配,实例化了一个又一个的模板实例,就是为了去获取里面定义的一个
typedef
,完了之后中间所有实例化出来的模板实例类型全都被丢弃
[9]
。
模板元编程最全面深入的介绍是
Boost.MPL
库的作者
David Abrahams
的《
C++ Template Metaprogramming
》,其中的样章(第三章)
[10]
对模板元编程作了一个非常高屋建瓴的概览
[11]
。
关于模板元编程,需要提醒的是,它并不属于“大众技术”。日常编程中极少需要用到元编程技巧。另一方面,
C++
模板里面有大量
ad hoc
的技巧,如果一头扎进去的话,很容易只见树木不见森林,所以需要提醒初学者的是,即便要学习,也要时刻保持“高度”,始终记得元编程的意义和目的,这样才不至于迷失在无穷无尽的语言细节中。
C++09
——进化
泛型编程在
C++
中取得了工业程度上的成功,得益于其高效性和通用性。但同时,在经过了近十年的使用之后,
C++
模板,这个作为
C++
实现泛型的机制的缺点也逐渐暴露出来。比如其中对于初学者最严重的一个问题就是在使用一个模板库时常常遇到无法理解的编译错误,动辄长达上
K
字节。这些编译错误很容易把一个初学者吓走。究其本质原因,为什么编译器会报出令人难以卒读的编译错误,是因为在编译器的眼里,只有类型,而没有“类型的类型”。比如说,迭代器就是一个类型的类型,
C++
里面也把它称为“概念”(
Concept
)。例如,
std::sort
要求参数必须是随机访问迭代器,如果你一不小心给了它一个非随机访问的迭代器,那么编译器不是抱怨“嘿!你给我的不是随机访问迭代器”,而是抱怨找不到某某重载操作符(典型的比如
operator+(int)
)。因为在编译器眼里,没有“迭代器”这么个概念,这个概念只存在于程序员脑子里和
STL
的文档里。为了帮助编译器产出更友好的信息(当然,还有许多非常重要的其它原因
[12]
),
C++09
将对“类型的类型”加入
first-class
的支持,其带来的众多好处会将
C++
中的泛型编程带向一个新的高度:更友好、更实用、更直观。
此外,
C++
的模板元编程尚没有
first-class
的语言支持,一方面是因为其运用不像一般的模板编程这么广泛,因而没有这么紧急。另一方面,
C++09
的时间表也等不及一个成熟的提案。如果以后模板元编程被运用得越来越广泛的话,那
first-class
的语言支持是难免的。
总结
本文对
C++
模板,以及
C++
模板所支持的泛型编程作了一个概览。着重介绍了泛型编程诞生的原因,泛型编程的过程和意义,与其它抽象手段的比较。并对
C++
中的模板元编程做了一些介绍。最后介绍了
C++
模板在
C++09
中的增强。
[1] B. Stroustrup: A History of C++: 1979-1991. Proc ACM History of Programming Languages conference (HOPL-2). March 1993.
[2] http://
en.wikipedia.org/wiki/Alexander_Stepanov
[3] http://www.cs.rpi.edu/~musser
[4] 实际上,STL的区间概念被证明是一个不完美的抽象。你有没有发现,要传递一个区间给一个函数,如std::sort,你需要传递两个参数,一个是区间的开头,一个是区间的末尾。这种分离的参数传递方式被证明是不明智的,在一些场合会带来使用上不必要的麻烦。比如你想迭代一组文件,代表这组文件的区间由一个readdir_sequence函数返回,由于要分离表达一个区间,你就必须写:
readdir_sequence entries(".", readdir_sequence::files);
std::for_each(entries.begin(), entries.end(), ::remove);
如果你只想遍历这个区间一次的话,你也许不想声明entries这个变量,毕竟多一个名字就多一个累赘,你也许只想:
std::for_each(readdir_sequence(".", readdir_sequence::files), ::remove);
下一代C++标准(C++09)会解决这个问题(将区间这个抽象定义为一个整体)。
[5] 当然,语言并没有规定模板实例化的底层实现一定要是对每组参数类型组合实例化一个版本出来。但目前的实现,这种方案是最高效的。完全消除了抽象惩罚。另一方面,One size fit all的方案也不是不可行,但总会有间接调用。这也正说明了静态类型系统的一个典型优点:帮助编译器生成更高效的代码。
[6] http://
en.wikipedia.org/wiki/Don't_repeat_yourself
[7] http://
en.wikipedia.org/wiki/Duck_typing
[8] 摘自Bjarne Stroustrup的paper:Evolving a language in and for the real world: C++ 1991-2006
[9] 也正因此,D语言加入了语言直接对模板元编程的支持,比如真正工作在编译期的static if-else语句。
[10] http://www.boost-consulting.com/mplbook/
[11] 第三章的翻译见我的blog:《深度探索元函数》http://blog.csdn.net/pongba/archive/2004/09/01/90642.aspx
[12] 由于篇幅原因,这里无法展开详述Concept对C++泛型编程的其它重要意义,有兴趣的可以参见我的一篇blog:《C++0x漫谈系列之:Concept! Concept!》。http://blog.csdn.net/pongba/archive/2007/08/04/1726031.aspx