欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

《编程机制探析》第十四章 关于方法表的那些事

程序员文章站 2022-05-17 13:43:54
...
《编程机制探析》第十四章 关于方法表的那些事

上一章,我们讲解了静态类型语言和动态类型语言的特性对比。这一章,我们继续深入讲解静态类型对象和动态类型对象的内部机理——方法表(虚表)的内存结构以及实现机制。
我们先从静态类型语言中常见的语法陷阱开始。这些语法陷阱能够帮助我们更深入地理解静态类型对象的方法表的结构和特征。
让我们回到上一章开头的那段代码。那个Visitor有两个方法,visitA和visitB。其中visitA就表示访问A类型的数据元素,接受的参数也是A类型。visitB表示访问B类型的数据元素,接受的参数也是A类型。这种写法是我的偏好,也是我的建议。
我希望读者能够接受我的这种建议:除非为了达到某种特殊目的,绝对不要在自己的class定义中的同名方法。
为什么这么讲呢?这个问题要从头细细道来。在之前的章节中,我一直回避了这个问题。因为这个问题很烦人,完全是没有必要的误会引起的。世上本无事,庸人自扰之。不过,既然已经遇到了,就顺便澄清一下相关概念吧。
我们还是以Java语言为例,因为这种问题在Java等静态类型语言中表现得尤其明显。
首先,我们需要了解一个名词——方法签名(Method Signature)。
方法签名是个什么东西呢?方法签名就是一连串关于方法的信息的集合,其中包括如下部分:方法名,参数个数,每个参数的类型(按照顺序排列)。
这些信息构成了一个本类范围内独一无二的特征值,编译器依靠这些信息就能够准确地识别出一个方法,因此,这些信息叫做方法签名。
每一个类中都有一个方法表(虚表,VTable),里面的方法有些是本类自定义的方法,直接出现在本类的定义范围内;还有些方法来自于祖先类,并没有直接出现在本类的定义中,但是,那些方法仍然是该类的方法表中的方法,因此,也是该类的方法。
无论是来自于祖先类的方法,还是本类自定义的方法,都属于本类的方法。它们在地位上并没有任何区别。
注意,这里没有用子类和父类这两个词,而是用了后代类和祖先类这两个词,这是为了更准确的表述这个问题。请一定要注意这两个词表达的继承关系。
在同一个类的方法表中,不允许有相同方法签名的存在。当编译器发现同一个类的方法表中,存在相同的方法签名的时候,会根据不同情况,进行不同处理。
第一种情况是,后代类中定义了和祖先类同样的方法签名。编译器遇到这种情况时,就会直接在后代类的方法表中替换带掉祖先类的对应方法。这就保证了在同一个类的方法表内,只允许存在同一份独一无二的方法签名。这种现象叫做override。
这本来是一个很简单、很直观的概念。但是,相当多的技术资料拿override这个词做文章,引起很多不必要的误解和混淆。这个词的中文翻译也莫衷一是,乱七八糟。为了避免引起任何误解和混淆,我这里就把“override”直接意译为“后代类在方法表中替换祖先类方法”。这种翻译虽然很土很长,但是,至少不会引起误解和混淆。
第二种情况是,编译器发现了相同的方法签名,都是本类新定义的方法,而不是祖先类的方法。这种情况下,编译器就会报错。
事情到这里,本来就该结束了。一切都很简单,很明了。但是,好事者又搞出来一个猫腻,叫做overload。这个词简直就是无事生非的最佳典型。我对其是深恶痛疾,比指针概念还要痛恨。
什么叫overload呢?那就是在一个类的方法表中,存在两个或者两个以上的同名方法,其参数个数和参数类型不相同。
由于方法签名不同。因此,这些同名方法能够逃过编译器的检查,因而得以幸存。这种现象,就叫做overload。同样,关于这个词,也有各种容易引起混淆的中文翻译。我一概不采纳。我这里给出自己的翻译,“同名方法并存”。这个翻译也不怎样,但至少不会制造出更多的误解和混淆。
有些人可能会觉得overload写法很酷很方便。但我的建议是,为了避免各种不必要的麻烦,最好不要这样写。不仅参数个数相同的同名方法不要写,就连参数个数不同的同名方法也不要写。总而言之,同名方法就不要写。
为什么这么强调呢?因为overload可能引起的语言陷阱远超我们的想象。
关于“后代类在方法表中替换祖先类方法”(即override),有一种情境叫做covariance(协变)。这本来是数学中的术语。编程语言设计人员借鉴这个词语来表述一种特殊的情景。这种情景很难用语言直接描述,我们最好还是来看一个例子。
假设祖先类中有一个方法,其方法签名为 visit(Nubmer a)。继承于该祖先类的后代类定义了一个方法,其方法签名为visit(Integer a)。
现在,有一个问题。后代类的方法visit(Integer a)是否能够替换掉祖先类中的visit(Nubmer a)。
初一看,答案当然是不能。因为这两个方法的参数类型不同,方法签名自然不同,所以,这种情况就不能发生替换(即,不能发生override),最终的结果,就是后代类的方法表中存在两个同名方法,一个是visit(Number a),一个是visit(Inter a)。这是overload的情景。
这个问题看起来很简单,就是一个overload的场景。但是,我们再细看看,就会发现一个有趣的现象。祖先类的方法visit(Number a)的参数类型是Number。后代类的方法visit(Integer a)的参数类型是Integer。而Number正是Integer的祖先类。在这种情况下,有些语言就允许后代类替换祖先类的方法。这种语法特性就叫做covariance。
因此,这种代码在不同的语言中有不同的表现。在支持covariance的语言中,就会产生“替换”(即override)的结果。在不支持covariance的语言中,就会产生“并存”(即overload)的情景。
Java语言不支持方法参数类型的covariance。在Java语言中,这种代码就会造成“并存”(即overload),即两个同名方法并列在后代类的方法列表中。
当我们使用这个后代类时,编译器很可能会遇到模棱两可的境况。比如,调用visit(new Interger(1))这个方法的时候,是调用visit(Number a)方法呢?还是调用visit(Integer a)方法呢?从参数类型匹配的角度来讲,两个方法都适用。这时候,编译器会根据上下文,尽量选择参数类型更接近的方法。我们来看一个例子。
class Parent{
  Object func(Number n) throws Exception{
    ...
  }
}

class Child extends Parent{
  Object func(Integer n) throws Exception {
    ...
  }
}
在这种极其变态诡异的“并存”(overload)情况下,年轻朋友们最着迷的游戏就是,你猜,你猜,你猜猜猜。

Number n = new Integer(0);
Integer i = new Integer(1);
Number nNull = null;
Integer iNull = null;

Child child = new Child();

child.func(n);
child.func(i);
child.func(nNull);
child.func(iNull);

child.func(null);

你猜,这几次都是调用哪个方法啊? 其乐无穷。
这个不用我猜。是编译器需要猜。
编译器根据参数类型进行猜测。猜得到,它就猜。猜不到,它就让你编译不过。
上面的,编译器能猜得到。
编译器才不管你运行的时候,真正的类型是什么。
它只按字面意思理解。你定义的参数,对应的变量是什么类型。它就选什么类型。如果两个类型都匹配,那么就猜更具体的类型。
如果都猜不到,那么就让你编译不过,说你是 含糊不清,二义性,ambiguous
编译器冷笑着:小样儿,跟我玩,你还嫩。
事情还没有完,Java语言虽然不支持“参数类型”的covariance,但Java语言支持返回值和异常的covariance。比如,下面的代码中发生的是什么现象?“替换”(override)还是“并存”(overload)?
class Parent{
  Object func(Number n) throws Exception{
    ...
  }
}

class Child extends Parent{
  String func(Number n) throws SQLException {
    ...
  }
}
聪明的读者已经猜到了。上面的代码会产生“替换”(override)的现象。两个方法的返回值分别是Object和String,而Object是String的祖先类。两个方法的异常反别是Exception和SQLExpection,而Exception是SQLException的祖先类。
Java语言支持这两种covariance,所以,上面的代码会发生“替换”(override)的现象。
怎么样,这个游戏很好玩吧?如果你还是觉得这种写法很酷,很帅,那就当我什么也没说。确实有些人提倡用“同名方法共存”(overload)的方式编程,他们觉得这样更加清晰。我只能说,我的观点正好相反。
静态类型的语言陷阱说完了,我们来看动态类型的情况。首先,Javascript语言的情况一目了然。Javascript对象就是一个Hash Table,而Hash Table里面根本就不可能存在同名的东西。因此,往Javascript对象中填入任何同名的东西,唯一的结果就是“替换”(override),根本就不会发生“并存”(overload)的情况。因此,也不存在“并存”(overload)引起的种种编程陷阱。
我们再来看Python和Ruby的情况。在Python和Ruby这两门动态语言中,并没有静态语言中的方法签名(Method Signature)的概念。因为它们并不检查参数类型,甚至也不检查参数个数,因为在Python和Ruby中,参数个数也可以是不定的。因此,Python和Ruby的情况与Javascript是一样的。只有替换(override),没有并存(overload)。只要是同名方法,一律替换(override)。
关于方法签名,我还有话要说。在Java里面,我们可以用Reflection编程接口获取方法名、参数类型列表等构成方法签名的信息,但是,我们却无法获取参数名列表的信息。在动态语言中,情况正好相反。我们可以获得方法名和参数名列表,却无法获得参数类型列表。
从这里我们可以看出静态类型语言和动态类型语言的一个重要差异——静态类型语言关注的是类型,动态类型语言关注的是名字。那么,造成两者之间的这种差异的真正机理是什么?是什么原因造成了两者之间的这种差异?
为了回答这个问题,我们需要回顾一下方法表的内部结构。
在静态语言中,虚表(方法表)是一个紧凑的数组结构,所有的方法条目(即每个方法的代码在内存中的存放地址)都是一个挨一个紧凑排列的。编译期在编译方法调用语句的时候,实际上是该方法把虚表中的相对位置编入了目标指令中。当程序运行到该指令的时候,就会根据这个相对位置去查虚表中的对应条目,从而查到该方法代码真实的存放地址,然后调用那段代码,从而实现方法的调用。静态类型语言之所以关注“类型”,是为了获得某个方法在那个类型的虚表中的相对位置。即,静态语言真正关心的是“内存位置”。
在动态语言中,我们可以把方法表理解为一个Hash Table,每一个方法名都是Hash Table中的键值,其对应的内容是方法实现代码的真正存放地址。动态语言只关心对象的行为,而不关心对象的类型。Hash Table这种结构,能够使得解释器根据方法名迅速定位到对应的方法实现代码。
我们可以用两个简单的等式,来描述静态类型语言和动态类型语言的方法表的数据结构。
静态类型语言:方法表 = 数组
动态类型语言:方法表 = Hash Table
静态类型语言的调用方法的目标指令看起来可能是这样的。
invoke A.ArrayTable[1]
invoke B.ArrayTable[3]
其中,A和B是不同的类型。ArrayTable就是VTable。这里用ArrayTable这个名字是为了强调VTable的数组结构。
当然,这两句代码是只是用来示意的伪代码。实际上的目标指令里面不可能存在A.ArrayTable[1]这种写法。
动态类型语言的调用方法的目标指令看起来可能是这样的。
invoke A.HashTable[“f1”]
invoke B.HashTable[“f3”]
可见,静态语言类型的方法调用被翻译成了数字(位置),而动态语言类型的方法调用被翻译成了字符串(名字)。
这也正是静态类型语言运行效率更高的原因。
这也正是动态类型语言更加动态灵活的原因。
正是两者的方法表的数据结构的不同,才造成了两种语言的种种不同的特性。
看到这里,喜欢思考的读者可能会问了。Python和Ruby的方法表结构,真的同Javascript一样,是一个Hash Table吗?
实际上,并不一定如此。Python和Ruby的方法表结构只是“表现得”像一个Hash Table。其内部实现结构完全可能是一个紧凑的数组结构。它们只需要另外提供一套能够根据方法名查找到方法位置的机制就可以了。
那么,Javascript的方法表是否也可以做类似的空间优化呢?我的回答是,不行。那样做会得不偿失。因为,每个Javascript对象内部都维护一个独立的方法表,而且,这个方法表还是会随时改变扩充的。为这样一个随时可能变化的数据结构做空间优化,意义不大。
Python和Ruby中,同一种类型的对象实例,共享同一份方法表。那份方法表就存放在那个类型定义中,而且基本是固定不变的。对这种基本固定的数据结构做空间优化,才有意义。
我没有研究过Python和Ruby的解释器的实现代码。我不知道它们是否针对方法表进行了空间优化。毕竟,对于每一个类型来说,方法表只有一份,存放在类型定义中,所有的对象实例都共享这份方法表。即使不优化,也没什么大不了的。
不过,我几乎可以确定的是,Python和Ruby应该对每个类型的对象实例的数据属性表进行了优化。
在Java等静态类型语言中,每个类型除了有一份方法表(数组结构的虚表)外,还有一份数据属性表(即成员变量表),用来存放数据信息。同虚表一样,数据属性表也是紧凑的数组结构。方法表和数据属性表的区别在于。方法表是独立存在于类型定义之外的,类型定义里面只存放了方法表的地址,这也是为什么方法表被称为虚表的原因之一。数据属性表则是完完全全、结结实实、妥妥当当地存在于类型定义的内存空间内的。
一个类型的对象实例全都是以类型定义为模板构造出来的,其内存构造自然与类型定义完全一样。我们可以这样理解其内存结构,一个对象实例的内存结构就是一个数组结构,里面有N(N >= 1)个条目。前面N-1个条目都是数据属性表的条目,里面存放着数据信息。最后一个条目(第N个条目)中存放着一个方法表的地址。那个方法表存放在内存中的另外一个地方,那个方法表也是数组结构,里面存放着方法实现代码的内存地址。
为了简化起见,我们也可以这样理解静态类型对象的内存结构:一个对象内部有两个数组结构的表格,一个叫做数据属性表,一个叫做方法表。用Java伪代码描述就是这样:
class ObjectSpace{
  ArrayTable dataTable;
  ArrayTable methodTable;
}
由于Java语言是静态类型,可以在变量前面声明类型。因此,很适合用在这里表述数据结构。可见,在某些情况下——比如,在我们关心数据结构类型的时候——静态类型语言的描述性和可读性是相当好的,这方面远远超过了不用声明类型的动态语言。这就是,寸有所长,尺有所短。
以上这种写法只是为了方便理解。实际上,dataTable在ObjectSpace是一条条铺开的(这样做是为了提高数据属性的访问效率)。而methodTable在ObjectSpace中只是一个地址引用,表示一个公共的方法表的内存地址。
现在,我们再来看Python和Ruby的类型定义和对象实例的内存结构。同静态类型一样,动态类型的内存结构也分为两个部分,数据属性表和方法表。而且,这两个表格全都“表现得”如同Hash Table——解释器可以通过名字轻易地定位到对应的数据属性或者方法。动态类型对象的内存结构用Java伪代码描述就是这样:
class ObjectSpace{
  HashTable dataTable;
  HashTable methodTable;
}
同静态类型对象一样,每一个动态类型对象实例都拥有自己独有的一份dataTable,有多少个对象实例,就有多少份dataTable。而methodTable就只是一个地址引用,表示一个公用的方法表的内存地址。
由于每一个动态对象实例都拥有一份dataTable,而且,这个dataTable的表格大小,以及表格字段名称(即变量名)基本上是固定不变的。因此,对这个dataTable进行空间优化,很有意义。
我们可以采取前面描述过的优化思路,把真实的dataTable存放在一个紧凑的数组结构中,然后,提供一套类似于Hash Table的查询方法,能够把数据属性的名字映射到数组结构中的位置,这样,我们就能够访问到真正的数据属性。
至于methodTable,每个类型只有一份,优化亦可,不优化亦可。不过,如果优化了dataTable,还不如一起把methodTable一起优化了,反正优化方案都是一样的,何乐而不为。
Javascript对象的内存结构又是如何呢?我们同样可以用Java伪代码来表述:
class ObjectSpace{
  HashTable memberTable;
}
在Javascript对象里,数据属性和方法的地位是相同的,都放在同一张Hash Table里。而且,这个Hash Table的大小和内容是随时可以扩充和修改的。对这样一个结构进行空间优化,难度大且不说,而且没有多大的意义。
看到这里,一些求实的读者可能会问:你前面讲的那种空间优化思路,是一种通用的做法,还是你自己的凭空想象?
我得承认,这是我自己的臆测和猜想。不过,我的这种猜想是有依据的。
我们来看一个鲜活的真实例子:微软COM组件的双接口(dual interface)——IUnknown和IDispatch。
通过这两个接口的比较,我们可以清晰地看到静态类型方法表和动态类型接方法表的鲜明对比,而且,我们还可以从IDispatch的接口设计中窥见上面讲述的空间优化思路。
COM是Component Object Model(组件对象模型)的缩写,是微软提出的一套组件编程规范。COM是一套二进制的组件接口规范。
什么叫做“二进制的”呢?意思就是,COM组件对象是真正的可执行的二进制代码(即目标代码,汇编代码,机器代码),而且,还把二进制的编程接口(这里指的就是二进制对象的虚表VTable)直接提供给程序员使用。
这种二进制方案的优点不言而喻,那就是运行效率。中间不存在任何通信协议的编码和解码,一切都和直接调用本程序内定义的对象方法一样。
COM组件提供的二进制接口(即组件类的虚方法表——VTable)叫做IUnknown,其中提供了三个基本方法。其中两个方法AddRef()和Release()是用来管理引用计数和内存释放的。
由于COM组件基本上都是用C++实现的,而C++不支持内存自动回收。所以,组件的提供方和调用方就需要通力合作,一起支持组件内存的正确释放。
由于这个机制与虚拟机内存自动回收机制有异曲同工之处。这里就花点笔墨解释一下。
调用方在获取组件之后,首先就要调用addRef(),表示自己引用了组件,敬告该组件:请不要随便释放组件内存,还有人用着呢。
调用方完成了对组件的使用之后,不打算再使用这个组件,就调用一下release(),对组件表示:我已经用完了,你愿意释放就释放吧。
组件内部管理着一个引用计数,表示当前用户的数量。当用户数量为零的时候,组件就可以释放自身内存了。
在这个过程中,所有的调用方和组件方都需要精密合作,一个地方出了差错,组件就不能正确释放了。
AddRef()和Release()这两个方法的用法基本上就是这样。我们来看IUnknown接口的最重要的方法——QueryInterface。这个方法用来询问组件是否支持某种接口。其用法有些类似于Java语言的instanceof关键字。
在Java语言里面,我们可以用instanceof来询问某个对象是否某种类型,当然也可以用来询问某个对象支持某种接口。比如:if(obj instanceof Comparable)这条语句就是询问obj这个对象是否支持Comparable接口。
COM组建的QueryInterface方法的用法有些相似。由于QueryInterface的真正用法比较繁琐,里面还涉及到组件ID和Windows注册表的知识,我这里就给出一个示意用法。我们这样写,comObj.QueryInterface(“Comparable”),意思就是询问这个组件是否支持Comparable接口。
通过QueryInterface这个方法,程序员可以获得自己真正需要的组件接口。
COM组件的实现方案非常丰富。一个COM组件可能包含其他的组件,并可以向用户提供这个组件的接口。这是代理模式(Proxy Pattern,Delegate Pattern)。
一个组件还可能包含多个其他的组件,并可以向用户提供多种组件接口。
有读者可能会抢答:我知道了,这是Composite Pattern(组合模式)。
但是,很遗憾,抢答错误。这种方案看起来像是Composite Pattern(组合模式)。但是,按照设计模式对应的场景和用法,这种方案还是代理模式。(哈哈,开个小玩笑。)
着迷于设计模式的读者不要失望,这种方案中还是存在一种新的设计模式的——Façade Patten。不过,这种设计模式没什么好说的。就是把原有的接口方法选出一部分,并据此重新定义一个接口(其中的方法签名也可能进行了一定得修改),然后再提供给用户。另外,我们可以从COM组件设计中找到更多的设计模式。比如,工厂模式(Factory Pattern)。在COM组建的用法中,组件都是由组件工厂来创建的。
我在前面的章节中,并没有提到工厂模式(Factory Pattern),原因在于,在现代的程序设计潮流中,工厂模式(Factory Pattern)不那么吃香了。人们不再热衷于从工厂中获取服务对象,而是倾向于使用IoC Container(对象关系构造容器)来提供程序需要的服务对象。
IUnknown是一个二进制接口(数组结构的虚表),其queryInterface方法获取的也是一个二进制接口,也就是说,也是一个虚表。如果调用方是C++语言的话,那么调用起这种虚表自然是得心应手。但是,如果调用方不是C++语言该怎么办?
如果调用方不是C++语言,可能会产生如下的问题:调用方语言是动态语言,无法直接使用虚表“VTable”结构;调用方语言与C++语言的数据类型不能直接匹配。
为了解决这些问题,COM组件规范又引入了一个新的接口IDispatch。这个接口主要提供了如下功能:能够根据一个名字找到组件虚方法表中的对应方法的ID;提供类型信息库,调用方可以根据这个类型信息库准备正确类型的数据。
IDispatch接口有一个叫做GetIDsOfNames的方法,能够根据方法名找到对应的方法ID(代表着该方法在虚方法表中的位置)。为了提高查询效率,GetIDsOfNames方法允许调用方程序一次查询多个方法名。
根据方法名获得了方法ID之后,调用方就可以调用IDispatch接口的invoke方法来调用对组件中对应的方法了。这个过程实际上就相当于通过方法名来调用方法。这是动态类型语言进行方法调用的做法。
通过GetIDsOfNames这个方法的定义,我们可以窥见其内部实现的常见策略。真正的方法表应该是紧凑的数组结构。GetIDsOfNames这个方法就是一种类似于Hash Table的查询接口,通过方法名,返回方法在方法表中(数组结构)的位置(数组下标)。
由此可见,我之前主观臆测的空间优化方案并非空穴来风,而是确有其事。
如果说IUnknow代表了静态类型的接口的话,那么,IDispatch就代表了动态类型的接口。不过,需要注意的是,IDispatch接口本身仍然是一个二进制接口,这个接口是COM组件实现的,而COM组件通常是C++语言实现的。因此,IDispatch接口通常也是C++语言实现的。
如果调用方是动态语言的话,那么,调用方并不能直接使用IDispatch接口,而是需要一个本语言的包装类,来包装对IDispatch接口的调用。这个包装类同时也负责对两种语言的数据类型的对应和包装。这也是异种语言之间相互调用的常用做法。
相关标签: 编程