8.4 关于类的实现
本节将要介绍的是在类的实现中除了继承和多态的部分。
8.4.1 语法规则
Diksam在声明变量的时候和Java相同,形式如下所述:
类型 变量名;
我们在6.1.5节中曾提到,有些语言是将类型写在后面的。
// 以ActionScript为例
var a:int;
将类型写在后面的语法结构在制作语法分析器时会非常轻松,但是在Diksam中,使用了C和Java程序员习惯的语法结构,即将类型写在前面——这么做了才知道,这是一条坎坷的路。
如果以“类型 变量名;”的语法进行声明,在一开始就能得到“类型”,从而一下子就可以知道这是一条声明语句,但也只有 int和 double可以作为保留字, Point之类的类名字就不能够成为保留字了。因此,只看这个是不能知道类名字的。yacc虽然会预读一个符号,但是,例如下面这样的数组声明,
Point[] p;
即使是预读了 Point后面“[ ”也不能搞清以下两点:
是数组型变量 Point要通过 []引用元素?
还是要声明 Point类的数组?
在C中,要想使用类型Point,就必须要在使用的地方前面声明这个类型。在这种语言中使用的处理方式是:在声明的时侯把类型由解析器传递给词法分析器,词法分析器随后把它登记为标识符,之后的Point就会被当做类型名称来处理。但是,作为一门当下的编程语言来说似乎不太雅致。在C语言中,为了表现诸如 Husband引用了 Wife, Wife又引用了 Husband这样的相互引用,不得不使用“预先声明结构体标签”的怪异方法。
因此,Diksam的语法规则如下:
declaration_statement
: type_specifier IDENTIFIER SEMICOLON ←省略了初始化等操作
;
type_specifier
: basic_type_specifier ←表示int、boule等基本类型
| array_type_specifier
| class_type_specifier ←表示类名
;
array_type_specifier
: basic_type_specifier LB RB
| IDENTIFIER LB RB
| array_type_specifier LB RB
;
class_type_specifier
: IDENTIFIER
;
看了上面这段代码,可能有人会想了:
等等。刚才不是说即使预读了Point后面的一个符号也搞不清楚它到底是变量名还是类名吗?但是在这个规则中不是很明显地告诉你如果只有一个IDENTIFIER的话就是class_type_specifier吗?
这个语法规则的重点在于引入了 array_type_specifier。如7.2.1 节中所述,在引入类之前,数组声明语法如下:
type_specifier
: basic_type_specifier
| type_specifier LB RB
;
在引入了类之后,我认为与 basic_type_specifier一起处理会更好。
type_specifier
: basic_type_specifier
| IDENTIFIER ←增加了这行代码
| type_specifier LB RB
;
但是,还是像前面说的,在预读了 IDENTIFIER后的“[ ”后,yacc就会因为不知道是把它当做要引用数组元素继续shift好,还是当做 type_specifier进行reduce好而发生错误。顺带提一下,引用数组元素的规则如下:
primary_no_new_array
: primary_no_new_array LB expression RB
| IDENTIFIER LB expression RB
;
不过,引入了 array_type_specifier 后,即使预读到了“[ ”也不会进行归约,因而也不会进行归约/归约冲突和移进/归约冲突。当然,现在还是搞不清楚到底是在 array_type_specifier 中,还是在 primary_no_new_array中。在看到了代码清单2-5的y.output的后半部分中一并记载了多个规则就能明白,yacc的状态可以跨越多个规则,并不会引发问题。
8.4.2 编译时的数据结构
函数在编译时被保存在 FunctionDefinition 结构体中,然后被复制到 DVM_Executable 中的 DVM_Function 结构体。类保存在编译时的数据类型 ClassDefinition和 DVM_Executable的 DVM_Class中。
对于从开头一直读到这里的读者,我想就没有必要太过详细地介绍了,还是一笔带过吧。
首先是编译时的数据结构 ClassDefinition。
struct ClassDefinition_tag {
DVM_Boolean is_abstract;
DVM_AccessModifier access_modifier;
DVM_ClassOrInterface class_or_interface;
PackageName *package_name;
char *name;
ExtendsList *extends; ←fix之前的临时数据结构
ClassDefinition *super_class;
ExtendsList *interface_list;
MemberDeclaration *member; ←成员
int line_number;
struct ClassDefinition_tag * next;
};
这个结构体中值得注意的地方是,成员 extends 是一个在create.c 中被构建后,又在fix_tree.c中被抛弃的临时数据结构。在Diksam中,继承类和接口时,没有使用Java的 extends和 implements风格,而是使用了C++和C#的冒号,因此(一开始)无法区分类和接口。这里先姑且加入一个 extends 成员,之后将在 fix_tree.c 中分为 super_class 和 interface_list(fix_extends()函数)。
member保存了类的成员。类成员的字段和方法以共用体的形式被保存在 MemberDeclaration结构体中。
struct MemberDeclaration_tag {
MemberKind kind;
DVM_AccessModifier access_modifier;
union {
MethodMember method;
FieldMember field;
} u;
int line_number;
struct MemberDeclaration_tag *next;
};
先看一下字段的定义。
typedef struct {
char *name;
TypeSpecifier *type;
int field_index;
} FieldMember;
name和 type表示字段的名称和类型。
这里要稍稍介绍一下 field_index。在DVM中,各个对象的字段的数据都以 DVM_Value 数组的形式保存。这里的 field_index 就是这个数组的下标,在f ix_tree.c中进行设置。
继承类的时候,超类的字段会由子类继续持有(如图8-5)。MemberDeclaration结构体的列表只含有当前类的成员,并不包含超类的成员,但 field_index却和超类的字段含有的通用的编号。
在Diksam中就是像这样,在编译时指定字段索引值。这是因为Diksam中并没有相当于Java的class文件这样的产出物。如果字节码保存在文件中的话,类中的字段增加,索引值也可以在文件中随之增加,但是却做不到上面的方法。在Java的字节码中指定的并不是字段的索引值,而是字段的名称。
接下来是方法。
typedef struct {
DVM_Boolean is_constructor;
DVM_Boolean is_abstract;
DVM_Boolean is_virtual;
DVM_Boolean is_override;
FunctionDefinition *function_definition;
int method_index;
} MethodMember;
首先 method_index和 field_index一样,是一个包含了超类方法的通用编号。但是,在实现了接口方法的时候,这个方法的 method_index只在接口之间通用。换句话说,它就是vtable的下标(如图8-8)。
另外,如代码所见, MethodMember中包含了指向 FunctionDefinition的指针。就是说,对于Diksam来说,方法也不过就是函数(稍有不同),它被注册在 FunctionDefinition中的同时也会创建 DVM_Function。
FunctionDefinition中增加了从方法能够追溯到类的 ClassDefinition的指针。如果这个成员为 NULL的话,就说明它不是方法而只是普通的函数。
struct FunctionDefinition_tag {
···前面省略···
ClassDefinition *class_definition; ←指向类的指针
···后面省略···
};
8.4.3 DVM_Executable中的数据结构
编译完成后就要生成 DVM_Executable了,这里使用了 DVM_Class结构体来保存 DVM_Executable中的类。
DVM_Class在 DVM_Executable中以下面这种可变长数组的形式保存。
struct DVM_Executable_tag {
···前面省略···
int class_count;
DVM_Class *class_definition;
···后面省略···
};
DVM_Class结构体的定义如下所示。
typedef struct {
DVM_Boolean is_abstract;
DVM_AccessModifier access_modifier;
DVM_ClassOrInterface class_or_interface;
char *package_name;
char *name;
DVM_Boolean is_implemented;
DVM_ClassIdentifier *super_class;
int interface_count;
DVMClassIdentifier *interface;
int field_count;
DVM_Field *field;
int method_count;
DVM_Method *method;
} DVM_Class;
上面的代码中出现了 DVM_ClassIdentifier,它是由包名和类名组成的类型。这个类型可以保存超类和(实现了的)接口。
字段和方法都是以可变长数组的方式保存的。 DVM_Class中保存的只有当前类中的定义,并不包含超类的部分。
保存字段的 DVM_Field的定义如下。其中成员所表示的含义都显而易见。
typedef struct {
DVM_AccessModifier access_modifier;
char *name;
DVM_TypeSpecifier *type;
} DVM_Field;
方法也是一样。
typedef struct {
DVM_AccessModifier access_modifier;
DVM_Boolean is_abstract;
DVM_Boolean is_virtual;
DVM_Boolean is_override;
char *name;
} DVM_Method;
并且,方法“差不多”是普通的函数,只是在动作上有细微的不同,因此在 DVM_Function中保存了它是不是方法的标识符(下面的 is_method)。
typedef struct {
DVM_TypeSpecifier *type;
char *package_name;
char *name;
int parameter_count;
DVM_LocalVariable *parameter;
DVM_Boolean is_implemented;
DVM_Boolean is_method; ←增加了这个成员
· · ·后面省略· · ·
} DVM_Function;
方法的函数名以 类名#方法名 的方式保存在 DVM_Function的 name成员中。例如 Point类的 print()方法,在 DVM_Executable的时候也可以看作是名称为 Point#print、 is_method为 true的普通函数。当然,解析器是不允许函数名中包含 #的,因此,使用者即使自己写了名为 Point#print()的函数,也不能在自己的程序中通过 Point#print()的方式调用。
8.4.4 与类有关的指令
随着引入了类的概念,DVM中也增加了相关的指令,如表8-2所示。下面的 object型为字符串、数组、类对象的总称(在表7-1中为字符串和数组的总称,这次增加了类的对象)。
表8-2 随着引入类而增加的指令
(续)
访问字段的指令,把字段的索引值作为操作数传递就可以了。这里的字段值指的是保存在 FieldMember结构体中的 field_index。
除此之外的指令将在后面的章节中介绍。
补充知识 方法调用、括号和方法指针
在Diksam中调用方法或函数的时候,会像 p.get_x()这样使用括号。即使方法中一个参数都没有,括号也不能省略。
但是,根据语言的不同,有的括号也是可以省略的。例如在Eiffel的语言中一个参数都没有的时候就可以省略掉括号 [15]。这种做法的优点只是单纯地改善了外观问题,节约了录入量。
在Java和C++中,为了实现封装,普遍的做法是将字段设置为 private,然后再像下面这样编写访问器(accessor)。
public double get_x() {
return x;
}
这里将来可能会发生变化,例如也许不会把 x单单作为字段保存,而是将计算后的结果返回去。考虑到这种情况,创建访问器的方法比起把字段设置为 public公开出去要好。但是,编写访问器是一件非常麻烦的事。
在这点上Eiffel的做法是,最初的字段是公开的,如果中途想要修改为在计算后再返回结果的话,此时可以定义一个名字为 x的方法替换掉最初公开的字段。不论是字段还是方法,从使用者的角度来看都是 p.x,这种做法达到了在不影响使用者的前提下将字段替换为方法的目的。
说起替换 x,在Eiffel中默认是不能使用 p.x为其赋值(使用“ .”对字段进行引用时不能作为左值)。因此,每个字段的访问器都是在一开始就强制创建的。我很同意这种做法,从外部改变字段的值是件大事,因此必须编写方法来实现 [16]。
我认为这是一个不错的主意,但在Diksam中并没有使用。原因是,采用了这种方式的话方法本身就不能再作为值被处理了。
// set_on_click方法中传递了对象o的方法作为参数。
button.set_on_click(o.method);
上面的代码注册了一个事件,在按下GUI的按钮时相当于调用了 o.method()。设想一下,如果把方法指针用作事件句柄或者回调方法的话,会发现调用 o.method方法是件困难的事情(因为要向 set_on_click传递 o.method()的执行结果)。
将方法自身作为值处理的功能,将在9.5.4节中实现。
8.4.5 方法调用
Diksam 中 push_method 指令用于在考虑了多态的情况下,决定被调用的函数。
push_method与 push_function把函数入栈的功能相似,是把方法入栈。实际上被推入栈中的也和函数一样,是 DVM_VirtualMachine中 Function类型的对应表的索引值。作为操作数的索引值,跟字段的情况差不多,是MethodMember 结构体的 method_index。 push_method 会根据栈中的对象和 method_index,在考虑到多态的情况下选择适当的函数。
push_method在选择了适当的函数之后就要进行调用了,这个动作与调用普通的函数基本相同,使用的指令也都是 invoke。
Diksam的函数调用已经在6.4.3节中介绍过了。
但是,在调用方法的时候,必须要将目标对象传递给方法。在被调用的方法中这个对象将被作为 this进行引用。
在Diksam中 this作为最后一个参数传递给方法。如图8-9所示。
图8-9 方法调用
在Diksam中,参数按照从前往后的顺序入栈,此时this是最后一个参数,成为了栈的顶端。因此, push_method的时候可以引用 this选择方法。
另外,如图8-9中所示。局部变量的偏移量也因为增加了 this而增加了1。这个调整在加载时进行(请参考load.c的 convert_code()函数)。
在new时调用的构造方法多少会有些不同,操作步骤如下:
1. 首先,创建对象并将其引用入栈。
2. 将构造方法的参数按照从前到后的顺序入栈。
3. 使用 duplicate_index指令将1.中创建的对象引用复制到栈的顶端。
4. 使用 invoke指令调用方法。
5. 最后,在栈中只留下了1.中入栈的对象引用。
8.4.6 super
Diksam和Java一样,有 super关键字。使用它可以调用到 this的超类的方法。
在Diksam中 super的实现非常简单,只将 this指向的 DVM_ObjectRef的vtable替换成超类即可。
但是,并没有把让super保存在其他变量中的使用方法考虑在内(如“ p = super;”)。因此,除了 super. 函数名()以外的形式,其他在编译时都会报错。
并且,在Diksam中超类的构造方法并不能通过 super()的形式调用,而是必须要使用 super.initialize()的形式。在Diksam中,不显式地指定构造方法名称的话,就不能知道调用的是哪个构造方法。
8.4.7 类的链接
类与函数相同,也会引用多个文件,因此必须要进行链接。
这里的结构和函数的基本相同。下面进行简单地介绍。
在8.4.3节中提到过, DVM_Executable中保存着 DVM_Class的数组。new指令以操作数的形式保存着类的索引值,这个索引值就是 DVM_Executable中 DVM_Class的下标。因此,即使没有在当前代码中定义,只是单纯被使用到的类也会被注册到 DVM_Class数组中,而且这样的类,它的is_implemented为 false(请参考8.4.3节)。
将 DVM_Executable 加载到DVM 的时候, push_function 的操作数将会替换为DVM内的下标(请参考6.4.1节)。类的话, new的操作数将会被替换为DVM内的下标(load.c的 convert_code()函数)。
这里说的“DVM内的下标”是指 DVM_VirtualMachine中 ExecClass数组的下标。 ExecClass 创建于加载包含类的源文件的时候,同时也会扩展 ExecClass数组(load.c的 add_classed()函数)。
is_implemented为 false的 DVM_Class会在此时嵌入方法的实现。
8.4.8 实现数组和字符串的方法
如同在8.2.7节中写到的,Diksam的数组和字符串有不少内建方法。
不论是数组还是字符串在创建对象的时候,都在引用对象的 DVM_ObjectRef中以保存硬编码vtable的方式实现。在这个vtable中登录了数组或者字符串方法的(原生方法)实现。
这样就可以在运行时调用方法了,但在编译时必须要进行参数检查。最麻烦的是,例如确定数组的 insert()方法中第2个参数(要插入的数组元素)的类型。 int数组第二个参数必须是 int, Point类的数组则必须是 Point。
认真考虑这个问题的话,就会想到Java的泛型(Generics)和C++的模板这些功能。但是,现在没有必要只是为了数组使用这么复杂的处理方式,用下面的方式也可以解决(保存一个包含了内建方法参数的类型信息的数组) (fix_tree.c)。
/* 参数的类型和数量保存在static的数组中
- DVM_BASE_TYPE表示数组元素的类型。
*/
static DVM_BasicType st_array_size_arg[] = {};
static DVM_BasicType st_array_resize_arg[] = {DVM_INT_TYPE};
static DVM_BasicType st_array_insert_arg[] = {DVM_INT_TYPE, DVM_BASE_TYPE};
static DVM_BasicType st_array_remove_arg[] = {DVM_INT_TYPE};
虽然我觉得这么做不太优雅,但还是在 DVM_BasicType中加入了奇怪的元素(DVM_BASE_TYPE)。
8.4.9 类型检查和向下转型
类的类型检查(instanceof)和向下转型其实是两个相似的功能。向下转型在执行时也会进行和 instanceof相同的类型检查。
因此, instanceof 和向下转型可以看作是编译时进行的检查。首先, Diksam 中存在着类和接口,在 A instanceof B 的时候,会出现以下几种情况。
也就是说, instanceof的右边指定了类的时候,编译时会因为绝对不会为真的 instanceof而导致错误。向下转型时也是一样,如果类之间没有继承关系的话也不可能进行向下转型。
更为重要的是,在Diksam中,绝对为真的 instanceof(同类之间的和与超类进行的 instanceof)、向超类的转型也会导致编译错误。我认为,没用的代码最后会导致bug,在编译时应该阻止这种情况发生。
运行时的检查,将在 ExecClass结构体中遍历所有超类和接口(因此,速度不是很快)。
向下转型的时候,在进行了和 instanceof同样的检查后,如果转型目标是类,就将引用中保存着的vtable替换为目标类的vtable。如果转型目标是接口的话,就用目标接口的vtable替换。
补充知识 对象终结器(finalizer)和析构函数(destructor)
在Java、C++、C#等语言中,类对象销毁时可以通过用户程序得知。具体来说,在对象销毁时,Java会使用终结器,C++和C#则会调用被称为析构函数的方法。
但是,Diksam中没有这个功能。姑且不论必须完全由编程人员控制对象寿命的C++,和可以预测对象销毁时机的Python(使用基本的引用计数器类型GC,在不创建循环引用的前提下),Diksam这样的语言中即使创建了对象终结器 [17]也没有什么作用。
对象终结器的用途,比如说用来关闭在C中 fopen()返回文件的指针。因为能够打开的文件数(译注:文件句柄)在进程中是有上限的,所以使用 fopen()打开的文件在用完后应该立即关闭。但是这个处理如果要交给对象终结器的话,它也不知道要何时进行哪些操作(特别是Java中连有没有进行动作都不知道)。说不定在处理开始前,所有的文件句柄就已经用光了。所以说这种做法不保险。
如果像上面说的那样,我想还是不要定义对象终结器。如果能够简单地实现对象终结器的话,也许效果会更好。但是,实际上想要优雅地实现对象终结器并非易事。
也许有人会想,“嗯?释放对象空间之前,不是只会调用 finalize() 方法吗?” 如果在对象终结器中把 this赋值给了全局变量或者其他东西的话怎么办?
垃圾回收器会根据“不能被追溯到的对象”这一原则来判断对象是否不被需要。但是,在对象终结器中将 this赋值给了全局变量的话,此时这个对象就可以从全局变量中被追溯到,以至于不能被释放了。
Java的GC也不得不面对这个问题。目前已知的是如果使用了对象,终结器会使GC的效率大幅下降。
另外,crowbar和Diksam(book_ver.0.4)中,对于指向原生指针类型的对象来说,可以使用原生函数实现对象终结器。编写原生函数多少会包含一些危险的处理,因此不得不考虑到,如果对在原生函数中悄悄地把对象释放了,然后又被其他程序引用的话就会导致崩溃,那么只能后果自负了。
注 释
[1]. 这也就说明了为什么被require 的程序不执行其顶层结构的原因。——译者注
[4]. 例如Java中的“超类”在C++中被称为“基类”。
[5]. 因为Diksam并没有将字节码保存为文件,所以不论如何都要重新编译。
[9]. 具 体 来 说 就 是MFC (Microsoft Foundation Class)。
[11]. 因此,在例如Java的编程语言设计中,不叫作“向下转换”而叫作“缩小转换”(narrowing cast)。但是鉴于大家比较习惯说“向下转换”,因此Diksam还是采用了这个说法。
[12]. 这里也不一定非要是C语言中所说的指针(内存地址)。在Diksam中就 是 DVM_Virtual-Machine中保存着的Function表的下标。
[13]. 由于Java的class文件中成员名字是字符串,因此发生在加载/链接时。