《TypeScript编程入门》6.2 继承、抽象类与多态

本节讲解 TypeScript 中类的继承与多态:如何用 extends 与 super 复用父类行为,方法重写时 override 关键字和 noImplicitOverride 选项如何防止漏改,抽象类与抽象方法怎样把公共流程固定下来而把差异留给子类,以及多态与里氏替换原则在真实代码中的体现。读完本节,你能用抽象类设计可扩展的骨架代码,并认清继承的代价与何时该改用组合。

本节目标:掌握 extends 与 super 的用法与字段初始化顺序,学会用 override 关键字与 noImplicitOverride 守住重写契约,理解抽象类如何把「流程固定、步骤开放」的结构表达出来,并用里氏替换原则判断一次继承是否合理。读完本节,你能设计出可扩展的骨架代码,也知道什么时候该放弃继承改用组合。

6.2 继承、抽象类与多态

上一节我们写了一个个独立的类,每个类自给自足。但真实系统里,大量类型共享同一套行为,只是细节不同:所有「支付方式」都要生成订单号、都要记账,只有扣款那一步各不相同。继承就是为这种「共性 + 差异」结构准备的语言机制。TypeScript 的继承完全沿用 JavaScript 的 extends,额外提供了 override 检查与 abstract 关键字,把「哪些是刻意重写的、哪些是必须实现的」写进类型里。

6.2.1 extends 与 super

先看一个最小例子:

class Animal {
  constructor(public name: string) {}

  move(distance: number = 0): void {
    console.log(`${this.name} 移动了 ${distance} 米`);
  }
}

class Dog extends Animal {
  bark(): void {
    console.log(`${this.name} 汪!`);
  }
}

const d = new Dog("旺财");
d.bark();      // 旺财 汪!
d.move(10);    // 旺财 移动了 10 米

Dog 自动获得 Animal 的 name 字段和 move 方法。extends 在类型层面建立的是「子类型」关系:Dog 可以赋给 Animal,反之不行。

子类有构造函数时,必须先调 super():

class Cat extends Animal {
  constructor(name: string, public indoor: boolean) {
    super(name);       // 必须在访问 this 之前调用
    // 若把 super 放后面,或忘了写:error TS2377 / TS2554
  }

  move(distance: number = 0): void {
    console.log("猫咪踮脚走");
    super.move(distance); // 复用父类实现
  }
}

三条硬规则:

  1. 派生类的构造函数里,访问 this 之前必须调用 super(),否则报 error TS17009: 'super' must be called before accessing 'this' in the constructor of a derived class.
  2. 派生类若不写构造函数,编译器会自动生成一个转发全部参数的隐式构造函数。
  3. 派生类若写了构造函数,就必须自己调 super(),编译器不会替你补。漏调会报 error TS2377: Constructors for derived classes must contain a 'super' call.

super.move(distance) 表示「调用父类版本的方法」,这是复用而非替换。没有它,父类逻辑就彻底丢了。

6.2.2 方法重写与 override 关键字

子类定义与父类同名的方法就叫重写(override)。默认情况下 TypeScript 允许你随手重写,这带来一个隐患:父类改了方法名,子类的「重写」就悄悄变成了「新增一个没人调用的方法」。

TypeScript 4.3 引入了 override 关键字与 noImplicitOverride 选项来堵这个洞:

{
  "compilerOptions": {
    "noImplicitOverride": true
  }
}

开启后,凡是重写父类成员的,必须加 override:

class Animal {
  move(distance: number = 0): void {
    console.log(`移动 ${distance} 米`);
  }
}

class Dog extends Animal {
  override move(distance: number = 0): void {
    console.log("小跑");
    super.move(distance);
  }
}

两个方向的错误都会被抓住:

class Bird extends Animal {
  move(distance = 0): void {}   // error TS4114: This member must have an 'override' modifier because it overrides a member in the base class 'Animal'.
}

class Fish extends Animal {
  override swim(): void {}      // error TS4113: This member cannot have an 'override' modifier because it is not declared in the base class 'Animal'.
}

建议:新项目一律开启 noImplicitOverride。 它把「我是有意重写」和「我手滑写了个重名方法」区分开来,是重构时最有价值的一道保险。注意 override 只对成员方法/属性有效,不能修饰 static 成员(静态成员不参与多态)。

6.2.3 字段初始化顺序:一个真实的坑

派生类的字段初始化发生在 super() 调用返回之后。这意味着父类构造函数里如果调用了会被子类重写的方法,读到的可能是「还没初始化」的子类字段:

class Base {
  constructor() {
    this.setup();          // 危险:此时子类字段还没赋值
  }
  setup(): void {
    console.log("Base setup");
  }
}

class Derived extends Base {
  value = 42;              // 这一行在 super() 之后才执行

  override setup(): void {
    console.log(`value = ${this.value}`);
  }
}

new Derived();
// 实际输出:value = undefined
// 而不是很多人以为的 value = 42

原因很直白:new Derived() 的执行顺序是「先跑 Base 的构造函数(里面调了被子类重写的 setup)→ 再回来给 value 赋 42」。这是 JavaScript 的既有语义,TypeScript 只是忠实地把它反映在类型里。

规避方式有两条:

  • 不要在构造函数里调用可被重写的方法。这是最根本的一条,很多语言规范都把它写进 lint 规则。
  • 实在需要在构造时初始化,就把逻辑放到显式的 init() 里,由调用方在对象构造完成后触发。

顺带一提,参数属性也有同样的时序问题:constructor(public value = 42) 展开的赋值同样在 super() 之后。

6.2.4 抽象类与抽象方法

有时候父类只描述「要做什么」,无法给出「怎么做」。比如「数据导出器」知道流程是「取数据 → 格式化 → 写出」,但格式化方式完全取决于子类。这时用 abstract:

abstract class Exporter {
  // 抽象方法:只有签名,没有实现,子类必须实现
  abstract format(rows: Record<string, unknown>[]): string;

  // 具体方法:流程固定,调用抽象方法作为「钩子」
  export(rows: Record<string, unknown>[]): void {
    const payload = this.format(rows);
    this.write(payload);
  }

  protected write(payload: string): void {
    console.log(`写出 ${payload.length} 个字符`);
  }
}

两个关键点:

  1. 抽象类不能直接实例化:new Exporter() 报 error TS2511: Cannot create an instance of an abstract class.
  2. 抽象成员不能有实现,子类必须提供实现,否则报 error TS2515: Non-abstract class 'CsvExporter' does not implement inherited abstract member format from class 'Exporter'.

子类这样写:

class CsvExporter extends Exporter {
  override format(rows: Record<string, unknown>[]): string {
    const headers = Object.keys(rows[0] ?? {});
    const lines = rows.map((r) => headers.map((h) => String(r[h] ?? "")).join(","));
    return [headers.join(","), ...lines].join("\n");
  }
}

class JsonExporter extends Exporter {
  override format(rows: Record<string, unknown>[]): string {
    return JSON.stringify(rows, null, 2);
  }
}

const rows = [{ id: 1, name: "小明" }, { id: 2, name: "小红" }];
new CsvExporter().export(rows);
// 写出 26 个字符
new JsonExporter().export(rows);
// 写出 82 个字符

抽象类还能同时声明抽象属性:

abstract class Job {
  abstract readonly name: string;
  abstract run(): Promise<void>;
}

注意 abstract 只能出现在 abstract class 里:普通类里写抽象方法会报 error TS1244: Abstract methods can only appear within an abstract class.,写抽象属性则报 error TS1253: Abstract properties can only appear within an abstract class.。另外抽象类可以有构造函数,也可以有私有成员——这两点是与接口(interface)最实际的区别,下一节会系统对比。

6.2.5 多态与里氏替换

多态的意思是:调用方只依赖父类型,运行时由真实子类型决定行为。

function exportAll(exporters: Exporter[], rows: Record<string, unknown>[]): void {
  for (const e of exporters) {
    e.export(rows);   // 这里不关心是 Csv 还是 Json
  }
}

exportAll([new CsvExporter(), new JsonExporter()], rows);
// 写出 26 个字符
// 写出 82 个字符

exportAll 的参数类型是 Exporter[],却能装下任何子类实例。这正是继承在工程上的主要价值:把「变化点」封在子类里,让调用方只面对稳定接口。

多态能成立的前提是里氏替换原则(Liskov Substitution Principle):子类实例必须能替换父类实例而不破坏调用方预期。具体到 TypeScript,编译器帮你守住了签名层面的一半:

class Good extends Exporter {
  // 参数变宽、返回变窄是安全的
  override format(rows: readonly Record<string, unknown>[]): string {
    return rows.length ? "ok" : "";
  }
}
  • 参数类型可以变宽(父类收 Record,子类收 object)——这叫逆变,安全。
  • 返回类型可以变窄(父类返 string,子类返字面量 "ok")——这叫协变,安全。
  • 反过来会报错:子类返回 string | null 而父类声明 string,报 error TS2416: Property 'format' in type 'Good' is not assignable to the same property in base type 'Exporter'.

但编译器管不了语义层面:如果 format 在父类的契约是「不抛异常」,子类却抛了,编译照样通过而调用方会炸。里氏替换的后半段要靠人来守。判断一次继承是否合理的经典自检是:子类能否说「我是一个更具体的父类」。CsvExporter 是 Exporter(说得通);Stack 继承 Array(说不太通——栈不该有 shift),后者应该用组合。

6.2.6 模板方法:抽象类的典型用法

上面 Exporter 用的正是模板方法模式:父类把算法骨架写死,把可变步骤留成抽象方法或可重写的钩子。再看一个更贴近业务的例子——重试策略:

abstract class RetryableTask<T> {
  protected abstract execute(): Promise<T>;

  protected shouldRetry(error: unknown, attempt: number): boolean {
    return attempt < 3 && !(error instanceof RangeError);
  }

  async run(): Promise<T> {
    let lastError: unknown;
    for (let attempt = 1; ; attempt++) {
      try {
        return await this.execute();
      } catch (error) {
        lastError = error;
        if (!this.shouldRetry(error, attempt)) {
          throw error;
        }
        await new Promise((r) => setTimeout(r, 2 ** attempt * 100));
      }
    }
  }
}

class FetchUser extends RetryableTask<{ id: number }> {
  constructor(private readonly id: number) {
    super();
  }
  protected async execute() {
    const res = await fetch(`/api/users/${this.id}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return (await res.json()) as { id: number };
  }
}

注意这里抽象类还带了泛型参数 T,execute 的返回类型由子类在 extends 时确定——泛型与继承的配合在 泛型约束(extends) 里会展开。这种「骨架 + 钩子」的写法在框架代码里非常常见,设计模式层面的更多变体可以参考 设计模式 与 TypeScript 设计模式实践 。

6.2.7 继承的代价与「组合优先」

继承是强耦合:子类知道父类的实现细节,父类一改,所有子类都可能受影响。业界经验总结成一句话——优先使用组合,而不是继承。三条实用判据:

场景建议
确实是「is-a」关系,且子类复用父类大部分实现用继承
只是「has-a」或「uses-a」用组合(持有对方实例)
想复用多个来源的行为用 mixin(下一节)
继承层级超过三层警惕,考虑重构

同一份 Logger 能力,用组合的写法:

interface Logger {
  info(msg: string): void;
}

class Service {
  constructor(private readonly logger: Logger) {}   // 组合:注入而非继承
  handle(): void {
    this.logger.info("handling");
  }
}

Service 不关心 Logger 是谁,测试时塞一个假的进去即可——这比继承一个 BaseService 灵活得多。

6.2.8 继承与接口的关系(预告)

到这里你可能已经发现:抽象类像是「半成品类」,接口像是「纯契约」。两者的分界是本节和下一节之间的桥:

  • 抽象类可以有实现、构造函数、私有成员,但一个类只能 extends 一个父类。
  • 接口只能声明签名(TS 里接口不能有实现),但一个类可以 implements 多个接口。

所以真实工程里的常见组合是「抽象类管骨架,接口管契约」。implements 的语法、类实现接口时的检查规则、以及如何用 mixin 绕过单继承限制,正是下一节 接口实现与 mixin 的内容。

小结

  • extends 建立子类型关系,派生类构造函数里访问 this 前必须 super();不写构造函数时编译器自动补一个转发版。
  • 开 noImplicitOverride 后,重写父类成员必须加 override:漏加报 TS4114,多加重写不存在成员报 TS4113。新项目建议一律开启。
  • 派生类字段在 super() 返回后才初始化,父类构造函数里调用可重写方法会读到 undefined——不要在构造函数里调用可被重写的方法。
  • abstract 类不能实例化(TS2511),抽象成员必须由子类实现(TS2515),abstract 不能出现在非抽象类里(抽象方法 TS1244 / 抽象属性 TS1253)。
  • 多态让调用方只依赖父类型;编译器保证签名层面的协变/逆变安全,语义层面的里氏替换要靠人判断。
  • 模板方法模式是抽象类最典型的用法:父类固定流程,子类只填空。
  • 继承是强耦合,组合优先;跨多来源复用行为要靠下一节的 mixin。
  • 下一节讲 implements、接口继承与 mixin,看 TypeScript 如何用「一个类实现多个契约」补上单继承的短板。

阅读导航:上一节:6.1 类、访问修饰符与参数属性 · 下一节:6.3 接口实现与 mixin 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes