8.3 关于类的实现——继承和多态

实现类最应该关心的不就是继承和多态的实现方法吗?——不管别人是不是这么想,至少我是。

因此,先把其他细节的话题放在一边,本章我们就来介绍一下Diksam中继承和多态的实现。

8.3.1 字段的内存布局

首先要考虑的是,在继承了其他类的情况下,字段在内存中的布局。

典型的例子就是,考虑创建类型 Line和 Circle,并继承表示二维的“图形”的类 Shape。 Shape中保存着“颜色”等图形的通用属性。在 Line和 Circle中,也保存着表示各自图形的必备信息。图8-4中记载了这些类(图中对方法也进行了描述,具体会在介绍多态时进行说明)。

figure_0266_0070

图8-4 “图形”的继承结构

图8-4 中, Shape 中保存了用 int 型的“颜色编码”, Line 中保存了直线的起点和终点。我想聪明的读者一眼就能看明白, start_x、 start_y是起点坐标, end_x、 end_y是终点坐标, Circle的 center_x、 cengter_y是原点坐标, radius是半径。

此时,字段数据的保存方式如图8-5所示,超类字段的后面紧跟着子类中增加的字段。

figure_0266_0071

图8-5 字段的存储方式

使用这种存储方式的好处在于,在引用 Shape 类型变量 shape 的字段 shape.color时,具体的对象实际上无论是 Line也好, Circle也好,它们在引用 color时的偏移量都是相同的。

如果出现了多继承的话情况就变得复杂了,但是由于Diksam的类只能单继承,因此字段的储存也可以使用这种方式实现。

8.3.2 多态——以单继承为前提

继承,不仅要能引用字段,还必须要实现多态。回到图8-4, Shape中定义了 get_color()、set_color()和 draw()三个方法。其中 draw() 是 abstract方法,并且会在Line和Circle中重写。因此,只需编写代码就可以了。

shape.draw();

此时 shape不论是 Line还是 Circle,都会执行 draw()方法。

限制为单继承的好处就在于容易实现。只需要让类的实例中保存着指向方法实现的指针 [12]数组即可。这里说的数组恐怕是原来的C++用语,一般被称为vtable(virtual method table的简称)。

vtable与类相对存在,同一类的实例指向同一个vtable,并在 new的时候将vtable传递给实例。由于 Shape包含了三个方法,因此我们通过下面的列表来调用 Shape的方法。

get_color()在vtable上的下标为0 

set_color()在vtable上的下标为1

draw()在vtable上的下标为2

原则是子类的vtable首先要具有和超类同样的内容。但在方法重写的情况下会替换原有的位置,而在子类中增加方法的时候,子类的vtable也会变得比超类的长(图8-6)。

figure_0267_0072

图8-6 在单继承的情况下的多态

使用这个处理方式,缺点在于每次调用方法时都要引用vtable,所以多少会有些延迟。而优点在于不论类的继承关系有多深,或者类中的方法有多多,方法调用的开销都是基本相同的。

C++只要在不使用多继承的时候,通常都是用这种处理方式实现继承的。但C语言就不可能用这个处理方式实现继承和多态了(GTK+、X-Window Toolkit等)。

Diksam的类只能单继承,但是可以多继承接口。因此,对于字段来说是以单继承为前提的,但是对于方法的调用来说,就不得不考虑多继承的情况。在多继承时,vtable的下标就失去了唯一性,也就无法用这种处理方式来应对了。

8.3.3 多继承——C++

我们以C++为例,看一下它是如何实现多继承的。细节的地方因处理器而异,比如有继承了类A和B的类C,首先A和C如果是“主要继承关系”的话,那么不论是字段还是方法,都可以使用和单继承时相同的处理方式,但问题在于C的对象在被当做B引用(将C向上转型为B)的时候。C++在此时会将指针本身转换为B的vtable对应的地址。

这个现象可以在代码清单8-6中得到验证。

代码清单8-6 C++的指针转换

1: #include <stdio.h>

2:

3: class A {

4: public:

5:  int a;

6:  virtual void a_method() {}

7: };

8:

9: class B {

10: public:

11:  int b;

12:  virtual void b_method() {}

13: };

14:

15: // 继承了类A和类B的类C

16: class C : public A, public B {

17: public:

18:  int c;

19:  void a_method() {}

20:  void b_method() {}

21: };

22:

23: int main(void)

24: {

25: C *c = new C();

26:

27: // 显示new过的变量c的指针

28: printf("c..%p\n", b);

29:

30: // 将其赋值到B*型的变量中,并显示

31: B *b = c;

32: printf("c as B..%p\n", b);

33:

34: //显示A, B, C各类中的成员变量(字段)的地址

35: printf("&A->a..%p\n", &c->a);

36: printf("&B->b..%p\n", &c->b);

37: printf("&C->d..%p\n", &c->c);

38:

39: return 0;

40: }

在我的环境中,输出结果如下(我的环境 int和指针都是4个字节)。

c..0x804a008

c as B..0x804a010

&A->a..0x804a00c

&B->b..0x804a014

&C->d..0x804a018

内存结构如图8-7所示。在将指针赋值给 B*型的变量时,指针所指向的地址发生了改变。可以确认在 A的字段 a和 B的字段 b之间存在着 B的vtable占用的内存空间。

figure_0269_0073

图8-7 C++的多继承

在C++中,只有这样的(自动的)转换才可能改变指针的值,C像是通过 void*保存了对象,给人的感觉非常不好——其实我也有过这样的经历,当然这是题外话。

如果在向上转型(通常向上转型是自动进行的)为B的时候移动指针,不但可以用普通的方式引用到B的字段,由于vtable也是各自保存的,因此就实现了多态。

只是在Diksam中,如果移动了指针的话会给GC的实现带来困难。现在的Diksam,指向堆的指针类型是 DVM_Object*,但如果像C++一样移动指针的话,就会由于没有指向最初的 DVM_Object而给GC的mark阶段造成麻烦。另外,由于Diksam不能多继承字段,如果使用C++的处理方式就显得有点过度设计了。

8.3.4 Diksam的多继承

在Diksam中采用了如下的处理方式。

不是在对象的开头部分保存vtable,而是在引用了对象的值中。这样既保存了指向对象本身的引用,同时也保存了指向vtable的引用。

在向上或者是向下转型时,替换引用值中的vtable就可以了。

在Diksam中,值被保存在DVM_Object共用体中,并且使用DVM_ObjectRef结构体引用其中的对象。

typedef union {

int   int_value;  / 值为整数时 /

double   double_value; / 值为实数时 /

DVM_ObjectRef object;  / 值为对象时 /

} DVM_Value;

DVM_ObjectRef定义如下。

typedef struct {

DVM_VTable v_table; / vtable的指针 */

DVM_Object data; / 数据本身 */

} DVM_ObjectRef;

在8.3.2节中介绍了在Diksam中类继承的处理方式——使用vtable实现多态,并且在转型为接口的时候替换引用值中的vtable(DVM_ObjectRef 的 v_table成员)。

当然,基于上面的处理方式也会出现对象不知道自己原本是什么类型的情况,因此在vtable中保存了指向对象原本类型的指针。

struct DVM_VTable_tag {

ExecClass exec_class; / 指向类的指针 */

int  table_size; / vtable的元素数 /

VTableItem table;  / vtable本身 */

};

其中叫作 ExecClass的类型就是保存类在运行时信息的类型,每个类只对应一个,在 DVM_VirtualMachine中以数组的方式保存(这个数组的创建时机请参考8.4.7节)。

typedef struct ExecClass_tag {

DVM_Class   *dvm_class;

ExecutableEntry  *executable;

char    *package_name;

char    *name;

DVM_Boolean   is_implemented;

int     class_index;

struct ExecClass_tag *super_class;

DVM_VTable   *class_table;

int     interface_count; ←请注意这个地方

struct ExecClass_tag **interface; ←和这个地方

DVM_VTable   **interface_v_table;

int     field_count;

DVM_TypeSpecifier **field_type;

} ExecClass;

如图8-8所示(在图中省略了接口的 ExecClass)。

figure_0271_0074

图8-8 Diksam的多继承

如此一来,在向上转型的时候,要找到相应的vtable并替换 DVM_Value中的(DVM_ObjectRef的) v_table成员。在使用 up_cast指令(将在后面的章节介绍)时,这个接口的数组下标将会作为操作数进行传递。

补充知识 无类型语言中的继承

作为像Diksam和C++这样的静态类型的语言,不论是引用字段的时候还是调用方法的时候,都能够一下子引用到在编译时 [13]就已经决定的索引值。多继承的情况则更加复杂,但不论怎样都可以使用“固定的开销”引用到字段或者方法,这点是没有变化的。

与此相对,在像Ruby这样没有变量类型的语言中, a.hoge语句(单纯的实现)由于在编译时并不知道 a的类型,没有办法一下子就访问到索引值,因此必须要利用成员名字进行搜索。也就是说,根据成员数量不同,检索需要的时间也不同,所以引用成员时就不是“固定的开销”了。

尽管如此,如果使用二分法检索(假如有1000个成员的话,用10次以内的循环就可以完成)的话,也可以看作是“固定的开销”。

8.3.5 重写的条件

重写是在调用超类的方法时,实际被调用的是(也许是)子类的方法。从调用者看上去好像调用的是超类的方法,但是实际上调用的却是子类的方法,而这个子类的方法说不定会让调用者吓一跳。

例如,子类方法的返回值类型要比超类的“窄”(被称为 共变 ,covariant)。当超类有以下方法时,如果子类要对其进行重写的话,子类的 getShape()方法不能返回Shape以外的类型 [14]

Shape getShape();

子类的返回类型只要是比超类“窄”就算合法。也就是说,像下面这样将 Shape的子类作为返回值的重写是合法的。

// Circle如果是Shape的子类的话,这个方法就是合法的

Circle getShape();

这种情况下,子类的 getShape()方法一定只能返回Circle类型。但是在调用者,期待的是比Circle更“宽”的 Shape类型,这是没有问题的。相反,如果期待的是 Circle,返回的却是其他 Shape(Line或者 Rectangle)的话,真的会被吓一跳吧。

像这样将返回值共变的好处有很多,比如限制了 Java 中 Object 类的 clone()方法的返回值,以减少不必要的转型。Java从JDK1.5开始具备这个功能。

在Diksam中,参数的情况却是相反的。子类方法的参数类型要比超类的“宽”(被称为 反变 ,contravariant)。在超类中有如下方法时:

void drawShape(Circle circle);

在子类中可以合法地定义如下方法:

void drawShape(Shape shape);

子类的 drawShape() 能接受的参数,超类的 drawShape() 应该也能接受,这样的话调用者就不会被吓一跳了。

至于访问修饰符,子类的方法必须要比超类的“宽松”。 public的方法不能被重写为 private的,反之则合法。

9.2.3节会为Diksam引入异常的概念,Diksam的异常处理属于Java风格的异常检查(把方法中可能抛出的异常全部用 throws 列出,并由编译器检查)。这种情况下,子类的方法不能比超类抛出更多的异常。

让我们再说回参数,Diksam在编译器进行静态检查时允许反变是非常方便的设计,但是在实际应用中,有时也需要同时允许共变的存在。假设 Shape有设置样式的 setStyle(Style style)方法, Line和 Circle需要的样式也应该是根据不同图形定制的 Style的子类——但是,如果实现了这项功能的话,一定要在调用 Shape的s etStyle()时进行一些运行时检查。

相似的话题在7.3.2节的补充知识中已经作过介绍,在Java中假设存在超类 Shape 和子类 Circle,那么Circle 的数组会自动成为 Shape 数组的子类。这个设计可能会引发叫作 ArrayStoreException的运行时错误。

如果将 Shape[]和 Circle[]看作是类的话,就应该能看出来它们是共变参数的一种。

// 将“Shape数组”看作是类

class ShapeArray {

// 取得数组元素的方法

Shape get(int index);

// 设置数组元素的方法

void set(int index, Shape shape);

}

// CircleArray是ShapeArray的子类

class CircleArray extends ShapeArray {

// 返回值的共变 → 没问题

Circle get(int index);

// 参数类型的共变 → 必须进行运行时检查

void set(int index, Circle circle);

}