《编程机制探析》第十三章 动态类型
程序员文章站
2022-05-17 13:36:48
...
《编程机制探析》第十三章 动态类型
在前面的章节中,我们已经几次遇到过Type Dispatch(类型分派)的场景了。在这种场景中,我们需要根据数据类型选择不同的行为。比如,我们来看下面这段典型Double Dispatch的Visitor Pattern的代码。
void traversal(Visitor visitor){
// traverse each element
…
currentElement = 当前元素
if (currentElement的类型是A)
visitor.visitA(currentElement)
else if (currentElement的类型是B)
visitor.visitB(currentElement)
…
…
}
我们看到,在上述的遍历算法中,我们需要判断当前元素的类型来决定下一步执行的代码。这就程序在运行时动态获取当前数据的类型信息。
可不要小看动态获取类型信息这个需求,并不是所有的语言都能够很容易地做到这一点。至少在C++语言中,做到这一点就极其困难。
对应这个问题,C++语言中专门有一个术语,叫做RTTI(Run Time Type Information,运行时类型信息)。如果要在C++语言中实现RTTI的需求,则必须用一套RTTI相关的宏定义,在编译期间就为某个类型(class)产生额外的类型信息(通常是字符串类型),以便程序在运行时能够获取。在有些C++ Template技术中,还支持typeof关键字,同样是用来在编译期间产生类型信息。
Java语言实现运行时类型信息获取的需求,就容易了许多。Java语言中有一个关键字instanceof,可以判断某个数据是否某种类型。另外,所有的Java对象都支持getClass()方法,这同样可以用来在运行时获取类型信息。
不仅如此,Java对象还支持Reflection(反射)特性。应用程序可以利用Reflection编程接口在运行时查看到某个Java对象内部的所有公开的成员变量和成员方法。
在某些语言中,比如,Python中,Reflection(反射)特性也叫做Introspection(内省)。从词义的角度来说,我觉得,Introspection这个词更加贴切一些。不过,由于Java比较流行,Reflection这个词也用得更多一些,甚至连Python也引入了这个词。我们也就从众,用Reflection这个词。
应用程序不仅可以利用Reflection编程接口获取对象的内部类型定义信息(如属性列表、方法列表等),还可以利用Reflection编程接口直接根据用一个字符串表述的方法名,直接调用对象中的对应方法。
这个功能在某些场景中特别有用处。比如,应用程序可以解析一段文本文件,获取其中的某个字段,并根据该字段作为方法名,调用某个对象中的对应方法。
有一个叫做OGNL的Java开源项目就利用这一点,实现一套强大的对象属性访问语言。OGNL 是 Object-Graph Navigation Language 的缩写,意思就是对象图导航语言,可以把这样的字符串“a.b.c.d”翻译成对应的Java代码调用——a.getB().getC().getD()。当然,OGNL是利用Reflection编程接口,一步步实现这种级联调用的。
C#语言提供了与Java语言类似的Reflection机制。这两门语言有很多近似的地方,也是竞争最直接的两门语言。两个阵营的程序员经常相互攻击对方语言的缺陷,彰显己方语言的优越性。
既然讲到了Reflection,我们就顺便为前面章节中“Java与C#泛型比较”的小话题做个总结陈词。
C#的泛型技术叫做“Reification”(类型具体化)。这种泛型技术很彻底,直接影响到编译后的虚拟机指令。运行时,对象实例内部仍然保持着泛型信息。程序员可以用Reflection编程接口获取泛型定义的信息。
Java的泛型技术叫做“Type Erasure”(类型擦除),与C++ Template是同源的,都是在编译器做文章。编译之后,“参数化类型”就被擦除掉了。运行时,Java对象实例内部没有泛型(类型参数)的信息,也不能被Reflection编程接口获取。
需要注意的一点是,Java泛型的“类型擦除法”并不是有意擦除的,而是“不得不”“*”擦除。由于Java虚拟机级别不支持泛型相关的指令和类型,Java编译器只能把“类型参数”擦除掉。
由于泛型信息的位置不同,可以分为两种情况。
一种情况是,class级别定义的泛型信息,即修饰class定义的泛型信息。由于class的对象将被实例化,而对象中并没有保留这部分泛型信息的地方,这部分泛型信息必须被擦除。
另一种情况是,class内部定义的泛型信息,即修饰成员变量或者成员方法的泛型信息。这部分信息与对象实例并不直接相关,而是随着class类型定义走的。因此,这部分信息可以保留下来。
事实上,Java编译器也确实是这样做的。成员变量和成员方法的泛型信息全都以字符串的形式保留在在class类型定义的常量池中。常量池英文叫做constant pool,用来存放类型中定义的一些常量,通常是字符串。
对于这部分泛型信息,JDK中新增了一些Reflection编程接口获取这些泛型信息。程序员可以用这些泛型信息相关的Reflection编程接口获取某一个class内部定义的成员变量或者成员方法的泛型信息。
从这里,我们可以看到“类型擦除法”的一个原则:在可能的情况下Java编译器还是会尽量保留尽可能多的泛型信息。
有兴趣的读者可以自己写一个Java泛型类,用泛型信息修饰分别修饰class、成员变量和成员方法,并写一段代码用泛型信息相关的Reflection编程接口获取这个类对象的泛型信息。编译并运行,查看结果。然后,再用javap反编译生成的class文件,查看其中的虚拟机指令和常量池部分。这样做了之后,你会对Java泛型的实现机制有更深刻的体会。
Java泛型的擦除法只在编译期做文章,这种做法优缺点,也有优点。缺点很明显,由于运行时,对象实例被擦掉了泛型信息,对手阵营(主要是C#阵营)称之为 “伪泛型”。
优点呢?第一点,类型擦除法只影响编译器,不影响虚拟机指令,不影响编译后的对象实例内存结构(当然,对class类型定义的常量池还是有一点影响的)。一些Java语言的忠实拥趸会振振有词地说,Java泛型对象实例化后比C#泛型对象实例化后省了那么一点点内存。
第二点,类型擦除法支持类型参数中的通配符(wildcard)。这里的通配符就是“?”。比如,Java支持这样的泛型定义 -- List<?>, List<? extends Number>, List<? super Integer> 。这是一种编译器的游戏,这方面,C#泛型的“Reification”(类型具体化)可就吃亏了,无法支持这种通配符。
我个人对于泛型的观感是这样——能省则省,能避免就避免。因为泛型实际上强化了编译期的静态类型检查。而我个人更喜欢动态类型的灵活性和方便性。
在我个人看来,泛型的主要用武之地在于那些基本类型上,比如,int、float、double等。但是,现在的情况是,泛型却大量用于class类型上,比如,String、Integer等。对于这些class类型来说,我更倾向于用面向对象设计的方式来解决重用,而不是用泛型的方式。毕竟,归根结底,泛型也不过是一种代码膨胀的技术,无论是源代码还是目标代码的膨胀。
以上只是我的个人偏见,读者无须因此而受到影响,该怎么做还是怎么做,具体事情具体分析。现在,我们回到本章的主题——语言的动态性。
Java语言的Reflection机制为Java程序提供了更多的动态性和灵活性。不过,Java语言毕竟还是一门静态类型语言,其Reflection编程接口调用起来也是颇为麻烦。当我们在编程中遇到大量的动态类型、动态调用之类的需求时,我们就需要考虑更为动态的语言——比如Python和Ruby等动态语言了。
Python和Ruby之所以被称为动态语言,是因为它们的Duck Type(鸭子类型)的设计理念。
关于Duck Type(鸭子类型)的原话的大意是这样的:我不关心这个对象是不是一个鸭子,只要它能像鸭子一样呱呱叫,还能像鸭子一样摇摇摆摆地走路,我就把它当做一个鸭子——反正我只关心鸭子的这两个行为。
这段话的意思就是,Duck Type只关心对象是否支持某种行为,而不关心这种对象是否某种类型。
在Java之类的静态类型语言中,编译器首先关心的就是对象的类型。因为所有的行为方法都是在类型中定义的,只要类型一致,行为自然是一致的。因此,Java等静态类型语言的类型检查更严格一些。
在Python、Ruby等动态语言中,无论是编译期,还是运行期,都不进行任何检查,而是直接在该对象的内部方法表(其内存结构类似于C++、Java对象中的虚表VTable)中查找对应的方法,看看该对象是否支持这种方法。如果支持,那么好,这条代码就是合法的。
比如,如果Python、Ruby解释器遇到这样的代码,a.quark()。解释器并不去查看a对象的类型,而是直接到a对象的方法表中查找是否存在一个名字叫做quark、参数为空的方法。如果存在,那么这条代码通过,可以执行;如果不存在,报错。
由于Python和Ruby只关心方法列表,而不关心类型,这又引出了Python和Ruby的又一个强大的语法特性——mixin(混入)。利用Python和Ruby的这种语法特性,我们可以轻易地把外面定义的方法混入到一个类的方法列表中。这个功能不仅完全实现了C++多重继承的目,甚至有过之而无不及。当然,同C++多重继承一样,mixin也会引起类似的所引起的负面效果,比如,不小心引入了同名方法可能会引起一些不期望的结果,等等。不过,对于动态语言来说,这点负面效果的影响要远远小于C++的多重继承。
Python和Ruby的Reflection机制(或者叫做Introspection)比Java更加强大灵活,更加简单易用。使用Python和Ruby,我们可以轻易地实现Java语言难以达到的动态性和灵活性。
不过,Python和Ruby还不算是动态类型的极致。有一门常见常用的语言,比Python和Ruby还要动态。
这门语言是什么?聪明的读者已经猜到了。没错,就是Javascript。
在Javascript语言中,每一个对象都是一个类似于Hash Table(哈希表,也叫散列表)的结构。我们可以在其中任意添加属性和方法。比如,我们可以这样定义一个对象。
var myObject = {
m1: “some data” // 成员变量
f1: function() {…} // 成员方法
f2: function() {…} // 成员方法
}
我们还可以一步步填充这个对象。比如,
var myObject = {} // 空对象
myObject.m1 = “some data”;
function f1() { … }
myObject.f1 = f1;
myObject.f2 = function() {…}
为了更清楚地表现Javascript对象的内部结构,我们还可以这样写。
var myObject = {} // 空对象
myObject[“m1”] = “some data”;
function f1() { … }
myObject[“f1”] = f1;
myObject[“f2”] = function() {…}
在上述代码中,[ ]这对方括号就表示Javascript对象(Hash Table)中的某个名字对应的值。
Javascript对象的Reflection用法非常简单直观。for (var member in myObject) 就可以遍历myObject中的所有成员。
另外,我们还可以把Javascript对象的构造函数写成类似于Java语言的风格。
function myObject(){
this.m1 = "some data";
this.f1 = f1;
this.f2 = f2;
}
function f1() { … }
function f2() { … }
由此可见,Javascript对象内部的结构是多么的灵活,属性表中的每条属性都可以任意替换和填充。这才是真正的开放类型。
当然,Javascript是一元语言,不存在类型和实例的区别。而在C++、Java、C#、Python、Ruby等二元语言中,类型和实例是两个明确分开的概念。
如果要讲类型的话,每一个Javascript对象都是一个潜在的扩展类型。只要属性替换和填充发生了,一个新的扩展类型就产生了。
每个Javascript对象都有一个最基本的原型(prototype)对象,每次新生成一个Javascript对象,实际上就是把那个原型对象复制了一遍。这就像把一个Hash Table完全复制一遍一样。
如果你需要改变所有新对象的某个属性,你只要修改原型(prototype)对象的对应属性就可以了。之后,从那个原型复制出来的所有新对象的对应属性都会跟着改变。
Javascript访问原型对象相当容易。myObject.prototype就可以获得myObject的原型对象。myObject就是从myObject.prototype这个对象复制出来的。
Javascript并没有类型的概念,最初的原型也都是对象实例。这种语言叫做一元语言。与之相对的,有类型和实例概念的语言,比如,C、Java、C#、Python、Ruby等语言,叫做二元语言。
Javascript比Python、Ruby更具有动态性,主要就是因为它的“一元类型系统”的特性。
Python和Ruby是不关心类型,但毕竟还有类型的概念。但是,在Javascript这种一元语言中,干脆就取消类型的概念,所有的对象实例都自成一个类型,从而获得了更大的动态性。
Javascript语言把“Duck Type”的思想发挥到了极致——我们不关心它是不是鸭子,我们只关心它会不会像鸭子一样叫,我们只关心它会不会像鸭子一样摇摇摆摆走路。啊,不,事实上,我们根本就不关心它是否像鸭子,因为在我们的系统中根本就没有鸭子这个东西。我们只关心它会不会叫,会不会摇摇摆摆走路。
读者可能会问了,既然一元类型系统比二元类型系统更加灵活,那么为什么大部分语言都采用二元类型系统的设计思路?为什么不能像Javascript一样,取消类型的概念呢?所有的原初“类型”都是“原型”对象,这样不是很好吗?
实际上,我也是这么想的。我更欣赏Javascript这种类型系统设计思路。在我看来,之所以大部分语言不采取这种设计思路,是出于两种原因。
第一个原因说起来有些无聊,那就是,Javascript语言的这种一元类型系统太动态、太灵活了,使得类型检查排错成为几乎不可能的事情,大大增加了代码的风险性。
第二个原因是空间原因。在二元类型系统中,所有的方法都存放在方法表中。而一个类型的方法表只存在一份。无论创建多少个对象实例,都共用本类型的那个方法表。而在一元类型系统中,每个对象实例都有一份自己的方法表。这就造成了空间浪费。对象实例越多,空间浪费就越大。当然,正是因为这种空间浪费的设计,才使得Javascript如此灵活强大。
太极拳理论中有一句话,“一动则周身无有不动,一静则百骸皆静”。
Javascript就是一种动态到极致的语言,一动则周身无有不动,没有什么不能动的,属性内容可以动,方法内容可以动,属性名可以动,方法名也可以动,而且什么时候都可以动。
静态语言就正好相反,虽然还不到“一静则百骸皆静”的程度,但也差不多了。这里不能动,那里也不能动。这里可以动一点点,但是不可以大动。而且,静态语言提倡“一动不如一静”,最好还是不动。不动最安全。
动态好,还是静态好,这一直是个争议不休的话题。动态方的观点是,动态语言灵活强大,代码简洁,生产力高。静态方的观点是,静态语言类型安全,出错率低,运行效率高。总之,是公说公有理,婆说婆有理。
我的观点是,从重用的角度来说,动态语言确实比静态语言要强。在动态语言中,我们可以很容易地包装或者修正对象的外在行为特性,从而使得不同类型的对象实例“表现得”好像同一个类型一样。通过这种方式,我们可以让不同类型的对象适应同一个算法,从而达到重用的目的。
由于静态类型语言需要进行类型检查,要做到这一点是非常不易的。在静态语言中,想让不同的类型“表现得”像同一个类型一样,只有一个方法,那就是为这些不同的类型抽出一个共同的通用接口。这个要求有些太过分,不太现实,因为我们不可能去修改开发包中那些基本类的定义。因此,在静态语言中,想让不同的类型适应同一个算法,最现实的方法的就是使用泛型。而泛型实质上是一种代码膨胀技术,或者说代码生成技术。我本人对于代码生成技术是没有太多好感的。这也造成了我对泛型没有太多好感。而泛型是静态类型系统的衍生物。这就使得我更喜欢动态类型,而不是静态类型。
这些都是主义之争,无关实际工作。我们还是少谈些主义,多谈些问题。
代码设计有这么一条原则,遇到新需求的时候,尽量添加新代码,而不是修改旧代码。能够做到这一点的,就是好设计。Good Smell(好味道)。做不到这一点的,就是坏设计。Bad Smell(坏味道)。
在本章开头的代码中,存在着一些判断当前元素类型的条件语句。那些条件语句里面的判断条件都是写死的,无法改变。这种代码叫做Hard Coded(硬编码)。当我们在集合中增加一种新的数据类型的时候,我们就不得不修改遍历算法中的代码,增加一条对应该类型的条件语句。相应的,我们还需要在Visitor里面增加一个对应的访问该类型元素的方法。这就是Double Dispatch的Visitor Pattern的Bed Smell所在。这就是我为什么不愿意费工夫描述这个设计模式的原因。
现在,我们想一个办法,去掉这种设计中的Bad Smell。首先,我们要做的是,去掉遍历算法中的if else语句。这点做起来其实很简单。我们只需要把Visitor里面的方法简化到一个就好了。Visitor里面就只有一个方法,visit(Object currentElement)。那么,遍历算法中的类型判断语句放到哪里呢?当然是放到Visitor的方法里。
visit(Object currentElement)里面的代码看起来就是这样:
if (currentElement的类型是A)
….
else if (currentElement的类型是B)
….
这样,我们就成功地消除了遍历算法中的Bad Smell。这是理所当然的。因为所有的Bad Smell全都转移到Visitor里面了。不管怎么说,我们总算是进了一步。Bad Smell集中在一个地方,总比集中在两个地方好。
现在,所有的Bad Smell都在Visitor里面了。我们可以集中精力对付它。首先,我们要做的是,去掉visit方法中的if else语句。
读者会说了,这真是没事找事做。费劲巴拉地把if else移过来,结果还是要消除它。
可不要小看这一步。在Visitor中消除if else,可比在遍历算法中消除它简单多了。
消除if else的利器是什么?就是多态。可是,我们这里遇到的参数类型(元素数据类型)的多态,而不是Visitor本身的多态,我们多态不起来。
现在的问题是,我们如何才能实现多态的Type Dispatch(类型分派,即参数类型的分派处理)。
我们可以从前面描述的Javascript对象结构中获得灵感。Javascript对象是什么结构?对了,是个Hash Table。我们可以*地在增加和修改其中的属性。这是我们所追求的目标。
参照这种结构,我们也可以在Visitor内部维护一个Hash Table。我们可以在这个Hash Table任意添加某种类型的处理程序。当Visitor遇到一个数据元素的时候,先从Hash Table里面查找该类型,如果找到了,就调出该类型对应的处理程序进行处理;如果没找到,那就没办法了,只能为该类型写一个处理程序,然后添加到Hash Table中。
我们看到,在这个设计方案中,只有添加,没有修改。这正是我们追求的Good Smell。
示意代码如下:
interface Visitor {
void visit(Object currentElement);
}
class TypeDispatchVisitor {
Map handlerMap = new HashMap();
public void setTypeHandler(Class t, Visitor handler){
handlerMap.put(t, handler);
}
public void visit(Object currentElement){
Visitor handler = (Visitor)handlerMap.get(currentElement.getClass());
handler.visit(currentElement);
}
}
这个TypeDispatchVisitor的用法是这样。
TypeDispatchVisitor dispatcher = new TypeDispatchVisitor();
dispatcher.setTypeHandler(A.class, new AVisitor());
dispatcher.setTypeHandler(B.class, new BVisitor());
traversal(dispatcher);
其中,A和B都是元素类型,AVisitor和BVisitor是处理这两种类型的两个Visitor类。
我们看到,这种设计完美地解决了之前的问题,去除了原有设计中的Bad Smell。当然,这种设计又引入了一点新的Bad Smell——类型强制转换(Type Cast)。AVisitor和BVisitor在处理相应数据类型的时候,需要把参数从Object类型强制转换成对应的A类型或者B类型。不过,这点Bad Smell比之前的Bad Smell好闻太多了。
这种设计如果用动态语言来写的话,那就连类型强制转换(Type Cast)的Bad Smell都没有了。这也是我倾向于动态语言的一个原因。至少在程序设计方面,动态语言可以设计得更加漂亮。
在前面的章节中,我们已经几次遇到过Type Dispatch(类型分派)的场景了。在这种场景中,我们需要根据数据类型选择不同的行为。比如,我们来看下面这段典型Double Dispatch的Visitor Pattern的代码。
void traversal(Visitor visitor){
// traverse each element
…
currentElement = 当前元素
if (currentElement的类型是A)
visitor.visitA(currentElement)
else if (currentElement的类型是B)
visitor.visitB(currentElement)
…
…
}
我们看到,在上述的遍历算法中,我们需要判断当前元素的类型来决定下一步执行的代码。这就程序在运行时动态获取当前数据的类型信息。
可不要小看动态获取类型信息这个需求,并不是所有的语言都能够很容易地做到这一点。至少在C++语言中,做到这一点就极其困难。
对应这个问题,C++语言中专门有一个术语,叫做RTTI(Run Time Type Information,运行时类型信息)。如果要在C++语言中实现RTTI的需求,则必须用一套RTTI相关的宏定义,在编译期间就为某个类型(class)产生额外的类型信息(通常是字符串类型),以便程序在运行时能够获取。在有些C++ Template技术中,还支持typeof关键字,同样是用来在编译期间产生类型信息。
Java语言实现运行时类型信息获取的需求,就容易了许多。Java语言中有一个关键字instanceof,可以判断某个数据是否某种类型。另外,所有的Java对象都支持getClass()方法,这同样可以用来在运行时获取类型信息。
不仅如此,Java对象还支持Reflection(反射)特性。应用程序可以利用Reflection编程接口在运行时查看到某个Java对象内部的所有公开的成员变量和成员方法。
在某些语言中,比如,Python中,Reflection(反射)特性也叫做Introspection(内省)。从词义的角度来说,我觉得,Introspection这个词更加贴切一些。不过,由于Java比较流行,Reflection这个词也用得更多一些,甚至连Python也引入了这个词。我们也就从众,用Reflection这个词。
应用程序不仅可以利用Reflection编程接口获取对象的内部类型定义信息(如属性列表、方法列表等),还可以利用Reflection编程接口直接根据用一个字符串表述的方法名,直接调用对象中的对应方法。
这个功能在某些场景中特别有用处。比如,应用程序可以解析一段文本文件,获取其中的某个字段,并根据该字段作为方法名,调用某个对象中的对应方法。
有一个叫做OGNL的Java开源项目就利用这一点,实现一套强大的对象属性访问语言。OGNL 是 Object-Graph Navigation Language 的缩写,意思就是对象图导航语言,可以把这样的字符串“a.b.c.d”翻译成对应的Java代码调用——a.getB().getC().getD()。当然,OGNL是利用Reflection编程接口,一步步实现这种级联调用的。
C#语言提供了与Java语言类似的Reflection机制。这两门语言有很多近似的地方,也是竞争最直接的两门语言。两个阵营的程序员经常相互攻击对方语言的缺陷,彰显己方语言的优越性。
既然讲到了Reflection,我们就顺便为前面章节中“Java与C#泛型比较”的小话题做个总结陈词。
C#的泛型技术叫做“Reification”(类型具体化)。这种泛型技术很彻底,直接影响到编译后的虚拟机指令。运行时,对象实例内部仍然保持着泛型信息。程序员可以用Reflection编程接口获取泛型定义的信息。
Java的泛型技术叫做“Type Erasure”(类型擦除),与C++ Template是同源的,都是在编译器做文章。编译之后,“参数化类型”就被擦除掉了。运行时,Java对象实例内部没有泛型(类型参数)的信息,也不能被Reflection编程接口获取。
需要注意的一点是,Java泛型的“类型擦除法”并不是有意擦除的,而是“不得不”“*”擦除。由于Java虚拟机级别不支持泛型相关的指令和类型,Java编译器只能把“类型参数”擦除掉。
由于泛型信息的位置不同,可以分为两种情况。
一种情况是,class级别定义的泛型信息,即修饰class定义的泛型信息。由于class的对象将被实例化,而对象中并没有保留这部分泛型信息的地方,这部分泛型信息必须被擦除。
另一种情况是,class内部定义的泛型信息,即修饰成员变量或者成员方法的泛型信息。这部分信息与对象实例并不直接相关,而是随着class类型定义走的。因此,这部分信息可以保留下来。
事实上,Java编译器也确实是这样做的。成员变量和成员方法的泛型信息全都以字符串的形式保留在在class类型定义的常量池中。常量池英文叫做constant pool,用来存放类型中定义的一些常量,通常是字符串。
对于这部分泛型信息,JDK中新增了一些Reflection编程接口获取这些泛型信息。程序员可以用这些泛型信息相关的Reflection编程接口获取某一个class内部定义的成员变量或者成员方法的泛型信息。
从这里,我们可以看到“类型擦除法”的一个原则:在可能的情况下Java编译器还是会尽量保留尽可能多的泛型信息。
有兴趣的读者可以自己写一个Java泛型类,用泛型信息修饰分别修饰class、成员变量和成员方法,并写一段代码用泛型信息相关的Reflection编程接口获取这个类对象的泛型信息。编译并运行,查看结果。然后,再用javap反编译生成的class文件,查看其中的虚拟机指令和常量池部分。这样做了之后,你会对Java泛型的实现机制有更深刻的体会。
Java泛型的擦除法只在编译期做文章,这种做法优缺点,也有优点。缺点很明显,由于运行时,对象实例被擦掉了泛型信息,对手阵营(主要是C#阵营)称之为 “伪泛型”。
优点呢?第一点,类型擦除法只影响编译器,不影响虚拟机指令,不影响编译后的对象实例内存结构(当然,对class类型定义的常量池还是有一点影响的)。一些Java语言的忠实拥趸会振振有词地说,Java泛型对象实例化后比C#泛型对象实例化后省了那么一点点内存。
第二点,类型擦除法支持类型参数中的通配符(wildcard)。这里的通配符就是“?”。比如,Java支持这样的泛型定义 -- List<?>, List<? extends Number>, List<? super Integer> 。这是一种编译器的游戏,这方面,C#泛型的“Reification”(类型具体化)可就吃亏了,无法支持这种通配符。
我个人对于泛型的观感是这样——能省则省,能避免就避免。因为泛型实际上强化了编译期的静态类型检查。而我个人更喜欢动态类型的灵活性和方便性。
在我个人看来,泛型的主要用武之地在于那些基本类型上,比如,int、float、double等。但是,现在的情况是,泛型却大量用于class类型上,比如,String、Integer等。对于这些class类型来说,我更倾向于用面向对象设计的方式来解决重用,而不是用泛型的方式。毕竟,归根结底,泛型也不过是一种代码膨胀的技术,无论是源代码还是目标代码的膨胀。
以上只是我的个人偏见,读者无须因此而受到影响,该怎么做还是怎么做,具体事情具体分析。现在,我们回到本章的主题——语言的动态性。
Java语言的Reflection机制为Java程序提供了更多的动态性和灵活性。不过,Java语言毕竟还是一门静态类型语言,其Reflection编程接口调用起来也是颇为麻烦。当我们在编程中遇到大量的动态类型、动态调用之类的需求时,我们就需要考虑更为动态的语言——比如Python和Ruby等动态语言了。
Python和Ruby之所以被称为动态语言,是因为它们的Duck Type(鸭子类型)的设计理念。
关于Duck Type(鸭子类型)的原话的大意是这样的:我不关心这个对象是不是一个鸭子,只要它能像鸭子一样呱呱叫,还能像鸭子一样摇摇摆摆地走路,我就把它当做一个鸭子——反正我只关心鸭子的这两个行为。
这段话的意思就是,Duck Type只关心对象是否支持某种行为,而不关心这种对象是否某种类型。
在Java之类的静态类型语言中,编译器首先关心的就是对象的类型。因为所有的行为方法都是在类型中定义的,只要类型一致,行为自然是一致的。因此,Java等静态类型语言的类型检查更严格一些。
在Python、Ruby等动态语言中,无论是编译期,还是运行期,都不进行任何检查,而是直接在该对象的内部方法表(其内存结构类似于C++、Java对象中的虚表VTable)中查找对应的方法,看看该对象是否支持这种方法。如果支持,那么好,这条代码就是合法的。
比如,如果Python、Ruby解释器遇到这样的代码,a.quark()。解释器并不去查看a对象的类型,而是直接到a对象的方法表中查找是否存在一个名字叫做quark、参数为空的方法。如果存在,那么这条代码通过,可以执行;如果不存在,报错。
由于Python和Ruby只关心方法列表,而不关心类型,这又引出了Python和Ruby的又一个强大的语法特性——mixin(混入)。利用Python和Ruby的这种语法特性,我们可以轻易地把外面定义的方法混入到一个类的方法列表中。这个功能不仅完全实现了C++多重继承的目,甚至有过之而无不及。当然,同C++多重继承一样,mixin也会引起类似的所引起的负面效果,比如,不小心引入了同名方法可能会引起一些不期望的结果,等等。不过,对于动态语言来说,这点负面效果的影响要远远小于C++的多重继承。
Python和Ruby的Reflection机制(或者叫做Introspection)比Java更加强大灵活,更加简单易用。使用Python和Ruby,我们可以轻易地实现Java语言难以达到的动态性和灵活性。
不过,Python和Ruby还不算是动态类型的极致。有一门常见常用的语言,比Python和Ruby还要动态。
这门语言是什么?聪明的读者已经猜到了。没错,就是Javascript。
在Javascript语言中,每一个对象都是一个类似于Hash Table(哈希表,也叫散列表)的结构。我们可以在其中任意添加属性和方法。比如,我们可以这样定义一个对象。
var myObject = {
m1: “some data” // 成员变量
f1: function() {…} // 成员方法
f2: function() {…} // 成员方法
}
我们还可以一步步填充这个对象。比如,
var myObject = {} // 空对象
myObject.m1 = “some data”;
function f1() { … }
myObject.f1 = f1;
myObject.f2 = function() {…}
为了更清楚地表现Javascript对象的内部结构,我们还可以这样写。
var myObject = {} // 空对象
myObject[“m1”] = “some data”;
function f1() { … }
myObject[“f1”] = f1;
myObject[“f2”] = function() {…}
在上述代码中,[ ]这对方括号就表示Javascript对象(Hash Table)中的某个名字对应的值。
Javascript对象的Reflection用法非常简单直观。for (var member in myObject) 就可以遍历myObject中的所有成员。
另外,我们还可以把Javascript对象的构造函数写成类似于Java语言的风格。
function myObject(){
this.m1 = "some data";
this.f1 = f1;
this.f2 = f2;
}
function f1() { … }
function f2() { … }
由此可见,Javascript对象内部的结构是多么的灵活,属性表中的每条属性都可以任意替换和填充。这才是真正的开放类型。
当然,Javascript是一元语言,不存在类型和实例的区别。而在C++、Java、C#、Python、Ruby等二元语言中,类型和实例是两个明确分开的概念。
如果要讲类型的话,每一个Javascript对象都是一个潜在的扩展类型。只要属性替换和填充发生了,一个新的扩展类型就产生了。
每个Javascript对象都有一个最基本的原型(prototype)对象,每次新生成一个Javascript对象,实际上就是把那个原型对象复制了一遍。这就像把一个Hash Table完全复制一遍一样。
如果你需要改变所有新对象的某个属性,你只要修改原型(prototype)对象的对应属性就可以了。之后,从那个原型复制出来的所有新对象的对应属性都会跟着改变。
Javascript访问原型对象相当容易。myObject.prototype就可以获得myObject的原型对象。myObject就是从myObject.prototype这个对象复制出来的。
Javascript并没有类型的概念,最初的原型也都是对象实例。这种语言叫做一元语言。与之相对的,有类型和实例概念的语言,比如,C、Java、C#、Python、Ruby等语言,叫做二元语言。
Javascript比Python、Ruby更具有动态性,主要就是因为它的“一元类型系统”的特性。
Python和Ruby是不关心类型,但毕竟还有类型的概念。但是,在Javascript这种一元语言中,干脆就取消类型的概念,所有的对象实例都自成一个类型,从而获得了更大的动态性。
Javascript语言把“Duck Type”的思想发挥到了极致——我们不关心它是不是鸭子,我们只关心它会不会像鸭子一样叫,我们只关心它会不会像鸭子一样摇摇摆摆走路。啊,不,事实上,我们根本就不关心它是否像鸭子,因为在我们的系统中根本就没有鸭子这个东西。我们只关心它会不会叫,会不会摇摇摆摆走路。
读者可能会问了,既然一元类型系统比二元类型系统更加灵活,那么为什么大部分语言都采用二元类型系统的设计思路?为什么不能像Javascript一样,取消类型的概念呢?所有的原初“类型”都是“原型”对象,这样不是很好吗?
实际上,我也是这么想的。我更欣赏Javascript这种类型系统设计思路。在我看来,之所以大部分语言不采取这种设计思路,是出于两种原因。
第一个原因说起来有些无聊,那就是,Javascript语言的这种一元类型系统太动态、太灵活了,使得类型检查排错成为几乎不可能的事情,大大增加了代码的风险性。
第二个原因是空间原因。在二元类型系统中,所有的方法都存放在方法表中。而一个类型的方法表只存在一份。无论创建多少个对象实例,都共用本类型的那个方法表。而在一元类型系统中,每个对象实例都有一份自己的方法表。这就造成了空间浪费。对象实例越多,空间浪费就越大。当然,正是因为这种空间浪费的设计,才使得Javascript如此灵活强大。
太极拳理论中有一句话,“一动则周身无有不动,一静则百骸皆静”。
Javascript就是一种动态到极致的语言,一动则周身无有不动,没有什么不能动的,属性内容可以动,方法内容可以动,属性名可以动,方法名也可以动,而且什么时候都可以动。
静态语言就正好相反,虽然还不到“一静则百骸皆静”的程度,但也差不多了。这里不能动,那里也不能动。这里可以动一点点,但是不可以大动。而且,静态语言提倡“一动不如一静”,最好还是不动。不动最安全。
动态好,还是静态好,这一直是个争议不休的话题。动态方的观点是,动态语言灵活强大,代码简洁,生产力高。静态方的观点是,静态语言类型安全,出错率低,运行效率高。总之,是公说公有理,婆说婆有理。
我的观点是,从重用的角度来说,动态语言确实比静态语言要强。在动态语言中,我们可以很容易地包装或者修正对象的外在行为特性,从而使得不同类型的对象实例“表现得”好像同一个类型一样。通过这种方式,我们可以让不同类型的对象适应同一个算法,从而达到重用的目的。
由于静态类型语言需要进行类型检查,要做到这一点是非常不易的。在静态语言中,想让不同的类型“表现得”像同一个类型一样,只有一个方法,那就是为这些不同的类型抽出一个共同的通用接口。这个要求有些太过分,不太现实,因为我们不可能去修改开发包中那些基本类的定义。因此,在静态语言中,想让不同的类型适应同一个算法,最现实的方法的就是使用泛型。而泛型实质上是一种代码膨胀技术,或者说代码生成技术。我本人对于代码生成技术是没有太多好感的。这也造成了我对泛型没有太多好感。而泛型是静态类型系统的衍生物。这就使得我更喜欢动态类型,而不是静态类型。
这些都是主义之争,无关实际工作。我们还是少谈些主义,多谈些问题。
代码设计有这么一条原则,遇到新需求的时候,尽量添加新代码,而不是修改旧代码。能够做到这一点的,就是好设计。Good Smell(好味道)。做不到这一点的,就是坏设计。Bad Smell(坏味道)。
在本章开头的代码中,存在着一些判断当前元素类型的条件语句。那些条件语句里面的判断条件都是写死的,无法改变。这种代码叫做Hard Coded(硬编码)。当我们在集合中增加一种新的数据类型的时候,我们就不得不修改遍历算法中的代码,增加一条对应该类型的条件语句。相应的,我们还需要在Visitor里面增加一个对应的访问该类型元素的方法。这就是Double Dispatch的Visitor Pattern的Bed Smell所在。这就是我为什么不愿意费工夫描述这个设计模式的原因。
现在,我们想一个办法,去掉这种设计中的Bad Smell。首先,我们要做的是,去掉遍历算法中的if else语句。这点做起来其实很简单。我们只需要把Visitor里面的方法简化到一个就好了。Visitor里面就只有一个方法,visit(Object currentElement)。那么,遍历算法中的类型判断语句放到哪里呢?当然是放到Visitor的方法里。
visit(Object currentElement)里面的代码看起来就是这样:
if (currentElement的类型是A)
….
else if (currentElement的类型是B)
….
这样,我们就成功地消除了遍历算法中的Bad Smell。这是理所当然的。因为所有的Bad Smell全都转移到Visitor里面了。不管怎么说,我们总算是进了一步。Bad Smell集中在一个地方,总比集中在两个地方好。
现在,所有的Bad Smell都在Visitor里面了。我们可以集中精力对付它。首先,我们要做的是,去掉visit方法中的if else语句。
读者会说了,这真是没事找事做。费劲巴拉地把if else移过来,结果还是要消除它。
可不要小看这一步。在Visitor中消除if else,可比在遍历算法中消除它简单多了。
消除if else的利器是什么?就是多态。可是,我们这里遇到的参数类型(元素数据类型)的多态,而不是Visitor本身的多态,我们多态不起来。
现在的问题是,我们如何才能实现多态的Type Dispatch(类型分派,即参数类型的分派处理)。
我们可以从前面描述的Javascript对象结构中获得灵感。Javascript对象是什么结构?对了,是个Hash Table。我们可以*地在增加和修改其中的属性。这是我们所追求的目标。
参照这种结构,我们也可以在Visitor内部维护一个Hash Table。我们可以在这个Hash Table任意添加某种类型的处理程序。当Visitor遇到一个数据元素的时候,先从Hash Table里面查找该类型,如果找到了,就调出该类型对应的处理程序进行处理;如果没找到,那就没办法了,只能为该类型写一个处理程序,然后添加到Hash Table中。
我们看到,在这个设计方案中,只有添加,没有修改。这正是我们追求的Good Smell。
示意代码如下:
interface Visitor {
void visit(Object currentElement);
}
class TypeDispatchVisitor {
Map handlerMap = new HashMap();
public void setTypeHandler(Class t, Visitor handler){
handlerMap.put(t, handler);
}
public void visit(Object currentElement){
Visitor handler = (Visitor)handlerMap.get(currentElement.getClass());
handler.visit(currentElement);
}
}
这个TypeDispatchVisitor的用法是这样。
TypeDispatchVisitor dispatcher = new TypeDispatchVisitor();
dispatcher.setTypeHandler(A.class, new AVisitor());
dispatcher.setTypeHandler(B.class, new BVisitor());
traversal(dispatcher);
其中,A和B都是元素类型,AVisitor和BVisitor是处理这两种类型的两个Visitor类。
我们看到,这种设计完美地解决了之前的问题,去除了原有设计中的Bad Smell。当然,这种设计又引入了一点新的Bad Smell——类型强制转换(Type Cast)。AVisitor和BVisitor在处理相应数据类型的时候,需要把参数从Object类型强制转换成对应的A类型或者B类型。不过,这点Bad Smell比之前的Bad Smell好闻太多了。
这种设计如果用动态语言来写的话,那就连类型强制转换(Type Cast)的Bad Smell都没有了。这也是我倾向于动态语言的一个原因。至少在程序设计方面,动态语言可以设计得更加漂亮。