第 22 章 / 共 58 章 卷五 OOP

继承与多态

extends 复用父类的字段与行为,virtual/override/final 决定哪些方法可以被子类改写,动态派发让父类引用自动调用子类的实现——本章把这条链上的每条规则与每个报错逐一实测。

本章导读

第 21 章的 class 是孤立的个体;本章让它们结成家族。extends 继承字段与方法;base(...) 把父类构造嵌入构造链;virtual/override/final 管理"哪些方法允许改写、改写必须显式、何处到此为止";动态派发让 Entity e = Player() 后 e.score() 自动走 Player 的实现。xlang 的继承是强约束风格:覆盖必须写 override、目标必须 virtual、签名必须精确——每条都有专属诊断,本章全部实测并附原文。

本章学习目标

掌握 extends 声明与父类成员继承;背下 base(...) 的两条"必须显式"规则与使用位置;理解 virtual/override/final 的协作与四条报错;理解动态派发的条件(父类引用 + 虚方法)与 vtable 实现;掌握 protected 的放行范围;会用 is 守卫 + as 向下转型;了解同名字段遮蔽与多层继承链行为。

22.1 extends 继承基础

class 子类 extends 父类:子类自动拥有父类的字段与方法,可以直接增改:

extends.xl
class Entity
  int baseValue = 1
  virtual func score() as int
    return self.baseValue
  end
  func run() as int          // 非虚方法:子类继承,不可改写
    return self.score() + 1
  end
end
class Player extends Entity
  int bonus = 5
  override func score() as int
    return 40 + self.bonus
  end
end
Player p = Player()
println(p.score())      // 45:子类改写版
println(p.run())        // 46:继承的 run 内部也调到了子类 score
println(p.baseValue)    // 1:父类字段直接可用
Entity e = p            // 父类引用指向子类实例(向上转型,总是合法)
println(e.score())      // 45:多态的核心——见 22.4

继承是单继承:extends 后只能跟一个父类;父类名不存在时报 [sem] 未知父类。注意 run() 的输出:它定义在父类、没有被子类改写,但内部调用 virtual score() 仍派发到子类版本——虚方法的"虚"全程有效。

22.2 base(...) 父类构造

子类构造必须先让父类部分就位。父类有无参构造(或没有 create)时,这条链自动完成;父类 create 带参时,编译器强制你显式接力:

base.xl
class Base
  int seed = 0
  create(int seed)
    self.seed = seed
  end
end
class Child extends Base
  int value = 0
  create(int seed, int value)
    base(seed)          // 显式调用父类构造
    self.value = value
  end
end
auto c = Child(7, 9)
println(c.seed, " ", c.value)   // 7 9

规则全集(全部实测有专属诊断):

  • 父类 create 带参、子类 create 里没调 base:[sem] 父类 create 需要参数,子类 create 必须显式调用 base(...)。
  • 父类 create 带参、子类干脆没写 create:[sem] 父类 create 需要参数,子类必须声明 create 并显式调用 base(...)。
  • base(...) 只能写在 create 体内:写在普通方法里报 [sem] base(...) 只能在 class 的 create 体内使用。
  • base 的实参按父类 create 重载解析,数量不符报 [sem] create 调用实参数量和重载签名不匹配。
  • base(...) 不必是 create 的第一句——先给本类字段赋值再调也合法。

22.3 virtual/override/final

三件套各管一环:virtual 声明"此方法允许改写"、override 声明"我在改写父类方法"、final 声明"改写到此为止"。协作规则由编译器严格把守:

写法结果(实测原文)
子类改写父类 virtual 方法,不写 override[sem] overriding a base member requires explicit override
override 一个非 virtual 的父类方法[sem] override target must be virtual
override 一个 final 方法[sem] cannot override a final class member
override 签名与父类不一致[sem] override requires an exact base member signature
给 private 成员加 virtual/override/final[sem] private class member cannot use override/final/virtual

两个推论:非 virtual 方法根本无法被覆盖(不写 override 报第一行错,写了报第二行错)——所以 xlang 没有"意外遮蔽父类方法"这种事故;final virtual 组合表示"我是虚方法,但继承链到此为止"。override 链可以接力:A virtual → B override → C 继续 override 合法(22.8 实测)。

22.4 动态派发

多态的完整条件:父类类型的引用装着子类实例,调用虚方法——运行时按实例的真实类型选实现:

dispatch.xl
Entity e = Player()     // 静态类型 Entity,动态类型 Player
println(e.score())    // 45:走 Player 的 override
println(e.run())      // 46:run 是 Entity 的,但内部 score() 仍派发到 Player
println(e.baseValue)  // 1:字段没有多态——按声明类型直接取

注意引用的静态类型决定你能看见什么:e 的类型是 Entity,访问 Player 独有的字段(e.hp)报 [sem] unknown class/union field——尽管实例里明明有这个字段。多态只发生在 virtual 方法的调用上;字段访问、非虚方法都按静态类型解析(非虚方法也不可能被子类改写,22.3)。底层机制是 vtable,证据见深入细节拾遗 1。

22.5 protected 的子类放行

第 21 章见过:protected 成员从外部访问报 [sem] protected class member is not accessible。它与 private 的唯一差别在继承体系内——子类的方法里可以直接用:

prot.xl
class Base
  protected int secret = 42   // 外部摸不到,子类随便用
end
class Child extends Base
  func peek() as int
    return self.secret      // 子类内合法
  end
end
Child c = Child()
println(c.peek())   // 42

选型准则:只想自己用 → private;想让子类参与实现但不对外开放 → protected;公开接口 → public(默认)。protected 是为继承设计的"家族内部通道"。

22.6 is/as 与向下转型

向上转型(子→父)永远合法;向下转型(父→子)需要运行时验证,配套两个运算符:

isas.xl
Entity e = Player()
println(e is Player)   // true:is 看动态类型,沿继承链都为 true
println(e is Entity)   // true
if e is Player then
  Player p = e as Player   // as 向下转型,取回完整接口
  println(p.hp)          // 100:子类字段回来了
end

as 失败的代价很大:实例真实类型不符时(Entity 实例 as Player),程序直接 fatal 退出(退出码 4),没有异常、没有提示——所以 as 必须配 is 守卫,上面的 if 写法是标准姿势。is 对整条继承链回答 true:C extends B extends A 时,C 的实例 is A、is B、is C 全为 true。

22.7 继承中的字段

父类字段直接继承(22.1 的 p.baseValue);子类还可以声明同名字段——编译器不拦,行为是遮蔽:

shadow.xl
class Base
  int value = 1
end
class Child extends Base
  int value = 99   // 允许:遮蔽父类同名字段
end
Child c = Child()
println(c.value)   // 99:子类字段赢

字段遮蔽编译器不拦,但两个 value 同时存在于实例里(父类构造照常维护父类那份),极易"改了这份读那份"——工程上视为坏味道,换个名字。另一个相关空白:xlang 没有 base.score() 式的"调用父类被覆盖方法"语法(拾遗 3)。

22.8 多层链与 object 预告

chain.xl
class A
  virtual func tag() as string
    return "A"
  end
end
class B extends A
  override func tag() as string
    return "B"
  end
end
class C extends B
  override func tag() as string
    return "C"   // override 链接力:目标是 B 的 override,合法
  end
end
A a = C()
println(a.tag())   // C:隔两代照样派发到最终实现

构造与析构沿链有序:构造从祖父到孙(base create → child create),析构恰好相反——与 C++ 同款顺序。链的顶端还有一个特殊存在:object 是可选的根基类,extends object 让实例获得动态字段能力(e["hp"] = 100 式玩法),那是第 26 章的主题。

22.9 实战:多态图形表

把不同子类装进同一个父类型数组,统一遍历、各自计算——多态最经典的应用:

shapes.xl
class Shape
  virtual func area() as float
    return 0.0
  end
  virtual func name() as string
    return "shape"
  end
end
class Circle extends Shape
  float r = 1.0
  create(float r)
    self.r = r
  end
  override func area() as float
    return 3.14159 * self.r * self.r
  end
  override func name() as string
    return "circle"
  end
end
class Rect extends Shape
  float w = 1.0
  float h = 1.0
  create(float w, float h)
    self.w = w
    self.h = h
  end
  override func area() as float
    return self.w * self.h
  end
  override func name() as string
    return "rect"
  end
end
array<Shape> shapes = []
shapes.push(Circle(2.0))
shapes.push(Rect(3.0, 4.0))
float total = 0.0
for s in shapes
  println(s.name(), " ", s.area())   // circle 12.56636 / rect 12.0
  total = total + s.area()
end
println(total)   // 24.56636

遍历变量的静态类型是 Shape,每次 s.area() 却都命中正确的子类实现——新增一种图形只需再写一个 extends,遍历代码一行不改。这就是多态的工程价值:对扩展开放,对修改封闭。

深入细节

拾遗 1:动态派发的实现——vtable 证据

用 xl cgen 观察 22.4 的 Entity/Player,生成的关键代码是:

struct XL_Class_Entity { const struct XL6_VTable_Entity* __xl6_vt_Entity; ... long long baseValue; }; struct XL6_VTable_Entity { long long (*__xl6_vslot_score__...)(XL_Class_Entity* self); }; // 构造 Player 时:value.base.__xl6_vt_Entity = &xl6_vtable_Entity__Player; // 调用点:obj->__xl6_vt_Entity->__xl6_vslot_score__...(obj)

含虚方法的 class 在结构体头部多一个虚表指针;每个虚方法占一个函数指针槽;构造时按真实类型装入对应虚表(Player 的槽里是指向子类实现的包装函数);调用点经虚表间接跳转。这就是"声明 virtual 才有运行期开销、非虚方法保持直接调用"的成本模型——virtual 是显式订阅动态派发。

拾遗 2:为什么没有切片问题

C++ 里把子类对象按值赋给父类变量会"切片"(子类部分被砍掉)。xlang 没有这个病:class 实例是引用语义(第 21 章),Entity e = Player() 赋的是引用,堆上的 Player 实例完整无缺——所以 e.score() 能派发、e is Player 为 true、e as Player 能取回 hp。赋值、传参、装箱进 var,任何路径都不会丢失子类部分。"无切片"不是编译器做了特殊处理,而是引用语义的自然结果。

拾遗 3:缺失的 super 调用

很多语言允许子类在 override 里调用父类版本(super.score())。xlang 的 base 关键字只服务于构造函数,base.score() 会报 [hir] typed HIR value expression is missing its static type——RC0 没有提供这条通道。需要"父类逻辑 + 子类增量"时的绕行:把父类逻辑抽成一个不同名的普通方法(如 baseScore()),子类的 override 直接调它。这是设计空白而非笔误,写代码时绕开即可。

⚠️ 常见错误

以下报错全部来自真实编译器输出(文件路径已缩短)。

坑 1:覆盖方法忘了 override
# bad.xl 内容:父类有 virtual func value(),子类写同名方法 # func value() as int / return 2 > xl run bad.xl bad.xl:7:3: [sem] overriding a base member requires explicit override

原因:xlang 不允许隐式覆盖——同名即冲突,必须声明意图。正确做法:冠以 override;若想说的是"新方法"而非覆盖,改名。

坑 2:override 一个非 virtual 方法
# bad.xl 内容:父类 func value() 没标 virtual # 子类 override func value() as int ... > xl run bad.xl bad.xl:7:12: [sem] override target must be virtual

原因:只有 virtual 方法开放了改写许可。正确做法:设计父类 API 时预判扩展点,把允许改写的方法标 virtual;事后发现要改,回父类补 virtual。

坑 3:覆盖 final 方法
# bad.xl 内容:父类 final virtual func value() # 子类 override func value() as int ... > xl run bad.xl bad.xl:7:12: [sem] cannot override a final class member

原因:final 是父类作者的明确封印。正确做法:尊重封印,用组合(持有一个父类实例)而非继承来扩展行为。

坑 4:父类 create 带参却忘了 base(...)
# bad.xl 内容:父类 create(int seed),子类 create 里只给自己字段赋值 > xl run bad.xl bad.xl:9:3: [sem] 父类 create 需要参数,子类 create 必须显式调用 base(...) # 变体:子类根本没写 create—— bad.xl:7:7: [sem] 父类 create 需要参数,子类必须声明 create 并显式调用 base(...)

原因:父类部分的初始化参数只能由子类转交,编译器不会猜。正确做法:子类 create 形参里带上父类需要的值,create 体内调 base(seed)。

坑 5:在普通方法里调 base(...)
# bad.xl 内容: # func reset() as int / base() / return 0 > xl run bad.xl bad.xl:7:9: [sem] base(...) 只能在 class 的 create 体内使用

原因:base 是构造链专用语法,不是"重新初始化父类"的开关。正确做法:重置逻辑写成父类的普通方法(如 baseReset()),子类方法调 self.baseReset()。

本章小结

主题规则要点
extends单继承;子类继承字段与方法父类不存在报 [sem] 未知父类
base(...)父类 create 带参则子类必须显式接力只能在 create 体内;不必首句;按重载解析
三件套覆盖必 override;目标必 virtual;final 封印签名须精确;private/static 不参与;override 链可接力
动态派发父类引用 + 虚方法 → 按动态类型选实现vtable 实现;字段与非虚方法按静态类型解析
protected外部禁止,子类内部放行private=自己用,protected=家族用,public=大家用
is/asis 沿链回答 true;as 向下转型as 失败静默 fatal(退出码 4),必须先 is 守卫
字段父类字段继承;同名遮蔽允许遮蔽是坏味道;无 super 调用语法(RC0 空白)
构造析构链构造祖父→孙,析构孙→祖父无切片:引用语义保住完整子类实例

复习题

✍️ 先遮住答案想一想
  1. 父类有 create(int seed),子类什么情况下必须写 create 并调 base(...)?哪两种写法会各得哪条报错?
  2. 覆盖父类方法的三条硬性规则(关键字、目标、签名)分别是什么?
  3. Entity e = Player() 后,e.score() 走谁的实现?e.hp 为什么编译不过?
  4. private 与 protected 的差别在哪?分别什么场景用?
  5. e as Player 失败时程序行为是什么?标准防护写法?
  6. 子类声明与父类同名的字段会怎样?为什么应避免?
参考答案
  1. 父类 create 带参时子类必须显式接力。子类有 create 但没调 base:[sem] 父类 create 需要参数,子类 create 必须显式调用 base(...);子类没写 create:[sem] 父类 create 需要参数,子类必须声明 create 并显式调用 base(...)。
  2. 必须写 override(缺则 [sem] overriding a base member requires explicit override);目标必须是 virtual(否则 [sem] override target must be virtual);签名必须精确一致(否则 [sem] override requires an exact base member signature)。
  3. 走 Player 的实现——虚方法按动态类型派发。e.hp 报 [sem] unknown class/union field:引用的静态类型决定可见接口,Entity 的接口里没有 hp。
  4. private 只限本类内部,protected 额外放行子类。自己用的实现细节用 private;留给子类参与的扩展点用 protected。
  5. 直接 fatal 退出(退出码 4),无异常无提示。标准写法:if e is Player then / Player p = e as Player / ... end。
  6. 编译通过,行为是遮蔽(c.value 读子类那份)。但父类那份仍在实例里被父类构造维护,两份字段容易改错读错——换名字。

编程练习

练习 1:动物合唱团

编写 animals.xl:class Animal 带 virtual speak();Dog/Cat/Duck 各自 override。造一个 array<Animal> 混装三种动物,遍历合唱,输出每种动物的叫声。

参考思路:对照 22.9 的多态数组;speak() 返回 string 即可;体会"新增动物不改遍历代码"。

练习 2:带构造链的账号体系

编写 users.xl:class User 有 create(string name);class Admin extends User 增加 level 字段,create(string name, int level) 里用 base(name) 接力,再写 virtual role() / override role() 让 User 答 "user"、Admin 答 "admin"。

参考思路:这是 22.2 与 22.4 的组合;注意 User 的 create 带参后,Admin 不调 base(name) 会得到坑 4 的第一条诊断——可以故意触发一次看报错。

练习 3:安全的向下转型

编写 cast.xl:函数 func heal(Entity e)——若 e 是 Player 则把 hp 加满 100 并打印"已治疗",否则打印"无法治疗"。用 Player 实例与裸 Entity 实例各调一次。

参考思路:is 守卫 + as 转型(22.6 标准姿势);去掉 is 守卫直接 as,对裸 Entity 实例会得到静默 fatal——在注释里记录这条实测结论。