`

关于C++泛型编程的一些杂感

 
阅读更多

关于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++里面却又确确实实的获得了极大的成功,这也正符合BSD&E里面的思想——现实总是需要折衷的,正应了中国的一句古话识时务者为俊杰。像LISP这样纯粹的语言到了现实应用当中往往是应用范围狭窄的同义词(不过用在教学和研究方面还是挺有意思的,虽然现在的主流FPL社区正在致力于将FPL应用到工业界去,但肯定还需要一段时间的;-))。BSC++从来都不是一门为漂亮而设计的语言,C++的语言特性都是从实际出发,实实在在的加进去的。另一个有趣的观察是,非主流的语言特性在主流语言当中往往能够得到很好的发挥,C++STLFPL风格初步运用到算法当中,算是获得了比较好的效果,至于一些更为纯粹的C++ FPLboost::lambdaboost::spirit::phoenixboost::bindboost::lambda::llfcpp等的运用则还处于摸索阶段。C++里面一个成功且必要的FPL风格运用是boost::mpl库里面的,由于C++ Metaprogramming并不支持side-effect(副作用),换句话说,在C++Metaprogramming当中,一切数据都是immutable的,所以像我们通常所见的for循环结构就不复存在了,转而成为递归结构。后者是FPL的招牌式结构;-)

C++GP的一个招人唇舌的地方儿就是它的语法,由于C++本质上是一门强类型语言,而且并没有内置的partial evaluationcurrying以及high order functional programming的支持,另外C++里面的函数也并非first class的对象。这些都使得我们在编写C++ FPL的库或通常的代码的时候感到处处掣肘,虽然利用一些神奇的技巧在C++里面是可以overcome这些缺陷的,但是语法,还是语法,有时候语法有点让人不可接受,当然,像我这样的热爱者会鼓吹说其实它的语法也不是那么差…;-)”。但毕竟跟LISPhaskell这样的原生FPL比起来,C++ FPL的语法还是显得有点生硬了,纯粹的FPL能够关注于表达代码的逻辑,理想情况下你看不到类型这回事。所以有人说haskell的表达就像数学一样简洁优美来着;-)但在C++当中你不得不受制于类型系统的束缚,有点像枷锁上的舞蹈,呵呵;-)

不过C++GP当中有一点奇妙的是,虽然我们熟知的runtime programming当中你并没有内建的对partial evaluation的支持,但在Metaprogramming里面却优雅的存在着,例如一个元函数plus,你可以写plus<_1,100>,这就是一个partial evaluationDavid在他的《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 awareplus版本使这成为可行的,但每次都要做这种重复劳动太浪费了;-)作为比较,为什么Metaprogramming具有这种能力呢?主要是因为以C++类模板为依托的C++元函数具有一个良好的FPL特性,即lazy evaluation(惰性求值),这种能力是C++内建的函数所没有的,对于一个内建函数如plus来说,你写plus()就等于是在写它的返回值,也就是说,evaluation会立即进行。但对于一个元函数plus<>来说,你写plus<>,求值并不立即进行,而是要等到你为它加上::value的时候,即plus<>::value,这才算完成了求值过程。换句话说,我们通常见到的函数,其求值过程是跟传参过程绑在一起完成的,求值就是传参,传参就是求值。但元函数则不同,你可以先传它一组参数,却可以在任意时刻去取它的返回值;-)

这就致使了C++ MetaprogrammingFPL能力从本质上是完备的和内建的;-)尽管语法仍然还是有点“那什么”;)

上边废话扯了一堆,下面是写毕业论文的时候的一些东西,比较基本(如果你愿意,也可以称为本质^_^),因为怕老师看不懂(@_@),老鸟就不必往下看了哈;-)

从语言层面上来说,现代的编程语言为复用提供了三种主要的基本途径。结构化、面向对象(OO)以及泛型(GP)。

结构化程序设计

结构化程序设计当中,提供复用性的语言特性主要是函数,在软件工程当中,函数可以被当成黑箱,实现一个或一组相关的功能(functionality),而用户不用关心函数内部的具体实现,只要负责将参数送入,然后接受返回值就可以了,C库函数就是极好的例子。

但是结构化程序设计有它本质上的缺点,这个缺点主要体现在代码的阻止上面,进而影响了可维护性。结构化程序设计的一个主导思想就是著名的“程序=操作+数据”,这里操作其实就意味着函数。虽然该论断一言道破了软件开发或程序设计的本质,但真正落实到实际开发当中,在成本控制方面,开发者还需要更强大的手段。譬如,结构化程序设计的一个严重问题就是,与一组数据相关的一组操作不能很好的被封装到一块去,例如,在C语言里面,我们要表达一个动物以及该动物的行为,我们只能采用一个接口,外加一组函数来表示。这种松散的组织方式就造成了理解和维护上的困难。而且,由于没有类的机制,函数的名字只能通过加上其对应类型的名字作为前缀来避免名字冲突。这不但增加了出错的机会,从某种程度上也增加了系统的混乱。所以说结构化程序虽然提供了过程/函数级别的复用,但是这种复用能力在当今软件开发当中是远远不够的。而且由于数据跟操作之间松散的组织方式,所以结构化程序并不是很适合大型而复杂的应用开发。之所以以前的一些操作系统,如UNIX/LINUX系列全是以C来编写,个人觉得,主要跟一些历史遗留因素有关,另一个因素是当时C++尚未发展得像今天这般成熟。至于效率方面,C++标准委员会提交的一则技术报告[TR]很直观的表示出,C++中的类机制跟用C来实现类似的封装能力不但效率不打折扣,甚至有过之而无不及。另外,一些大型的效率相关的应用使用C++来实现也正实现了这一点。譬如.NET整个的基层架构全是C++编写。而且开发大型的3D游戏,C++几乎是唯一的选择。可见在效率方面,并非像许多人一贯以为的那样,

面向对象的程序设计(OO

OO则提供了一种更为高层的抽象手段和复用机会,一个被良好OO化的系统中的大部分构造(construct)都应该是对象,对象与对象之间原则上通过消息来沟通,但大多数现代语言基于效率的考虑仍然是通过对象成员方法的调用来模拟消息的发送,这虽然带来了一定的耦合程度,但提高了效率,是一种合理的折衷(tradeoff)。此外,一个良好地抽象化的OO系统中的接口应该是相对稳定的,所以耦合于接口的对象之间仍然能够保持绝大部分的独立性和自由度。OO复用的成功的例子非常之多,著名的如微软的COM/DCOMOMGCORBA。其主要思想在于从对象层次上来封装一集相关的操作(或数据),对象向外部提供一组接口,每个接口提供一组相关的功能,比起原始的函数封装来说,OO中的对象不单具有概念上的清晰性,同时其功能性方面的内聚性,相关性也为复用提供了更直观友好的表达方式。而像COMCORBA这种大型的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(在没有引入JGJava Generic[JG])之前,所有的容器都是基于继承的,容器中只保存Object引用,然而对于用户来说,代码中将某个容器使用在保存,譬如说int的时候,往后就肯定只能(且只应该)再往里面添加int,而不能是其它东西(之多是跟int兼容的对象,如char等)。但是对于Java来说,用户完全可以往里面塞入一头大象!换句话说,在这个方面,Java的语言并没有为用户提供一个强类型检查的设施。这个设施应该能够确保代码结构的前后一致性,某种程度上,这就是指类型。换一种更底层的描述就是,当用户决定将一个List用于保存对象X的时候,编译器/语言就应该能够以某种形式来将X这个类型信息保存起来,在后续的代码当中,如果用户还想往这个List里面添加东西的时候,编译器/语言应该能够将其类型跟X对比,如果不兼容,那么就拒绝接受这样的做法。然后在Java引入JG之前,一旦某个东西被扔进了容器,其静态类型信息就会丧失殆尽,尽管其完整的类型信息仍然依附于对象上,可供运行期类型识别(RTTI)来操纵,但那是在运行期,彼时一旦出现错误只能亡羊补牢,不但损失效率,还丧失了及早提醒开发人员的良机。

源码级复用的能力

二进制复用的问题:OO是基于二进制契约来进行复用的。这就限制了它的复用程度,譬如,一个排序算法如果要被复用的话,被排序的对象就必须实现诸如IComparable这样的接口。也就是说,必须遵从排序算法订下的二进制契约。这意味着一种强制性,如果要复用该库的算法或其他构件的话,必须遵循它规定的一些接口规则,这就将我们的构件与该算法耦合起来了,现在如果有其他算法有一个更好的实现,但需要我们实现另一个语义相同但名字不同的接口,譬如ICmpIComparable的缩写)的话,我们除了改动我们的现存类之外就没法让我们的类适应于新的算法。而从算法的角度来说,一旦某个算法依赖上了某个接口,那么该算法就只能被应用到实现了该接口的对象上了。很可能其他对象也实现了相应的语义,只不过是用其他接口来表达或者根本没用接口来表达,譬如对于IComparable来说,人们可能仅用一个equal方法就表达了。虽然后者同样具有“Comparable”的语义,但却无法使用一个依赖与IComparable接口的算法来对它进行排序。某种意义上来说,这就限制了语义的表达。一个不完全的解决方式:当然,这个问题有一个解决方案,就是软件工程当中经典的解耦合手段——当AB之间存在循环依赖时,应该进行接口分解,并让AB都依赖于一个更高层的接口。这里我们可以套用这种办法,将IComparable接口提升到一个更高的层次,两个算法都必须依赖于该接口,这样一来就形成了一个公共契约,大家都必须遵从,一定程度上避免了某些构件的自行其道。但是,这个解决方案仍然存在一个更严重的问题,即所有与IComparable相关的构件依然全部都依赖与该接口。耦合仍然存在,关键是,人们在表达该接口所具有的语义时仍然需要实现该接口,而且所有与该接口相关的算法要想让它自身能够被广泛复用的话,就必须遵从这个接口,而不能自行其道的定义一个类似ICmp的接口。这其实就是说,在OO里面,你如果想要实现某个接口所具有的语义、而且想让你的类能够得到最大程度的复用的话,你就必须将你的类耦合到某个二进制契约上。GP与源代码复用:最好的解决方案是这个“更高的层次”是语言,即大家都遵从语言内建的接口,譬如,所有语言当中都有四则运算以及比较(< >)操作,operator < 就是语言内建的接口,让所有需要“比较”语义的构件都遵从语言这个内建的操作是最为理想的方式。大家不会弄错接口的名字,不会忘记实现接口,也不会导致互相之间的不兼容。STL的算法正是利用了这一点,所以得到了极高的复用性。而总的来说,GP提供的源码级复用则是使用编译时间来换取了松散耦合性。

泛型编程(GP

C++还未标准化的时候,泛型编程的先驱Al StevensSTL之父)当时就发现有些算法并不依赖于数据结构的具体实现,而只是依赖该结构的几个最基本的属性。学过离散数学的人都知道,一个集合如果要能够被排序的话,其上必须定义了“序”的概念所谓偏序集就是定义了偏序关系的集合。在C++里面,偏序关系就被映射成为了operator <,只要某个集合或区间内的元素支持operator <,即存在偏序关系[1],那么这个集合就是可排序的,而不用去管其中保存的是猴子还是大象。这也就是说,我们的算法应该只依赖于这个最基本的属性——偏序——就可以了。当然,OO也可以做到这一点,但效率令人无法容忍。GP解决了这个问题,使用GP来抽象偏序关系,可以完全消除abstraction penalty,效率跟手动写出一个专用(specialized)的算法一样高效。这完全要归功于GP的理念。

GPOO的比较

OO好得多的通用性OO的通用性是建立在类继承体系之上的,这导致了算法只能够被用在有限的类型上面。而GP的算法是建立在结构一致性Structural Conformance)上面,简单的说,结构一致性是指语法结构上的一致性,GP假定某个特定的语法跟其语义能够对应起来。譬如说,std::sort()算法的一个版本只要求其操作数支持operator <,基本上这就是说,只要对于其操作数来说“a<b”能够通过编译,该算法就能成功。这不像OO,利用OO完成的通用算法要求其操作数继承自某个特定的基类,如LessThanComparable。所以说,GP算法能够运用到的类型集是无限的/非绑定的(unbounded)。

OO好得多的效率:同样,OO由于以类继承为主流实现机制,而类继承层次之间的转换免不了会设计RTTI,这是没有能够充分利用编译期静态类型信息的结果。GP把这个能力引入了语言,一个std::list<int>里面保存的一定是int,而一个std::list<double>里面保存的则一定是double,换句话说,静态类型信息被完整的保存在了编译期。这就带来了两个好处,其一就是效率,由于知道对象确切的类型,所以编译器进行代码生成的时候就不用运用RTTI,这使得GP算法的效率跟手动编码一样高。一个sort<int>()算法跟你自己手写一个专门针对int数组的sort()算法相比,效率不相上下,甚至前者更好一些。

OO强得多的类型检查:静态类型信息得以保存的另一个好处就是可以在编译期发觉更多潜在的错误。一个被广泛认可的观点是强类型有助于程序的早期纠错。因为良好的代码当中的类型应该是一致的,例如,假设你为长腿的东西定义“跑”这个行为,而实际上你要是把一个茶壶也拿来“跑”一下那肯定就是荒唐的。静态类型检查会为你查出这个错误。当然,像JAVA这种语言当中也是能够进行一定程度的静态类型检查的,但是其中类继承抽象的广泛运用无疑会极大程度上削弱这个机会。

GP的一个主要的弱点就是它不是二进制可复用的,这是因为支撑GP的抽象机制是语法一致性,而语法是代码层面的东西,语法上的约定无法体现在二进制层面(至少目前不能)。相较之下主流OO实现中的约定天生就是二进制层面的。GP的这个弱点某种程度上阻碍了它成为一个首选的商业特性,因为以GP实现的库,其源代码基本上是必须公开的。而传统的C库全是以二进制形式发布的。不过GP由于其本质上的强大能力,在代码复用性,组织性,可维护性以及效率等方面表现出来的优势,很快就跨入了工业界,虽然等到其成熟运用还需一段时间,但从各门现代语言(以JAVAC#为代表)纷纷加入对它的支持来看,这是大势所趋!

meta-programming元编程)

1994Erwin UnruhC++标准委员会上递呈了一个小程序,这个小程序后来被人们认为时C++模板元编程最早期的雏形。该程序能够在编译期打印出一列素数,它们呈现在编译错误信息当中。Todd Veldhuizen进而提出了模板元编程的概念。它把元程序(metaprogram)跟程序(program)区分了开来。元程序是一个操纵其他程序的程序,例如编译器、部分求值器、解析器生成器等等。Erwin Unruh的素数计算模板展示了一种可能:你可以使用C++模板系统来写出编译期的程序。由此Todd Veldhuizen得到启发,可以使用C++模板来进行所谓的元编程,例如,通过在编译期将各个不同的代码片断编织在一起从而构成一个具有针对性的算法。

元编程向来被认为是C++模板技术当中的最为高深的东西,但其实元编程跟普通的编程逻辑也是同样的,应该说,只要是编程,不管是哪种编程,不管是哪种语言,最重要的是逻辑。但虽说如此,软件开发的重要方面仍然是不光包括逻辑的。成本也是另一个相当不容忽视的方面。元编程主要能够带来两点好处:

u 早期(编译期)的类型检查:C++的理念之一就是强类型,所谓强类型,从根本上其实是一种帮助及早发现错误的手段。强类型语言的设计者很早就发现,许多程序的错误都会体现在类型的不一致上面。譬如说,一个容器,如果人们想让它装A类对象,但后来又把B类对象装了进去,那么这肯定是不对的,除非A是B的基类且该容器中存放的是引用(这里指泛意的引用,并非C++的引用,在C++中引用是不能放在容器当中的[注])。C++模板元编程则把这种编译期差错的理念发挥到了及至,从思想上来讲,C++元编程就是鼓励尽早的把能够在编译期作出的决策放在编译期,并且尽量在编译期检查能够检查的任何正确性。

u 提升编译期决策在整个决策过程当中的地位。在很多的构件当中,有相当一部分程度上的决策应该是放在编译期的,而且应该在编译期给出来的。譬如一个回调函数类boost::function,它所接受的函数的签名(原型)应该在使用它的时候就给出,这个决策很明显应该在编译期,所以我们用一个模板参数传递给它:boost::function<int(double)> call_back;这就创建了一个能够接受所有类型为int(double)[注]的函数/仿函数的回调器。当然,这只是最浅层次的模板应用,元编程的强大力量在于对类型的操纵能力,譬如当你将某个仿函数对象注册到call_back回调器的时候,boost::function构件会在它的内部对你的仿函数的类型进行精确的计算并确认出它是属于哪一种类型的实例。再一个例子就是boost::variant,这个类实现union的强类型的版本,其中的元编程使用也达到了一定的程度。譬如,我们想创建一个 int/double/X/Y四种类型的Union,我们这样做boost::variant<int,double,X,Y> var;这时候你如果把一个不是这四种类型之一的类型的对象赋给var的话,你就会得到一个编译期错误,阻止了未定义行为蔓延到运行期,这种神奇的技巧的实现正得益于元编程的使用。

u 效率。由于很多信息都放在了编译期,进行计算和抉择,所以元编程能够显著的提高构件的效率,与不使用元编程的构件相比,前者在编译期做掉了许多工作,譬如类型转换,函数分派等。而后者则需要在运行期占用时间,甚至是显著的时间,譬如在Java里面,使用一个List来存放对象,完了取出对象的时候必须从Object引用转变为对象的实际类型的引用,这里涉及到比较高的运行期开销,虽然看上去只是一个c强制转换,但背后隐藏着类型的比较,而这种比较却完全可以放在编译期,根本不用消耗运行期宝贵的时间。当类层次结构比较复杂的时候,或者涉及到类层次体系里面的横向转型(cross cast)的话,需要消耗的运行期时间更多。此外,在其他方面,元编程也提供了许多的编译期机遇,譬如编译期函数分派,或称tag dispatching其实就是这样一个例子。总的来说,元编程的理念就是能在编译期做的事情就尽量在编译期做掉,这是像从JAVA——python——Lisp/Scheme(函数式编程语言)这样的语言所不能做到的,因为它的语言里面缺乏相应的机制。C++某种程度上一切都是以效率为中心的,所以才有了模板和元编程。

2.9.1 强类型编程与弱类型编程的比较

举个最简单的例子,如果你要使用一个容器的话,你肯定首先必须决定要用它来存放哪类对象,并且在你后续的使用当中自然必须遵从这个决策。这样的容器一般叫做同质(homogeneous)容器,而另一类就是异质(heterogeneous)容器,异质容器一般在你不能确定某个容器在将来会存放什么样的东西时才会出现,譬如你只知道这个容器以后会用来存放某些东西,至于这些东西的具体类型以后才会知道,这种情况在一个动态系统当中是经常会遇到的情况。这种情况下,如果是JAVA语言就非常容易,只需直接用List这样的容器就行了,因为它们天生就是存放Object引用的。但JAVA在这个方面的类型强度还不够,不管是什么东西都可以且必须转换为Object引用放在容器当中,从而一个同质容器在外界看来其(编译期)约束跟异质容器没什么差别,即用户可以在一个语义上为同质的容器当中存放五花八门的类型的对象。

而在C++当中,实现异质容器需要更为复杂的技巧,因为总所周知C++的RTTI能力非常薄弱,基本上就是在动态类型身上挂了一个表示类型的字符串而已,这个字符串被包在一个typeinfo结构里面,后者却还是不可移植的,不同平台上完全可以而且实际上也确实对其进行不同的表示。所以这样一来C++中要实现异质容器的话恐怕就涉及到一个根本的问题,元数据的存在与否。在小尺度上,这可以通过规定一组类型的描述以便取代不可移植的typeinfo结构来实现,但这种方式是有限制的,只适用于一个库,因为一个库里面的类型一般是有限的一个集合,所以可以为它们规定一组描述方式,但用户自己可能创建的类型却是无限的,库根本没法规定让用户也按照它们的思路去描述一个类型。这时就必须利用编译器的权利了,只不过目前C++标准还没有将typeinfo的二进制格局标准化,恐怕这要等到C++中出现ABI的时候。

不过,boost库当中有一个设施:boost::any,它是创建异质容器的绝佳选择,但很遗憾,由于上面描述的typeinfo以及C++ ABI方面的缘故,它并不是可移植的。但如果是在同一个平台之上或者只考虑源代码可移植的话,其能力还是相当强大的。譬如你可以创建这样一个容器:

std::vector<boost::any> va;

X x;

Y y;

va.push_back(x);

va.push_back(y);

这就导致了x,y这两个不同类型的元素可以被放入同一个容器当中。但后面取出元素的过程可就不那么直接了,对于这个例子而言,我们知道va.front()返回的应当是一个X类型的元素,不过由于C++的强类型特性,它实际的返回类型是boost::any,我们可以这样来恢复它原本的类型:

// just retrieve the value since this will lead to 'return by value'

boost::any_cast<X>(va.front());

// on the other hand, if you wanna modify the element in place

X* px = boost::any_cast<X>(&va.front());

*px = ...;

能够这样做的前提在于必须知道元素确切的类型。而且知道这些类型跟它们的对象之间的对应。否则转换就面临出错的危险。这里我们可以通过把类型跟它的转换函数放在一起的方式来将元数据加到容器中的数据身上。也可以通过一些其他的技巧,具体看面临的任务而定。譬如,boost::signal在存放各种具有相同的签名的函数或仿函数的时候就是采取的这种策略,它将用户给定的函数或仿函数(普通函数跟仿函数的类型是不同的,函数可能具有兼容的签名(例如int f(int)跟int g(double)就都是兼容于double (*)(int)的))从而类型各不相同,而仿函数则每个都不相同,因为它们是不同的类)包装成同一类型的boost::function<...>对象,然后再将它存入std::map<boost::any>容器当中,当需要调用到它们的时候再将它们取出来,转型为boost::function<...>,然后进行调用,由于boost::function实际存储了它接受的每个(仿)函数的真正类型,所以把真正存放在底部的那些函数/仿函数(先前由用户注册进去的)恢复原来类型就是boost::function的任务了。这种数据+转型操作实例的捆绑是C++弥补其RTTI能力弱势的一个惯用手段。

这样以来,强弱类型的优劣就很明显了,典型的强类型语言如C++能充分利用强类型的编译期纠错能力,同时也能够一定程度上的实现弱类型语言的一些动态能力,虽然复杂一点,但并非本质上的缺陷。而弱类型的语言就不能反过来利用编译期信息了,这是一种很可惜的浪费。后面我们会看到,元编程不光是具有在编译期发觉错误的能力,还能够强迫用户把应该在编译期进行的决策提前到编译期,从而从另一个方面更有利于了编译期的纠错。应该说两者是密不可分的。“后者是前者的前提和促进,前者是后者的保障”;-)。


[1] 严格的说,这被称为严格偏序(Strict Partial Ordering)或严格弱序(Strict Weak Ordering)。所谓严格偏/弱序即是指
!aRb && !bRa => a 等价于 b

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics