面向对象机制

在某些说法中,封装、继承和多态,被称作面向对象机制的三大特点。 然而事实上,很多现代的函数式语言也都具备上述特点。 既然 Decaf 自诩为面向对象语言,它也具备这些特点——用类进行封装、类之间可以(单)继承、对象的成员方法调用时动态分发 (dynamic dispatch)。 TAC 程序中提供了虚表这种特别的数据结构来辅助我们实现上述机制。

虚表及成员方法调用

虚表的目的是实现运行时函数地址绑定,即所谓的动态分发机制,例如以下 Decaf 代码:

class Father {
    int writeName() { print(1); ...}
    int smile() { print(2); ...}
}
class Son extends Father {
    int writeName() { print(3); }
    int laugh() { print(4); }
}

这里 Father 类定义了一个 writeName 方法,而其子类 Son 使用新的 writeName 覆盖了这个方法。考虑以下代码片断:

class Father a;
class Son b;
class Father c;
a = new Father();
b = new Son();
c = b;

执行 a.writeName() 的结果是输出1,执行 b.writeName() 的结果是输出3,但是执行 c.writeName() 的结果会是什么呢? 虽然 c 被声明为 Father 的对象,但是实际上它是一个 Son 的对象,因此,按照Decaf语言规范,c.writeName() 所调用的应当是 SonwriteName(),即输出3。

这种行为在二进制层次上是怎么实现的呢?这里我们将采用一种叫做虚表的结构。我们为每一个类都创建一个存放成员函数入口地址的数组如下:

Virtual table of Father:
+8: address of function writeName (which prints 1)
+12: address of function smile

Virtual table of Son:
+8: address of function writeName (which prints 3)
+12: address of function smile
+16: address of function laugh

上图中每个虚表中都列出其对应的类的所有可访问的成员函数地址,成员函数地址前面的+8、+12等等表示这个地址存放在虚表中的位置,例如+8表示存放在离虚表开头8字节的地方。注意这里我们把虚表的前8个字节的元数据忽略了。 然后我们在 ab 所指向的内存区域的开头分别放有指向 FatherSon 的虚表的指针,在每次通过 a 或者 b 调用 writeName() 的时候,我们都首先通过 ab 开头的那个虚表指针找到对应的虚表,然后在表中找出偏移地址为+8那一项对应的实际函数地址,调用之。

现在我们考虑 c = b 的情况。由于Decaf的对象赋值采用引用赋值,因此这个赋值语句的效果仅仅是让 cb 指向同一块内存区域。 因此,按照上面的过程,当调用 c.writeName() 的时候,我们首先通过 c 所指向的内存区域找到对应的虚表(Son 的虚表),然后在这个虚表内找到 writeName 对应的那一项。 我们发现这一项对应的函数地址是打印3的那个 writeName() 函数的地址,因此 c.writeName() 的调用结果是输出3。

注意这里为了实现成员方法的继承,Son 的虚表继承了 Father 的虚表中 smile 那一项,并且为了保证子类兼容于父类,同名的函数在子类的虚表中的位置跟父类虚表中的位置是一样的,例如 writeNamesmile 两个函数在 Son 虚表中处于+8和+12两个位置,这跟在 Father 的虚表中的情况一致;而原来 FatherwriteName 的入口地址被 Son 版本的 writeName 的入口地址取代,以此实现成员函数的覆盖。

由于 TAC 的函数不区分“成员”和“静态”,那么我们如何区分不同 Father 类对象的 .writeName() 调用呢? 这里我们采用类似于 Python 的技巧,让所有成员方法都额外带有一个 self 参数,即这里的 writeName 实际上签名为

int writeName(class Father self);

那么调用 obj.writeName() 相当于

writeName(obj)

因此,一个参数本来有 n 个的成员方法会被翻译成为一个有 n + 1 个参数的普通函数。

关于虚表的更多内容请参考:http://en.wikipedia.org/wiki/Virtual_table

静态方法调用

静态方法会被直接翻译成一个普通的 TAC 函数,按其地址调用。 即将其地址先存到一个临时变量 t 中,再 call t

对象的内存表示及成员变量访问

我们考虑如下类定义:

class A {
    int x;
    int y;
    int f1() {...}
    int f2() {...}
}
class B extends A {
    bool z;
    int f3() {...}
}

我们知道,f1, f2, f3 的地址都是存放在 A 或者 B 的虚表中的,现在的问题是 x, y, z 是怎么存储的。 由于不同的对象可以有不同的 xyz 值,因此这些成员变量不能像成员函数那样存放在虚表中。 一般来说这些域都存放在跟各对象相关联的内存块中,例如 A 的对象和 B 的对象的内存块的可能内容分别如图所示(注意这里的虚表忽略了前8个字节的元信息):

从图中可以看出,每一个对象实体都对应着一个记录这个对象状态的内存块,其中包括了这个对象的虚表指针和所有用于说明这个对象状态的成员变量。 成员变量的排布顺序是:“辈分”越高的成员变量越靠前,例如从父类继承而来的成员变量总是在这个内存区域的前面,而子类特有的成员变量在这个内存区域的最后面,并且父类的成员变量的位置总是跟一个父类对象所对应的内存区域里面的情况一致的,这样做的目的是为了实现子类的对象能兼容于父类的对象(继承机制的一个表现)。

虽然 Decaf 语言不支持自定义构造函数,但是在用户 new 出来一个对象的时候,在 TAC 上我们必须要有一个构造函数来初始化其对应的内存区域,并返回该区域的首地址,作为对象的引用。 在框架中,我们在遍历每个类的时候,就会先生成其构造函数对应的 TAC 指令。 不难想象,这个函数的功能很简单:按照上述存储布局用 ALLOCATE 运行时库函数申请一段大小合适的内存区域,在前4个字节存储虚表的首地址, 并在接下来的部分依次初始化每个成员变量的值(默认是0)。 最后返回此内存区域的首地址,作为对象的引用。

当访问一个对象的某个成员变量的时候,首先是通过这个对象的引用找到这块内存区域,然后再根据要访问的成员变量在这片内存区域中的偏移量对该成员变量进行访问。 由此可见,在Decaf中,访问一个对象的成员变量需要一次LOAD操作,而访问成员函数则由于需要通过首先按照访问成员变量的方式访问其虚表,然后在虚表中再次按照访问成员变量的方式拿到函数的入口指针,从而需要用两次LOAD操作。

this 关键字的处理

为了简化本阶段的代码生成,在 PA2 中,我们已经把所有隐式含有 this 的成员访问转换为显式的,如

class A {
    void foo() { }
    void bar() {
        foo();
    }
}

会被展开为

class A {
    void foo() { }
    void bar() {
        this.foo();
    }
}

且按照调用惯例,this 的值总是存储在 0 号临时变量中。

Last updated