Lec14: 面向对象语言
对象字段布局、虚方法分派、多继承与运行时类型检查的编译实现。
课程导航与课程讲次
课程讲次
文章目录
教材:Modern Compiler Implementation (Andrew W. Appel), Chapter 14 “Object-Oriented Languages”
载体:课程讲义 Chapter14(共 40 页幻灯片),示例语言为在 Tiger 上扩展的 Object-Tiger
一句话总结
面向对象语言编译的核心是“字段与方法的定位”:单继承下用前缀法(prefixing)让同名字段与方法在所有子类中保持相同偏移量,使字段访问与动态分派都退化为常数偏移取值;多继承破坏了前缀法,需用全局图着色或每类哈希表来定位成员;运行时类型测试(instanceof)可用沿父类链上溯的循环或固定深度的 display 数组以 完成;私有性与向下转型的安全性则由编译期类型检查(必要时辅以运行时检查)保证。
面向对象语言概述
- 基于类的(class-based)面向对象语言的特征:
- (1) 所有(或大多数)值都是对象;(2) 对象是某个类的实例(instance);(3) 对象封装状态(state,即字段 fields)与行为(behavior,即方法 methods)。
- 三大重要特性:继承(inheritance)、封装(encapsulation)、多态(polymorphism)。
- 本章主线(编译器视角的五个问题):类的语法;单继承下数据字段的布局与方法分派;多继承下的成员定位;运行时类成员测试;私有字段与方法的实现。
类的语法:Object-Tiger
在 Tiger 上扩展声明语法以创建类。文法:
dec → classdecclassdec → class class-id extends class-id { {classfield} }classfield → vardecclassfield → methodmethod → method id(tyfields) = expmethod → method id(tyfields) : type-id = expclass B extends A { ... } 的语义:
- 声明新类
B,继承类A;该声明须位于声明A的 let 表达式作用域内。 A的所有字段与方法隐式属于B。B可重写(override)A的某些方法,但参数类型与返回类型必须完全一致。- 字段不能被重写。
- 预定义类
Object:无字段、无方法,是继承树的根。 B中每个方法都有一个隐式形参self,类型为B;self不是保留字,只是每个方法中自动绑定的标识符。
创建对象与调用方法的表达式语法:
exp → new class-id → lvalue . id() → lvalue . id(exp{, exp})new B创建B的实例;b.x取字段;b.f(x, y)调用方法,b作为f的隐式self实参。
贯穿全章的示例程序:
let start := 10 class Vehicle extends Object { var position := start method move(int x) = (position := position + x) } class Truck extends Vehicle { method move(int x) = // 重写 move if x <= 55 then position := position + x } class Car extends Vehicle { var passengers := 0 method await(v: Vehicle) = if (v.position < position) then v.move(position - v.position) else self.move(10) } var t := new Truck var c := new Car var v : Vehicle := cin c.passengers := 2; c.move(60); v.move(70); c.await(t)end- 注意:
v的静态类型是Vehicle,运行时却指向Car;v.move(70)必须调用到运行时真实类的move——这正是动态分派要解决的问题。
单继承的数据字段
取字段的难题
- 例:
v.position,v的静态类型为Vehicle。编译器要生成代码,从v指向的对象(记录)中取出position字段。 - 朴素想法:从
v的环境项拿到Vehicle的类描述符(class descriptor),再从描述符查position的偏移量。 - 难点:运行时
v可能指向Car或Truck对象,position在它们中的位置是否一致?
前缀法(Prefixing):字段布局
- 单继承语言:每个类只继承一个父类。
- 规则:
B extends A时,B从A继承的字段按A中的原顺序排在B记录的最前面;B新增字段排在其后。 - 示例:
class A extends Object { var a := 0 }class B extends A { var b := 0 var c := 0 }class C extends A { var d := 0 }class D extends B { var e := 0 }布局(方括号内为字段,下标为偏移量):
- 关键结论:字段
a在A、B、C、D中偏移量都是 。因此无论v实际指向哪个子类对象,v.a/v.position都落在编译期已知的固定偏移处——单继承下字段偏移量编译期可定,取字段只需一条指令。
方法的编译
- 一个方法实例(method instance)像普通函数一样被编译,生成位于指令空间某地址的机器码。
- 例:
Truck_move方法实例的入口是机器码标号Truck_move。 - 每个类描述符包含:指向父类的指针,以及方法实例列表。
静态方法(Static Methods)
- 部分 OO 语言允许方法声明为 static。
- 编译
c.f()的查找过程:- (1) 求
c的类,设为C;(2) 在C中找方法f,若未找到;(3) 找C的父类B,依次上溯;(4) 若在祖先类A中找到 static 方法f,则编译为对标号A_f的普通函数调用。
- (1) 求
- 即
c.f()直接 callA_f,目标地址编译期确定。
动态方法与分派向量(vtable)
- 若
f是动态方法,能否把c.f()直接编成A_f?不能:f可能在C的某个子类D中被重写;编译期无法判定c指向C(应调A_f)还是D(应调D_f)。
- 解法:类描述符中放一个分派向量(dispatch vector / virtual table / vtable),每个(非静态)方法名对应一个方法实例指针。
- 前缀法同样适用:
B继承A时,方法表先放A已知的所有方法名条目,再接B新声明的方法。
- 前缀法同样适用:
- 示例:
class A extends Object { var x := 0 method f() }class B extends A { method g() }class C extends B { method g() } // 重写 gclass D extends C { var y := 0 method f() } // 重写 f各类 vtable(下标为方法偏移量):
- 关键:
f在所有描述符中偏移恒为 ,g恒为 。同名方法在所有类的 vtable 中偏移一致(前缀法保证)。
执行 c.f()(f 为动态方法)的三步:
- (1) 从对象
c偏移 处取类描述符 ; - (2) 从 的(常量)偏移 处取方法实例指针 ;
- (3) 跳转到地址 并保存返回地址(即 call )。
多继承
- 若允许类
D同时继承A、B、C,字段偏移与方法定位变难:- 无法同时把
A的字段都放在D开头、又把B的字段也都放在D开头。前缀法失效。
- 无法同时把
全局图着色:字段
- 思路:链接期一次性静态分析所有类(图着色算法),为每个字段名找到一个在所有含该字段的记录中都通用的偏移量。
- 示例:
class A extends Object { var a := 0 }class B extends Object { var b := 0 var c := 0 }class C extends A { var d := 0 }class D extends A,B,C { var e := 0 }- 图着色建模:
- 结点:一个不同的字段名;边:两个字段共存于同一个类;颜色:偏移量
D同时含 ,故五者两两相邻,必须取五种不同颜色,例如 。各类按此全局偏移布局(如B在偏移 处留空,C在偏移 处留空)。
- 缺点:对象中间出现空槽(内部碎片)。
- 改进(打包):把对象字段紧凑排列,由类描述符记录每个字段的实际位置。
- 此时描述符有空槽、对象无空槽;可接受,因为对象数 描述符数。
- 代价:每次取/存字段需三条指令而非一条:
- (1) 从对象取描述符指针;(2) 从描述符取该字段的偏移值;(3) 在对象的该偏移处取/存数据。
- 对比:单继承用前缀法,字段偏移编译期已知,一条指令即可。
全局图着色:方法查找
- 同一套图着色法对方法也适用:
- 方法名与字段名混在一起,构成一张大干涉图(interference graph)的结点。
- 描述符中字段条目给出对象内位置;方法条目给出方法实例的机器码地址。
图着色的问题:动态链接
- 全局着色只能在链接期(link-time)完成。
- 但许多 OO 系统支持把新类动态载入运行中的系统。
- 链接期图着色对支持动态增量链接(dynamic incremental linking)的系统造成诸多困难。
哈希法(Hashing)
- 在每个类描述符中放一张哈希表,把字段名映射到偏移、方法名映射到方法实例。
- 优点:与分离编译(separate compilation)和动态链接配合良好。
- 两张表:
- Ftab(field-offset table):存字段偏移与方法实例;
- Ktab(key table):存字段名指针(用于冲突检测)。
- 若类含字段 ,则 Ftab 的槽 存 的偏移,Ktab 的槽 存指针 。
- 取对象
c的字段 :- (1) 从
c偏移 取描述符 ; - (2) 从地址 取字段名 ;
- (3) 测试是否 (成立则无冲突);
- (4) 从 取字段偏移 ;
- (5) 从 取字段内容。
- (1) 从
- 动态方法实例查找用类似算法;任意哈希冲突解决技术皆可用。
测试类成员关系
部分 OO 语言允许运行时测试对象是否属于某类(表 14.6 类型测试与安全转型设施):
| 功能 | Modula-3 | Java |
|---|---|---|
| 测试对象 是否属于类 或其任意子类 | ISTYPE(x,C) | x instanceof C |
| 设 静态类型为 、实际指向 的子类 的对象,得到一个编译期类型为 的表达式 | NARROW(x,D) | (D)x |
简单循环法(无多继承)
实现 x instanceof C:沿父类链向上逐级比较。
t1 ← x.descriptorL1: if t1 = C goto true t1 ← t1.super if t1 = nil goto false goto L1t1.super是类t1的父类(超类)。缺点:可能慢(继承链越长越慢)。
Display 法(O(1))
- 更快:在描述符中放一个父类 display 数组。
- 假定类嵌套深度有上限(如 ),每个描述符预留 字的块。
- 设类
D的嵌套深度为 (编译期可知),D的描述符中:- ,,,,;对 有 。
- 性质:
x是D或D的任意子类的实例x的描述符中 ;否则不成立。 - 故
x instanceof D仅需:- (1) 取
x偏移 的描述符 ;(2) 取 的第 个类指针槽 ;(3) 与描述符 比较。常数时间。
- (1) 取
类型强制转换(Type Coercion)
- 设变量
c的类型为C:- 把
c当作C的任意父类型使用:合法且安全。如var b : B := c(C extends B)。向上转型(upcast)总安全。 - 反之不然:
c ← b只有当b运行时确实是C的实例时才安全,否则——
- 把
b ← new Bc ← bc.some_field_of_C_but_not_B // 行为不可预测- Modula-3、Java:从父类到子类的强制转换(downcast)伴随运行时类型检查,若运行时值不是该子类实例则抛异常(即除非
b instanceof C)。
Modula-3: Java:IF ISTYPE(b,C) if (b instanceof C) THEN f(NARROW(b,C)) f((C)b)ELSE ... else ...- C++ 的 static cast 无运行时检查,因而不安全。
Typecase
- Modula-3 的 typecase 让“测试-再窄化(test-then-narrow)”惯用法更简洁高效:
TYPECASE exprOF C1 (v1) => S1 | C2 (v2) => S2 . . | Cn (vn) => SnELSE S0END- 若多个 同时匹配(如其中一个是另一个的父类),只取第一个匹配的子句;若都不匹配,则取 ELSE 子句。
- 可直接翻译为一串 else-if,每个 if 做:(1) 一次实例测试;(2) 一次窄化(narrowing);(3) 一个局部变量声明。
私有字段与方法
- 真正的 OO 语言能保护对象字段,使其不被其他对象的方法直接操作。
- 私有字段:不能被对象外部声明的任何函数或方法读取/更新。
- 私有方法:不能从对象外部调用。
- 私有性由编译器的类型检查阶段强制执行。
- 实现:在类
C的符号表中,每个字段偏移与方法偏移旁附一个布尔标志,标明该成员是否私有。
私有与保护的多种形式(不同语言各有取舍,一般由编译期类型检查静态实施):
- 仅声明该成员的类可访问;
- 声明类及其任意子类可访问;
- 仅与声明类同一模块(包/命名空间)内可访问;
- 从声明类外部只读、但类自身的方法可写。
评述与要点提炼
- 前缀法是单继承的灵魂:把“同名成员在所有子类中保持相同偏移量”做成不变式,于是字段访问与动态分派都退化为“常数偏移取值”,无需运行时查找。这是单继承高效的根本原因,也是考试高频点。
- 一层间接换灵活:动态分派的代价是对象头部多一个描述符/vtable 指针,调用多一次间接(取描述符 取槽 跳转)。理解“为何不能在编译期定死
A_f”比记住三步更重要。 - 多继承的本质困难是“无法让多个父类同时占据记录开头”,由此分出两条路线:全局图着色(偏移全局一致、有内部碎片、需链接期全局信息、难配动态加载) 对 每类哈希(分离编译与动态链接友好、但每次访问要哈希加冲突检测)。这是“静态全局优化”与“动态局部灵活”的经典权衡。
- instanceof 的两种实现对应“时间换空间”与“空间换时间”:循环上溯省空间但耗时 ;display 数组以每类固定 字的空间换来 判定,其成立前提是继承深度有上界且 编译期已知。
- 安全的类型系统把“向下转型”视为可能失败的运算:Java 与 Modula-3 用运行时检查兜底(instanceof 加异常),C++ 的 static cast 把责任丢给程序员因而不安全;typecase 只是该模式的语法糖,可机械展开为 if-instanceof-narrow 链。
- 串联记忆:本章五节其实回答同一个问题——“给定一个静态类型不精确的对象引用,如何在运行时正确而高效地定位它的字段、方法与真实类型”,前缀法、vtable、图着色、哈希、display 都是这一问题在不同继承模型下的工程答案。

