|
|||||||
![]() |
![]() |
||||||
|
|||||||
|
面向对象的程序设计本文最初由 Andrew McKewan 编写,原文的版权归他所有。 概念Win32Forth 使用 MOPS metaphor 进行面向对象的程序设计。这就是说,您可以定义类用于创建对象。类定义的数据对于它创建的每一个对象来说是独立的,而方法为它所创建的对象共用。这里就是一个简单的类的例子: :Class Disk 创建和删除动态对象到目前为止,我们讨论的都是静态的对象,驻留在应用字典中的对象总是占据空间。另一个创建对象的办法是在 ALLOCATE 分配的空间中创建。 Win32Forth 提供一个简单的实现办法,如下所示: NEW> Disk ( -- a1 ) 这就通过为对象分配空间而创建了一个类 Disk 的对象。 Forth 接着在对象上执行 ClassInit: 方法,并在 Windows 的堆上返回对象的地址 a1 。之后,对象的地址可以保存在一个变量、值或者数组中为后面的应用程序使用。为了执行对象上的一个方法,您必须像下面这样把它的地址放到一个变量或者值中: 0 value DiskObj : makeObj ( -- ) NEW> Disk to DiskObj ; makeObj \ make a Disk Object FreeBytes: DiskObj \ -- freebytes 您可以根据需要创建任意多的动态对象,这只受限于可用的存储器空间。如果您不再使用一个对象,则可以这样去除: DiskObj Dispose \ dispose of the object 0 to DiskObj \ clear the object holder 当您不再使用一个动态创建的对象时,把它去除总是一个好的想法。一个特殊的方法 ~: ( 波浪冒号 ) 在存储器释放之前将被自动执行,它使您有机会在对象被破坏之前进行清除工作。方法 ~: 在类 Object 中被定义为空操作,但是在您动态创建一个新的对象时,可以根据需要进行任何的重新定义。 如果您在程序结束之前忘记了去除您创建的动态对象,则 Forth 将在程序结束时自动释放为它们分配的存储器空间。 在类中构建相邻的数据字段有时,如果能在类中定义一系列的数据对象并使它们在存储器中相邻存放是很有用的。比如您想定义一个传递给 Windows 过程调用的数据结构。通常 Forth 在类中放置数据项目时会为对象附加一个单元的类指针,这能够方便地进行反编译和调试程序。如果您不关心前面描述的连续数据结构对方法调试的限制,则您就可以像下面这样用 Win32Forth 来创建: 下面是一个 C 语言数据结构的例子: typedef struct _WIN32_FIND_DATA { :Class DirObject <super 如果在 Forth 命令行上打入最后定义的字,它将显示当前目录下的全部 *.F 文件名。 注意:上面的代码可以通过下面的办法装入 Win32Forth 中。加亮您想装入的行,然后按 Ctrl+C ( 或者使用 Edit 菜单的 "Copy" ) 复制文本 , 再选择 Win32Forth 控制台窗口,按 CTRL+V ( 或者使用 Edit 菜单的 "Paste to Keyboard" ) 粘贴文本进入 Win32Forth 。文本每次一行地粘贴进 Win32Forth, 就像您通过键盘输入的一样。之后,您只需要简单地输入 SIMPLEDIR [enter] ,您就会看到程序显示出当前目录下的 Forth 文件列表。 进一步的说明使用 ANS Forth 进行面向对象的程序设计 [Andrew McKewan]本文描述了一个为 Forth 所做的面向对象扩展的使用和实现。这个扩展符合 Yerk 和 Mops 的语法,但它是用 ANS Standard Forth 实现的。 我为什么这样做当开始用 Forth 为 Windows NT 编写程序时,我就担心这个环境的极端复杂性。在寻找克服这个复杂性的办法时,我开始研究使用 Yerk 面向对象的 Forth 。 Yerk 是 Macintosh 的 Forth 系统,是最早以 Neon 为名推向市场的商业产品。它实现了一个使您能够为 Macintosh 编写面向对象程序的环境。 虽然 Yerk 的许多部分是 Macintosh 相关的,其底层的 class/object/message 思想却非常普通。我把这些移植进 Win32Forth -- 一个用于 Windows NT 和 Windows 95 的公共域 Forth 系统。 然而,无论是在 Yerk 还是在 Win32Forth 中,大多数的系统核心部分都是用汇编语言编写并且是机器相关的。此外,两个系统都修改了外层解释器以适应新的语法。 我的期望是为任何 ANS Forth 系统在这些系统相关的平台上提供使用面向对象语法和程序设计风格的能力。在此目标之下,我牺牲了一些性能和一些特点。 面向对象的概念面向对象的模型来自于 Smalltalk 。首先我要描述在这个模型中使用的名字:对象、类、消息、方法、选择子、实例变量和继承。 Objects 对象是用于构建程序的实体。对象含有不能从外部访问的私有数据,与一个对象进行通信的唯一方式就是给它发送一个消息; Message 消息由选择子(一个名字)和参数组成。当一个对象接收到这个消息之后,它就执行对应的方法。这个方法的参数和结果都是在 Forth 堆栈上传递的; Class 类是创建对象的模板。类为对象描述了实例变量和方法。一但定义了类,您就可以创建许多相同的对象。每个对象都有各自的实例变量的副本,但是共享相同的方法代码; Instance variables 实例变量是属于对象的私有数据,实例变量可以被对象中的方法访问,但是不能从对象的外面看到。实例变量自己也可以是带有私有数据和公有方法的对象; Method 方法是对应消息执行的代码。它们与普通冒号定义类似,但是有一个特殊的语法,使用字 :M 和 ;M 定义。您可以在方法中放置任何的 Forth 代码,包括向其它对象发送一个消息; Inheritance 继承使得您可以定义一个类,它作为另一个被称为超类的类的子类。这个新的类“继承”了超类所有的实例变量和方法,您还可以给这个新类添加实例变量和方法。如果您仔细地设计类的层次,就可以大大减少所需要编写的代码; 如何定义一个类下面这个类 Point 解释了定义一个类所使用的基本方法:
类 Point 从类 Object 继承。 Object 是所有类的根,它定义了一些常用的行为(比如,得到一个对象的地址或者得到它的类),但是没有任何实例变量。所有的类都必须从超类继承。 下一步我们定义两个实例变量 , x 和 y 。它们都是类 Var 的实例。 Var 与 Forth 变量相似,是一个基本单元大小的类。它有方法 Get: 和 Put: 以访问它的数据。 类 Point 的方法 Get: 和 Put: 按一对整数来访问它的数据。它们通过向实例变量发送 Get: 和 Put: 消息而实现。 Print: 打印 x 和 y 的坐标。 ClassInit: 是一个特殊的初始化方法。只要对象被创建,系统就会向它发送一个 ClassInit: 消息。这就允许对象执行任何的初始化功能。这里我们把变量 x 和 y 初始化成当前值。只要创建了一个点,它就被初始化成这些值。这与 C++ 的构造器很相似。 并不是所有的类都需要 ClassInit: 方法。如果一个类没有定义 ClassInit: 方法,它就有一个类 Object 中的方法,只是它不做任何事情。 创建一个类的实例现在我们定义了 Point 类,让我们来创建一个点: Point myPoint 正如您所看到的那样, Point 是一个定义字。它创建一个 Forth 定义,称为“ myPoint ” 。 让我们看看它的内容: Print: myPoint 这将在屏幕上打印出文本 "x = 1 y = 2" 。您也可以看到新的点通过 ClassInit: 消息被初始化了。 现在我们可以修改 myPoint 并查看新的值: 3 4 Put: myPoint Print: myPoint 注意在 Point 的定义中,我们创建了两个实例变量。对象定义字是“ class smart ”: : 如果在一个类的内部使用则将创建一个实例变量,如果在一个类的外面使用则定义全局对象。 发送消息给自己在定义 Print: 中,我们使用短语“ Get: self ” 。这里我们正在发送 Get: 消息给我们自己。 Self 是一个名字,它引用当前对象。编译器将编译一个对 Point 的 Get: 方法的调用。与此相似,我们可以像下面这样定义一个 ClassInit: 方法: :M ClassInit: 1 2 Put: self ;M 这是一个 Forth 中常用的因子化技术,在这里也同样适用。 创建一个子类比如说我们想要一个像 myPoint 这样的对象 , 它只是用不同的格式打印自己。 :Class NewPoint <Super Point :M Print: ( -- ) Get: self SWAP 0 .R ." @" . ;M ;Class 子类继承了它的超类的所有实例变量,还可以加入它自己新的实例变量和方法,或者重载在超类中定义的方法。我们可以测试: NewPoint myNewPoint Print: myNewPoint 这将打印出 "1@2" ,它是 Smalltalk 打印点的格式。我们改变了 Point 的 Print: 方法,但是保留了其它所有的行为。 给超类发送消息在某些情况下,我们并不想替换一个方法,只不过是想加入一些事情。下面这个类总是在一个新的行上打印它的值: :Class CrPoint <Super NewPoint :M Print: ( -- ) CR Print: super ;M ;Class CrPoint myCrPoint Print: myCrPoint 当我们使用短语“ Print: super ” 时,我们告诉编译器发送我们的超类中定义的打印消息。 索引化的实例变量类 Point 有两个命名的实例变量“ x ” 和“ y ”。 当类被定义之后,其中的命名实例变量的类型和数目就固定了。对象也可以包含索引化的实例变量。它们通过一个以 0 为基的索引进行访问。每个对象可以定义不同数目的索引化变量,每个变量的大小通过字 <Indexed 在类的开始定义: :Class Array <Super Object CELL <Indexed At: ( index -- value ) (At) ;M To: ( value index -- ) (To) ;M ;Class 这样我们就声明了一个 Array ,它将包含索引化的实例变量,其大小与 CELL 占用的字节数相同。为了定义一个数组,把它的元素数目放到类名之前: 10 Array myArray 这将定义一个 Array ,它有 10 个元素,编号从 0 到 9 。我们可以使用 At: 和 To: 方法访问数组中的数据。 4 At: myArray . 64 2 To: myArray 索引化的实例变量允许创建数组、表和其它的集合数据结构。 前期还是后期绑定在这些例子中,您可以想到“所有的消息发送都需要花费很多时间”。为了执行一个方法,对象必须查找消息,先在类中、再到它的超类中,直到找到为止。 但是,如果对象的类在编译时是已知的,编译器就进行查找,并编译这个方法的可执行标记(代码域地址)。这被称为“前期绑定”。这样在调用这个方法的时候虽然还有些开销,但开销已经很小了。在我们上面见到的全部代码中,编译都是进行前期绑定的。 在另一些情况下,您需要在运行时刻进行查找。这被称为“后期绑定”。一个例子就是:您有一个 Forth 变量,它含有一个对象的指针,可是这个对象的类到了运行时间才能知道。语法是这样的: VARIABLE objPtr myPoint objPtr ! Print: [ objPtr @ ] 在上面的中括号里,表达式必须处理一个对象的地址。编译器识别出中括号并在运行时查找消息。 ( 不必担心,我并没有重新定义“ [ ” 或者“ ] ”。当一个消息选择子识别出左中括号之后,它使用 PARSE 和 EVALUATE 编译中间代码,然后编译一个后期绑定消息的发送。这也能够在解释状态下工作。 ) 类绑定类绑定( Dave Boulton 把它称为“ promiscuous binding ”)是一种优化,如果我们有对象指针或者对象是通过堆栈传递的,则使用类绑定能够得到早期绑定的性能。如果我们用一个名字来使用选择子,编译器将前期绑定这个方法,并假设那个类的对象在堆栈上。所以我们可以这样来编写一个打印点的字: : .Point ( aPoint -- ) Print: Point ; objPtr @ .Point 它将对调用进行前期绑定。如果您传递了除 Point 以外的任何东西,那么不会得到期望的结果(不论传递什么,它都会打印这个对象的前两个单元的内容)。这是一种优化技术,应该在程序完全调试好之后再使用。 在堆上创建对象如果系统能够进行动态存储分配器,则程序员就会想到在运行的时候借助堆来创建对象。例如,可能是这种情况:程序员不知道用户应用程序应该创建多少个对象。 在堆上创建对象的语法是: Heap> Point objPtr ! Heap> 将返回新的点的地址,它可以被保存在堆栈上或者变量中。为了释放这个点和它的存储器空间,我们使用: objPtr @ Release 在存储器被释放之前,对象将收到一个 Release: 消息,它可以进行所需要的清除工作(比如释放其它的实例变量等)。这与 C++ 的析构类似。 实现当前对象的地址保存在值 ^base 中。 ( 在一个本地 native 实现的系统中,这将是处理器寄存器的好用途之一 ) 你唯一能够使用 ^base 的情况是在一个方法中。只要一个方法被调用, ^base 就被保存,并用被发送消息的对象的地址装入。当这个方法退出的时候, ^base 就被恢复。 类结构所有的偏移量和大小以 Forth 单元为单位。 Offset Size Name Description 0 8 MFA Method dictionary (8-way hashed list) 8 1 IFA Linked-list of instance variables 9 1 DFA Data length of named instance variables 10 1 XFA Width of indexed instance variables 11 1 SFA Superclass pointer 12 1 TAG Class tag field 13 1 USR User-defined field 前 8 个单元是方法的 8 路散列列表。从方法选择子来的 3 位数据将决定方法可能在哪个列表中。这减少了后期绑定消息的查找时间。 IFA 字段是一个命名实例变量的列表,表中的最后两个项总是“ self ”和“ super ”。 DFA 字段是这个对象命名的实例变量的长度。 XFA 字段有两个作用。对于有索引化实例变量的类来说,它包含有每个元素的宽度。对于非索引化类,这个字段通常是 0 。一个特殊的标记 -1 标识一般类(见下面)。 TAG 字段含有一个特殊的值来帮助编译器确定是否一个结构真的表示一个类。在 native 实现中,一个单一的代码域用于标识类,但是这在 ANS Forth 中是不可用的。 USR 字段不为编译器使用,它只保留给程序员使用。未来我可能扩展“类变量”的概念,以允许加入类结构。在 Windows 实现中,这个字段用于存储这个类将要响应的窗口消息的列表。 对象结构Offset Size Description 0 1 Pointer to object's class 1 DFA Named instance variable data DFA+1 1 Number of indexed instance variables (if indexed) DFA+2 ? Indexed instance variables (if indexed) 一个全局或者基于堆的对象的第一个字段是指向类的指针。这允许我们能够进行后期绑定。通常,类字段不存储实例变量。这不但节省了空间而且并不经常使用,因为编译器知道实例变量的类,而实例变量在类之外是不可见的。对于索引化的类,总是存储类指针,因为类含有定位索引化变量的信息。此外,程序员可以把一个类标记为“一般的”( general )以要求存储类的指针,这在对象向自己发送后期绑定消息时需要(比如 msg: [ self ] )。 当一个对象执行的时候,它返回第一个命名的实例变量的地址。我们把这个地址作为“对象地址”来引用。这个字段含有命名的实例变量的数据,因为实例变量又是对象自己,这种结构可以无限嵌套。 含有索引化实例变量的对象还有另外两个字段。索引化的首部包含索引化实例变量的数目。索引化变量的宽度存储在类结构中,这也是为什么我们必须为索引化的类存储一个类指针。 在索引化首部的后面是索引化的数据。这个区域的大小是索引化变量的宽度和数目的乘积。有一些原语定义可以访问这个数据区域。 实例变量结构Offset Size Name Description 0 1 link points to link of next ivar in chain 1 1 name hash value of name 2 1 class pointer to class 3 1 offset offset in object to start of ivar data 4 1 #elem number of elements (indexed ivars only) LINK 字段指向类中的下一个实例变量。这个链表的首部在类中的 IFA 字段里。当创建一个新的类的时候,所有的类字段都从超类中复制过来,这样新的类中一开始就有超类的所有实例变量和方法。 NAME 字段含有从实例变量名中计算来的散列值。如果按字符串来存储,将占用很多空间和编译时间,而且一个好的 32 位散列函数并不经常产生冲突。如果您使用了一个与前面相同的名字,编译器将退出。您可以更改实例变量的名称或者改进散列函数。 在 NAME 之后是一个指向实例变量类的指针。编译器总是前期绑定发送给实例变量的消息。 offset 字段含有实例变量在这个对象中的的偏移量,当向一个对象发送消息的时候,这个偏移量与当前对象地址相加。 如果实例变量是索引化的,接下来存储元素的数目,这个字段在非索引化类中没有使用。 与对象不同,实例变量并不在 Forth 字典中保存一个名字。与此对应,您不能执行它们以得到其地址,您只能向它们发送消息。如果您需要地址,则可以使用在类 Object 中定义的 Addr: 方法。 方法结构方法存储在从 MFA 字段而来的 8 路链表中。每个方法通过 32 位选择子标识,它是消息选择子的参数域地址。 Offset Size Description 0 1 Link to next method 1 1 Selector 2 1 Method execution token 方法的代码通过 Forth 字 :NONAME 创建。在本实现中它不含特殊的进入前置( prolog ) 或者退出( epilog )代码。当方法被执行的时候,当前的对象将在 ^base 中。方法通过下面的字来执行,它保存当前的对象指针、从堆栈将入对象、调用方法、恢复指针。 : EXECUTE-METHOD ( ^obj xt -- ) ^base >R SWAP TO ^base EXECUTE R> TO ^base ; 当方法被编译进定义的时候,对象和执行标记作为常量跟随在 EXECUTE-METHOD 之后。 这就意味着调用一个方法比调用通常的冒号定义要有更多的开销(这是我对 ANS Forth 作出的一个让步。在 native 版本中,一个快速的 CODE 字放在方法的开始和结尾处,其开销可忽略不计)。 当一个消息被发送给一个实例变量之后,方法的执行标记和变量的偏移量作为文字量编译到 EXECUTE-IVAR 之后 : EXECUTE-IVAR ( xt offset -- ) ^base >R ^base + TO ^base EXECUTE R> TO ^base ; 在偏移量为 0 时,可以进行优化(对于发给自己和超类的消息或者第一个实例变量)。因为我们不需要改变 ^base ,我们只需要直接编译可执行标记。 使用特殊字的选择子在 Yerk 的实现中,解释器被改变了(通过向量化 FIND ) 以使得它能够自动地把以 “ : ” 结尾的字识别成一个消息。它通过消息的名字计算一个散列值,并用它作为选择子。这种方法使得字典很小。 在 ANS Forth 中,没有办法修改解释器(编写一个短而新的解释器)。人们总是在争论是不是有更好的“东西”。 在本实现中,消息选择子是 Forth 的立即字。它们在方法定义中第一次使用之前被自动定义。由于它们是单一的字,我们能够使用参数域作为选择子。 当一个选择子执行的时候,它编译或者执行代码以发送一个消息给后面的对象。如果在一个类中使用,它首先查看这个字是不是一个命名的实例变量。如果不是,就查看它是不是一个合法的对象,最后看它是不是一个类的名字并实现类绑定。 Yerk 也允许发送消息给一个值或者局部变量,并自动编译后期绑定调用。在 ANS Forth 中, 我们不能从它们的执行标记中得到任何信息,所以这个特性没有实现。我们可以通过显式的后期绑定达到同样的效果: Message: [ aValue ] 对象初始化当一个对象被创建的时候,它必须被初始化。对象的存储空间被清 0 ,类的指针和索引化的首部被建立,之后每个命名的实例变量被初始化。 这是通过递归字 ITRAV 实现的。它得到一个实例变量结构的地址和一个偏移量以及后面的链,初始化每个命名的实例变量并发送一个 ClassInit: 消息。在运行时,它递归地初始化实例变量的实例变量,如此等等。 最后,对象发送一个 ClassInit: 消息。当一个对象从堆中创建时,也将执行同样的过程。 示例类我已经实现了一些简单的类作为您自己类库的基础。这些类都有与预先定义的 Yerk 和 Mops 相似的名字。类实现和示例类的代码可以从下列地址得到 Fig ftp site . 结论对于我来说,使用对象的最大收益来自于对复杂性的管理。对象是很小的数据包,它们理解程序其它部分发给它的消息。把实现的细节保持在对象中,它就能够对程序的其它部分表现出更简单的性质。继承可以减少您必须编写的代码的数量。如果一个成熟的类库可用,您还会经常发现所需要的功能早已经在那儿了。 如果 Forth 社区能够接受面向对象的模型,我们可以用汇编编写一个面向对象的 Forth 库,这个库与 Skip Carter 领导的 Forth Scientific Library 项目类似,代码和工具都是 Forth 程序员能够共享的。那个项目在 ANS 浮点标准化之前是不可能的。 不幸的是,有许多不同方法可以向 Forth 中加入对象。只要看看《 Forth Dimensions 》上过去十年间关于面向对象编程的文章数量就可以明白了。因为 Forth 是如此容易(和娱乐般地)地进行扩展和修改,所以每个人都在走着自己(不同的)路。 作者 : Andrew McKewan |
|||