关于C++泛型编程的一些杂感
刘未鹏(pongba)
C++的罗浮宫(http://blog.csdn.net/pongba)
一些关于GP的思考或总结,没有太多的技术细节,主要是一些思想上的阐释。另外,文字比较乱,没有细细整理,凑合吧;-)
关于GP,可以说我是对它有很复杂的感情的,其实GP这种东西最好是建立在无类型语言上面,就C++0X目前对GP的支持的趋势来看,确实如此,auto/varadic templates这些特性的加入象征着C++ GP的形式正越来越转向一种更纯粹的泛性语法描述,表面上你几乎不会看到任何类型的痕迹,只有语法以及语法背后蕴涵的语义,然而在C++里面有一个“最大的国情”,即支持所有这些的是一个坚实的大地——强类型系统。所有的泛化所有的模板代码一旦实例化之后就落实到某一集特定的类型身上然后接受强类型系统的考验;-)有点像波函数的塌缩——本来是具有无数可能的,一旦有了一个观测者立即就塌缩成一个实体。在GP中,观测者就是使用者,或者说使用者给出的一集模板实参;-)
话说回来,虽说GP最好是建立在无类型语言,像LISP/Scheme这种语言上面,但它在C++里面却又确确实实的获得了极大的成功,这也正符合BS在D&E里面的思想——现实总是需要折衷的,正应了中国的一句古话“识时务者为俊杰”。像LISP这样“纯粹”的语言到了现实应用当中往往是“应用范围狭窄”的同义词(不过用在教学和研究方面还是挺有意思的,虽然现在的主流FPL社区正在致力于将FPL应用到工业界去,但肯定还需要一段时间的;-))。BS说C++从来都不是一门为漂亮而设计的语言,C++的语言特性都是从实际出发,实实在在的加进去的。另一个有趣的观察是,非主流的语言特性在主流语言当中往往能够得到很好的发挥,C++STL将FPL风格初步运用到算法当中,算是获得了比较好的效果,至于一些更为纯粹的C++ FPL如boost::lambda,boost::spirit::phoenix,boost::bind,boost::lambda::ll,fcpp等的运用则还处于摸索阶段。C++里面一个成功且必要的FPL风格运用是boost::mpl库里面的,由于C++ Metaprogramming并不支持side-effect(副作用),换句话说,在C++Metaprogramming当中,一切数据都是immutable的,所以像我们通常所见的for循环结构就不复存在了,转而成为递归结构。后者是FPL的招牌式结构;-)
C++GP的一个招人唇舌的地方儿就是它的语法,由于C++本质上是一门强类型语言,而且并没有内置的partial evaluation、currying以及high order functional programming的支持,另外C++里面的函数也并非first class的对象。这些都使得我们在编写C++ FPL的库或通常的代码的时候感到处处掣肘,虽然利用一些“神奇”的技巧在C++里面是可以overcome这些缺陷的,但是语法,还是语法,有时候语法有点让人不可接受,当然,像我这样的热爱者会鼓吹说“其实它的语法也不是那么差…;-)”。但毕竟跟LISP、haskell这样的原生FPL比起来,C++ FPL的语法还是显得有点生硬了,纯粹的FPL能够关注于表达代码的逻辑,理想情况下你看不到“类型”这回事。所以有人说haskell的表达就像数学一样简洁优美来着;-)但在C++当中你不得不受制于类型系统的束缚,有点像“枷锁上的舞蹈”,呵呵;-)
不过C++GP当中有一点奇妙的是,虽然我们熟知的runtime programming当中你并没有内建的对partial evaluation的支持,但在Metaprogramming里面却优雅的存在着,例如一个元函数plus,你可以写plus<_1,100>,这就是一个partial evaluation,David在他的《C++ Template Metaprogramming》里面把这个称为”partial function application(部分函数应用)”。但是在runtime的场景下这是不可能的,譬如一个runtime函数plus,你可以写plus(_1,100)吗?显然不可以。不过等一下,这种说法不够精确,如果plus是一个“lambda aware”的functor的话,这还是可行的,实际上已经有了这方面的完善的工作,语法是plus[_1,100],怪异吧,呵呵。但话说到底这只不过是二类公民而已,需要自己做大量工作,C++内建的函数并没有这个能力,例如对于:
int plus(int i,int j);
你根本不可能使用plus(_1,2)。当然你可以重载出一个lambda aware的plus版本使这成为可行的,但每次都要做这种重复劳动太浪费了;-)作为比较,为什么Metaprogramming具有这种能力呢?主要是因为以C++类模板为依托的C++元函数具有一个良好的FPL特性,即lazy evaluation(惰性求值),这种能力是C++内建的函数所没有的,对于一个内建函数如plus来说,你写plus(…)就等于是在写它的返回值,也就是说,evaluation会立即进行。但对于一个元函数plus<>来说,你写plus<…>,求值并不立即进行,而是要等到你为它加上::value的时候,即plus<…>::value,这才算完成了求值过程。换句话说,我们通常见到的函数,其求值过程是跟传参过程绑在一起完成的,求值就是传参,传参就是求值。但元函数则不同,你可以先传它一组参数,却可以在任意时刻去取它的返回值;-)
这就致使了C++ Metaprogramming的FPL能力从本质上是完备的和内建的;-)尽管语法仍然还是有点“那什么”;)。
上边废话扯了一堆,下面是写毕业论文的时候的一些东西,比较基本(如果你愿意,也可以称为本质^_^),因为怕老师看不懂(@_@),老鸟就不必往下看了哈;-)
从语言层面上来说,现代的编程语言为复用提供了三种主要的基本途径。结构化、面向对象(OO)以及泛型(GP)。
结构化程序设计当中,提供复用性的语言特性主要是函数,在软件工程当中,函数可以被当成黑箱,实现一个或一组相关的功能(functionality),而用户不用关心函数内部的具体实现,只要负责将参数送入,然后接受返回值就可以了,C库函数就是极好的例子。
但是结构化程序设计有它本质上的缺点,这个缺点主要体现在代码的阻止上面,进而影响了可维护性。结构化程序设计的一个主导思想就是著名的“程序=操作+数据”,这里操作其实就意味着函数。虽然该论断一言道破了软件开发或程序设计的本质,但真正落实到实际开发当中,在成本控制方面,开发者还需要更强大的手段。譬如,结构化程序设计的一个严重问题就是,与一组数据相关的一组操作不能很好的被封装到一块去,例如,在C语言里面,我们要表达一个动物以及该动物的行为,我们只能采用一个接口,外加一组函数来表示。这种松散的组织方式就造成了理解和维护上的困难。而且,由于没有类的机制,函数的名字只能通过加上其对应类型的名字作为前缀来避免名字冲突。这不但增加了出错的机会,从某种程度上也增加了系统的混乱。所以说结构化程序虽然提供了过程/函数级别的复用,但是这种复用能力在当今软件开发当中是远远不够的。而且由于数据跟操作之间松散的组织方式,所以结构化程序并不是很适合大型而复杂的应用开发。之所以以前的一些操作系统,如UNIX/LINUX系列全是以C来编写,个人觉得,主要跟一些历史遗留因素有关,另一个因素是当时C++尚未发展得像今天这般成熟。至于效率方面,C++标准委员会提交的一则技术报告[TR]很直观的表示出,C++中的类机制跟用C来实现类似的封装能力不但效率不打折扣,甚至有过之而无不及。另外,一些大型的效率相关的应用使用C++来实现也正实现了这一点。譬如.NET整个的基层架构全是C++编写。而且开发大型的3D游戏,C++几乎是唯一的选择。可见在效率方面,并非像许多人一贯以为的那样,
而OO则提供了一种更为高层的抽象手段和复用机会,一个被良好OO化的系统中的大部分构造(construct)都应该是对象,对象与对象之间原则上通过消息来沟通,但大多数现代语言基于效率的考虑仍然是通过对象成员方法的调用来模拟消息的发送,这虽然带来了一定的耦合程度,但提高了效率,是一种合理的折衷(tradeoff)。此外,一个良好地抽象化的OO系统中的接口应该是相对稳定的,所以耦合于接口的对象之间仍然能够保持绝大部分的独立性和自由度。OO复用的成功的例子非常之多,著名的如微软的COM/DCOM、OMG的CORBA。其主要思想在于从对象层次上来封装一集相关的操作(或数据),对象向外部提供一组接口,每个接口提供一组相关的功能,比起原始的函数封装来说,OO中的对象不单具有概念上的清晰性,同时其功能性方面的内聚性,相关性也为复用提供了更直观友好的表达方式。而像COM和CORBA这种大型的OO框架则更能提供位置无关的代码复用,乃至于抽象到了面向服务(Service Oriented)的层次,为更为强大的复用提供了契机。
面向对象(OO)程序设计的主要特点
紧绑定
然而,传统的OO实现有一个很大的弱点,即它是紧绑定/有限(bounded)的。举个例子,橡树(Oak)和苹果树(AppleTree)都是树(Tree)的一种,现在有一个树的集合(Set),需要对该集合排序,排序准则是基于树的高度,很显然,一个树要想能够加入这个有序集的话,就必须继承自Tree类,这就是一种紧绑定,一旦Tree基类有了改动,所有依赖于它的树都必须重新编译或改动,当然,一个设计良好的抽象基类是不应该常常改动的,但无论如何本质上的绑定是肯定存在的。而且,这个对该集合排序的算法只能被应用到树身上,因为它也是依赖于Tree抽象基类的。从另一个角度来说,只有树才能够被该算法排序。很显然的,人也具有高度,如果我们想要对一个Person Set进行同样逻辑的排序,我们就得重写该算法,这就意味着重复劳动,不但要付出编程心力,还可能隐藏着错误。当然,一个聪明的设计可能会对这种情况进行进一步的抽象,提取出一个所谓Comparable接口,所有能够比较的东西都继承自该接口。但这同样是一条荆棘遍布的道路,不但依赖的问题仍然没有消除(仍然依赖于Comparable,乃至于Comparable里面的方法签名),而且还可能出现类型混乱,譬如一个人(Person)具有Comparable接口,而一头大象(Elephant)也具有Comparable接口,那么对这个排序算法来说,它们就是可Compare的,这在现实当中可能是没有任何意义的,很可能会导致运行期异常的抛出。这会带来运行期的高昂代价。最关键的还是,这种做法强制每个Comparable的类型都必须实现Comparable接口,才能够利用该排序算法。后面我们将会看到,泛型编程完全解决了这个问题。不过,OO的紧绑定也为它带来了一个强大的优势,即二进制可复用性。二进制可复用性是一种强大的能力,一个最简单的例子就是C的库函数,它们的实现全都是放在二进制库当中的,用户唯一可见到的就是函数的头文件当中的声明。本质上,只要规定用户遵从某个二进制约定,就可以实现二进制复用,而类的继承,即OO的实现机制,在大部分现代语言当中,本质上就属于一种二进制约定。派生类的虚函数表跟基类的虚函数表必须布局一致,这样一来从二进制层面,派生类就能够被当作基类来使用了。当然,并非一定要牺牲松散耦合性才能够获取二进制复用性,换句话说,并非一定得使用类继承才能获得二进制复用性。目前之所以需要这么做,是因为绝大部分的语言都是将类继承机制建立在虚函数表之上,即二进制层面之上的。
效率
但是,OO在效率方面却显示出了先天性的不足,前面已经详细解释过,这种先天性不足是由于OO乃是建立在类继承体系之上的一种思想(至少目前的主流OO实现莫不如是),而且在主流OO实现当中,出于效率上的考虑,对象之间的消息传递都是基于方法的调用,进一步增加了耦合程度。这就使得基于OO的泛性构件只能够被应用到有限的一集对象上。而且,由于OO的基于继承的本质,实现泛性构件必然要用到动态转换,造成对于某些应用(如嵌入式系统,软实时系统乃是硬实时系统)可能无法承受的负担。这就是有名的abstraction penalty,意即抽象需要付出的代价。从另一个方面来说,也是从更本质的方面来说,这是由于没有将编译期的类型信息足够的利用起来。譬如说,JAVA(在没有引入JG(