编程语言研究书库

类 — 构造器、继承、可见性与契约

类 — 构造器、继承、可见性与契约

这一章讲一件事: JavaScript 的类,在 TypeScript 手里变成了什么。 答案分两层:运行时那套(构造器、继承、this)基本原样,但类型层面多了一整套 「契约设施」——明确赋值检查、可见性修饰符、override 保险、implements、抽象类。 读完你能写出带完整类型护栏的类,并分清哪些护栏是编译期的、哪些能护到运行时。

1. 顶层全景

  一个类的类型层设施,按声明到使用排开:

  声明 ── 属性:初值?构造器赋值? readonly? ?  public/private/protected/# ?
       ── 方法:普通方法 or 箭头方法(this 绑定不同)
       ── 契约:implements 接口 / abstract 抽象类
  继承 ── extends + super()(先父后子)
       ── override(父类改名,子类立刻报错)
  使用 ── new 出实例;类名本身可以当类型用(指实例!)

  图说:主线例子是 Album 类一家——从光杆属性,到有构造器,到子类 SpecialEditionAlbum,
  最后到 implements 和抽象类。每一步都对应上面一层设施。

2. 第一幕:属性必须「有着落」

创建类用的是 class 关键字,加帕斯卡命名(每个单词首字母大写)1。 一上来就是个报错现场:

class Album {
  title: string;       // ❌ 红线
  artist: string;      // ❌ 红线
  releaseYear: number; // ❌ 红线
}

每个属性都挨骂:Property 'title' has no initializer and is not definitely assigned in the constructor(没有初值,也没有在构造器里明确赋值)2。

这是 TypeScript 的明确赋值检查(definite assignment): 类的属性不像普通变量那样「用时再赋值」,实例一创建属性就必须就位。 两条合法路:

路一:构造器赋值。 constructor 是实例创建时自动跑的方法,专管初始状态3:

class Album {
  title: string;
  artist: string;
  releaseYear: number;

  constructor() {
    this.title = "Loop Finding Jazz Records";
    this.artist = "Jan Jelinek";
    this.releaseYear = 2001;
  }
}

const album = new Album();
console.log(album.title); // Loop Finding Jazz Records

this 指当前实例;new 负责造实例,constructor 负责填初值4。 这里属性写死在构造器里,每个实例都一样——所以引出路二:构造器收参数。

constructor(opts: {title: string; artist: string; releaseYear: number}) {
  this.title = opts.title;
  this.artist = opts.artist;
  this.releaseYear = opts.releaseYear;
}

new Album({title: "Loop Finding Jazz Records", artist: "Jan Jelinek", releaseYear: 2001});

参数照常标注(第 04 章的规则在类里不变),opts 对象的形状就是类的「入场券」5。

补两条关于「明确赋值检查」的细则,实战中都会撞上:

细则一:它由 strict 家族的一个成员执行。 这条检查的全名是 strictPropertyInitialization,是 strict 总开关的子开关之一(第 15 章)。 关掉它,上面的红线全部消失——属性可以「没初值也没赋值」,用到时就是 undefined。 这也是「strict 关掉等于换语言」的一个具体证据:同一份类代码, 两种配置下红线数量差一大截。

细则二:「赋过值」不等于「明确赋值」。 检查器认的是「所有路径都赋值」, 不是「某条路径赋过」:

class Album {
  title: string;

  constructor(short: boolean) {
    if (short) {
      this.title = "Short";
    }
    // else 分支没赋 → 红线仍在:存在一条路径,实例出生时 title 没着落
  }
}

修复要么补上 else 分支,要么给属性初值(title = ""),要么标 title? 为可选。 「所有路径都有着落」这条标准,和第 06 章收窄的「所有分支都被照顾」是同一种 控制流思维——类型系统对控制流的跟踪,在变量、函数、类属性上反复出现。

还有一个推断红利:构造器里给 this.xxx 赋了值,属性的类型标注可以省—— TypeScript 从赋值处推断6。不过实际代码里标注常常留着:它在类体上起文档作用,一眼读完形状7。

类可以当类型用——这是 TypeScript 里类最有趣的性质:

function printAlbumInfo(album: Album) { /* 读 album.title ... */ }
printAlbumInfo(sixtyNineLoveSongsAlbum);

Album 在这个位置指实例的类型(有 title、artist、releaseYear 的那个对象), 不是类本身。传实例进来,形状匹配即通过——类名就这样横跨了「值」和「类型」两个世界 (这个世界观的完整展开是第 13 章的专题)8。

这个例子的运行结果也值得看一眼,因为它演示了「类作为类型」的用法全貌:

function printAlbumInfo(album: Album) {
  console.log(
    `${album.title} by ${album.artist}, released in ${album.releaseYear}.`,
  );
}

printAlbumInfo(sixtyNineLoveSongsAlbum);
// 输出:69 Love Songs by The Magnetic Fields, released in 1999.

参数标注 album: Album 读作「album 必须是 Album 的实例形状」—— 在函数体内,album.title 之类的访问全部有补全、有检查; 调用处传任何形状吻合的对象都能过(结构化类型的老规则,第 07 章)8。 类在这个用法里就是「能 new 的接口」:接口有的功能(形状合同)它都有, 多一个 new 的能力。反过来问「什么时候用类当类型、什么时候用接口」, 答案常在第 6 幕:要契约用接口,要实例 + 继承才用类。

3. 第二幕:属性的全套修饰符

主线例子的 Album 继续加装备。属性的修饰符一张表:

修饰符干什么层面
初值(title = "Unknown Album")类属性初始化器,默认值的来源运行 + 类型
readonly构造后不许改类型
?可选,构造器可不赋类型
public / private / protected可见性类型(除 # 外)
#真私有运行 + 类型

属性初始化器写在类体里,先于构造器执行——所以构造器里赋的值会盖掉默认值:

class User {
  name = "Unknown User";   // 先跑:name 先是 "Unknown User"
  constructor() {
    this.name = "Matt Pocock"; // 后跑:盖掉
  }
}

const user = new User();
console.log(user.name); // "Matt Pocock"

这个执行顺序值得亲手验证一次,因为它解释了类里一大类「为什么我的默认值没生效」 的事故:初值写了,构造器也写了,最后生效的是构造器——后写覆盖先写, 和变量赋值的直觉一致,只是发生在一个你可能没意识到「有两步」的地方9。 顺序反过来的诉求(「构造器先、初值兜底」)在类里不存在;要「可覆盖的默认」, 正确姿势是初值写默认、构造器可选地覆盖,正如上面的例子。

类型从初值推断,想标也行(name: string = "Unknown User"),和第 04 章变量的规则同款9。

readonly 和 ? 直接复用第 07、04 章的语义:readonly 属性构造后锁定(注意是类型层面); ? 属性构造器里可以不赋10。

两个修饰符在类上的完整形态,并排看一眼就够:

class Album {
  readonly title: string;    // 声明后只在构造器里有一次赋值机会
  artist?: string;           // 构造器可以不赋,实例上 artist 可能是 undefined
  releaseYear = 0;           // 有初值:明确赋值检查直接满足
}

三行各代表一种「有着落」的实现:readonly 靠构造器的一次性赋值, ? 靠「允许没有」,初值靠「自带默认」——明确赋值检查对三种都满意10。 注意 readonly 与初值可以叠加(readonly releaseYear = 0),意思是 「默认 0,且任何人(包括构造器之外)都别想改」;而 readonly 与 ? 叠加时, 构造器不赋值也合法,属性就是 string | undefined——第 04 章「可选属性读出来是 T | undefined」的规则,在类上逐字复用。

这条「逐字复用」值得单独点一句:类的属性系统和第 04 章的对象属性系统 是同一套规则的两次部署——?、readonly、T | undefined 的语义完全一致, 连报错文本都同宗(missing / required 一族)。学完对象再学类,增量只有三个: 明确赋值检查(类特有)、修饰符(可见性家族)、方法/继承(行为层); 其余都是旧知识换了个花括号。这也是本章读起来比预期快的原因—— 它的一半重量来自你已经付过的学费。

第九章到这里,五幕正文、五代剧本、一个总装样本,以及与之配套的出处地图, 全部齐了。类这一章的拆解也到此结束——第十章将清点 TypeScript 自己发明的运行时特性, 从「参数属性」这个和本章关系最近的开始:它能让构造器的样板代码再省一层, 但代价恰好是本章反复强调的那件事——你写的和运行的不再逐字一致。预习这个问题的 最好方式,是把本章合成样本的构造器改写成参数属性写法,再对比两边的编译产物; 看懂差异的那一刻,第 10 章的总立场你其实已经提前拿到了。

private:编译期的锁;#:运行期的墙

class Album {
  private rating = 0;
}

new Album().rating; // ❌ Property 'rating' is private

private 属性类外不可访问——但只挡编译期。@ts-ignore 一压,运行时照样读到 0: private 只是给编译器看的注解,编译擦除后荡然无存11。

要真私有,用 # 前缀——这是 ECMAScript 标准的一部分(不是 TypeScript 发明), 所以它在运行时也是私有的:

class Album {
  #rating = 0;
}

new Album()["#rating"]; // ❌ 连字符串动态访问都不行,得到 undefined

方括号字符串访问撞在两条报错上:"#rating" 不能用来索引 Album,且属性不存在12。 选择建议很直接:需要私有就优先 #——它两头都真;private 适合「表达意图、 但允许测试等场合用 @ts-ignore 突破」的场景13。

私有性的「越狱」测试:两个字段的两种命运

原书给了一个非常有说服力的对照实验,值得完整走一遍——同一个访问动作, 分别施加在两个字段上:

const album = new Album();

// 对 private 字段:编译期报错,但可以越狱
console.log(album.rating);        // ❌ Property 'rating' is private
// @ts-ignore                        ← 压错指令(第 12 章详解)
console.log(album.rating);        // ✅ 编译通过,运行时输出 0

// 对 # 字段:编译期报错,运行时同样走不通
console.log(album.#rating);       // ❌ not accessible outside class
console.log(album["#rating"]);    // ❌ 动态字符串也不行!

三个结果各自说明一件事。第一,private 的墙是纸糊的:@ts-ignore 一压, 编译期检查让路,运行时属性真的在那里,输出 011——这再次印证 「类型擦除之后一切类型层面的限制归零」。第二,# 是砖墙:动态字符串访问 ["#rating"] 撞到的是「属性不存在」——因为 #rating 根本不是这个对象的属性名, 它是类内部的私有槽位,运行时的对象上压根没有这个名字12。 第三,选择有了判据:墙要挡谁? 挡团队成员(防误用)→ private 够用,还更灵活; 挡运行时(防反射、防序列化泄露、防外部代码)→ 只有 # 合格。

这也是第 03 章「薄语法」清单上的又一例外预览:# 前缀会在编译产物里生成 真实的 WeakMap(或同等机制),是「会生成运行时代码」的 TypeScript 特性—— 但它出自 ECMAScript 标准,不算 TypeScript 的私有发明,所以不在第 10 章的 relic 名单里。

构造器的可选参数与兜底值

第 05 章函数参数的可选、默认值语法,在构造器里照常适用。CanvasNode 第 3 代的构造器是个标准样本:

class CanvasNode {
  x: number;
  y: number;

  constructor(position?: {x: number; y: number}) {
    this.x = position?.x ?? 0;   // 没传 position → 0
    this.y = position?.y ?? 0;
  }
}

new CanvasNode();              // ✅ x=0, y=0
new CanvasNode({x: 10, y: 20}); // ✅ x=10, y=20

position? 让整个参数可省;?. 防 null/undefined 上取值;?? 给兜底值—— 三个运算符各管一段,组合出「可省但有着落」的构造器14。前面第 2 节的 「明确赋值检查」在这里完全满足:无论调用方传不传,this.x、this.y 出构造器时必有值。

protected:给子类开的门

介于 public 和 private 之间:protected 属性类外不可访问,但子类内部可以15。

class Album {
  protected title = "Brown Sugar";
}

// 子类里:this.title ✅
// 类外:album.title ❌

它服务于第 4 幕的继承:父类的内部细节,只对自己的后代开放。

4. 第三幕:方法与 this

方法两种写法,调用效果一样,运行时的 this 不一样:

class MyClass {
  location = "Class";

  arrow = () => { console.log("arrow", this); };
  method() { console.log("method", this); }
}

const myObj = {
  location: "Object",
  arrow: new MyClass().arrow,
  method: new MyClass().method,
};

myObj.arrow();  // this = {location: 'Class'}  ← 定义处的实例
myObj.method(); // this = {location: 'Object'}  ← 调用处的对象

箭头方法的 this 绑死在定义它的实例上(箭头函数的天性:出生时捕获 this); 普通方法的 this 看调用点(作为 myObj 的方法调,this 就是 myObj)16。 原书的建议:一般场景用箭头方法定义类方法,能避开 this 失控的经典坑; 两种写法的其余差异超出本书范围17。

把这两种写法的差异收成一张对照表,选择时对着看:

维度箭头方法(move = () => {})普通方法(move() {})
this 绑定定义处实例(词法捕获,永不变)调用处对象(运行时决定)
定义位置实例字段(每个实例一份)原型(所有实例共享)
被抽出后当回调安全(this 仍指实例)危险(this 变成回调发起者)
子类可 override?不能(它是字段不是方法)能
可被 super.xxx 调用?不能能

日常默认按原书建议走箭头;出现「子类要重写这个方法」或「内存敏感、实例极多」 的场景,换普通方法。一个类里两种混用也完全合法——按方法逐个选择, 不必全局统一;统一是团队风格问题,不是技术约束。

这张表也预告了第 10 章的一个判定:箭头方法字段在编译产物里是构造器里的 赋值语句(每个实例执行一次),普通方法则在类体外挂到原型上—— 「箭头方法多耗一点启动成本、省很多 this 事故」就是这个差异的账本, 而「参数属性」(第 10 章主角)会把这两种构造代码进一步折叠。

判断(我们的,不是书里的): 「箭头方法优先」要打个折扣——箭头方法是实例属性, 每个实例各自持有一份函数字段,内存上是 N 份拷贝;普通方法挂在原型上,所有实例共享一份。 高频实例化(成千上万个对象)时这个差异可测。实际建议:对外回调(事件处理器、 setTimeout 回调)用箭头方法防 this 丢失;内部方法用普通方法。 原书把「内存差异超出范围」跳过了,这是教材的合理取舍,但读者该知道代价在哪。 如果错,会错在: 现代引擎对闭包的优化越来越好,N 份拷贝的实际开销在多数应用里 小到可以忽略——那么「箭头优先」确实是更省心的默认,我们的折中建议反而过度精细。

5. 第四幕:继承、super 与 override

主线例子的家族扩张:SpecialEditionAlbum extends Album,加一个 bonusTracks 属性18。

class SpecialEditionAlbum extends Album {
  bonusTracks: string[];

  constructor(opts: {
    title: string;
    artist: string;
    releaseYear: number;
    bonusTracks: string[];
  }) {
    super(opts);                    // ← 先让父类构造
    this.bonusTracks = opts.bonusTracks; // ← 再补自己的
  }
}

子类构造器两条铁律,原书点得明白:

  1. 参数形状覆盖父类 + 新增——父类要什么,子类构造器也要什么( BonusTracks 另算);
  2. super() 必须先调——它触发父类构造器,父类定义的属性靠它初始化; 先 this.bonusTracks = ... 再 super 是非法的19。

两条铁律的背后是同一条运行时事实:实例在被 new 出来的那一刻, 内存里的字段位次已经按继承链排好——父类的部分由父类构造器填,子类的部分 在 super() 返回后才轮到。 顺序反过来的话,子类在父类还没铺好的地基上盖楼, this.bonusTracks 指向的是一片尚未初始化的区域。TypeScript 把这个运行时约束 翻译成了编译期错误,所以「super 必须先调」其实是 JavaScript 的老规矩, 不是 TypeScript 的新发明19。

还有一个容易漏的细则:super 的参数不必「照抄」父类构造器的参数表, 只要传进去的实参满足父类构造器的形状就行。上面例子里子类构造器收一个 「父类字段 + bonusTracks」的合并对象,然后把整个 opts 递给 super(opts)—— 父类只挑自己认识的三个字段,bonusTracks 被忽略。形状是合同,多余不算违约 (第 13 章「开放对象」的又一实例)19。

实例化照常:new SpecialEditionAlbum({...全部四个属性})20。

override:给继承装上防漂移保险

子类重写父类方法,天经地义。但有个隐患:哪天父类把 displayInfo 改名成 displayAlbumInfo,子类的「重写」会静默失效——没人发现它重写的对象没了。 解法是给重写显式挂牌:

class SpecialEditionAlbum extends Album {
  override displayInfo() { /* ...加打 bonus tracks */ }
}

挂了 override 之后,父类那边一改名,子类立刻报错:「你声明的重写目标不存在了」21。 还能用 tsconfig 的 noImplicitOverride: true 把挂牌变成强制——重写必须写 override22。 这个开关在第 15 章的推荐配置里有下文。

override 防的到底是什么事故

把漂移场景走完整,override 的价值就从「好习惯」变成「必需品」:

  第 0 天:父类 Album 有 displayInfo();子类 SpecialEditionAlbum
           重写了它,加了 bonus tracks 的输出
  第 100 天:有人把父类方法改名为 displayAlbumInfo(理由:更准确)
           没有 override 的世界:子类的 displayInfo 变成一个普通方法,
           从此再没人调用它——多打的 bonus tracks 输出静默消失,无人察觉
  有 override 的世界:改名那一刻,子类的 override displayInfo() 立刻报错
           「此重写目标不存在」——事故在编译期、在改动现场被抓住

  图说:override 是「跨层引用」的锚点。继承天然制造跨层依赖,
  而跨层依赖最大的风险就是上层改名下层不知——override 把这条暗线变成了明线。

这个事故模式在大型代码库里极常见(重构父类的人通常不知道所有子类), noImplicitOverride: true 的意义就是把「记得挂牌」从人的责任心, 换成编译器的强制检查——和第 15 章 strict 家族是同一个哲学22。

6. 第五幕:契约——implements 与抽象类

extends 解决「复用实现」;还有一类需求是只定契约、不管实现。两个工具。

implements:对接口的承诺

interface IAlbum {
  title: string;
  artist: string;
  releaseYear: number;
  trackList: string[];
}

class Album implements IAlbum {
  title: string;
  artist: string;
  releaseYear: number;
  // trackList 缺失!
}
// ❌ Class 'Album' incorrectly implements interface 'IAlbum'.
//    Property 'trackList' is missing...

implements 把接口变成类的必须兑现的合同:缺一个属性,定义处当场报错23。 补上加法:一个类可以实现多个接口(implements A, B),接口之间还能 extends 组合。 命名插曲:接口名前的 I 前缀(以及 type 的 T)是匈牙利记法的残留,原书明确 不推荐普遍使用——只在「接口和同名类撞车」时才用来区分24。

implements 的报错链:为什么合同在定义处验收

把上面那个报错当教材再读一遍:

Class 'Album' incorrectly implements interface 'IAlbum'.
  Property 'trackList' is missing in type 'Album' but required in type 'IAlbum'.

两行各管一段:
  行 1(结论):实现不正确 —— 谁说的?合同方 IAlbum
  行 2(明细):缺 trackList —— 它在 Album 里是「不存在的属性」,
              在 IAlbum 里是「必填成员」——两边的要求对不上账

  图说:implements 的检查在「类声明」这个点一次性完成——
  不是在每个使用点,而是在定义点。错一次,修一处,全部调用方受益。

这个「定义处验收」的性质是 implements 和 extends 的关键差异之一: extends 把父类的实现带过来,implements 只把形状的要求立起来、 实现全得自己写。所以「类 + implements 接口」的组合,本质上是在类世界里 复用第 04–07 章的接口工具(单一事实源、声明合并、交叉组合),接口照旧是类型层的主角。

abstract:契约 + 半成品实现

抽象类夹在「接口(纯契约)」和「普通类(完整实现)」之间:

abstract class AlbumBase {
  title: string;
  // ...普通属性和方法照常写:addTrack() { this.trackList.push(track) } ...

  abstract addReview(author: string, review: string): void; // ← 没有方法体!
}

两个特征:

  1. 不能直接实例化——new AlbumBase(...) 报错(Cannot create an instance of an abstract class);它只能被继承25。
  2. 抽象方法只声明、不实现——任何子类必须把它实现掉,否则子类自己也得标 abstract26。

和 implements 对比,原书给了定位:想法类似,但抽象类可以带实现细节—— 公共代码(属性初始化、addTrack 这类共享方法)放在抽象类里只写一次, 子类只补各自的差异27。这是「模板方法」模式的类型化形态。

原书对抽象类的定性值得原文记住:「模糊了类型和运行时之间的界线」—— 它一半是类型(不能实例化、带抽象成员),一半是运行时(真类、有实现代码)。 这个两重性正是它在三个工具里最难归位的原因,也是它能力的来源: 「共享实现」是运行时的需求,「必须补完」是契约的需求,抽象类一个语法同时管两件。

7. 练习里的完整样本:CanvasNode 一家

原书的练习用同一个 CanvasNode(画布节点)类逐代演进,把本章设施串成了一部家族史, 值得整体走一遍:

第 1 代(8-1):有着落的只读属性。 测试要求 x、y 默认 0,且 @ts-expect-error 断言「x = 10 应该报错」(只读)。两种解法对应第 2 节的两条路: 构造器赋值(readonly x: number + constructor)或属性初始化器(readonly x = 0)28。

这道题的两种解法值得并排细看,因为它们的「运行顺序」不同:

// 解法 A:构造器赋值——先声明「必有」,再在构造器里兑现
class CanvasNode {
  readonly x: number;
  readonly y: number;
  constructor() {
    this.x = 0;
    this.y = 0;
  }
}

// 解法 B:属性初始化器——声明的同时带着落
class CanvasNode {
  readonly x = 0;
  readonly y = 0;
}

解法 B 更短,但有个隐蔽的能力边界:初始化器只能是静态值; 如果默认值需要「算出来」(比如依赖参数),就得回到解法 A。 日常选 B(短、直),需要计算时选 A——选择依据还是那句「初值是静态的还是动态的」28。

第 2 代(8-2):方法。 move(x, y) 更新坐标——普通方法或箭头方法皆可29。

第 3 代(8-3):getter。 内部存 x/y,对外暴露 position: get position() { return {x: this.x, y: this.y}; }——外部像读普通属性一样读 canvasNode.position,内部是方法30。

第 3 代构造器还顺手演示了一个三件套组合,值得单独留档:

constructor(position?: {x: number; y: number}) {
  this.x = position?.x ?? 0;
  this.y = position?.y ?? 0;
}

? 让参数可省、?. 让「没传时取值」不炸、?? 给兜底——三个符号各管一段, 拼出「可省但必有着落」的构造器。这是第 05 章函数参数语法在构造器里的原样复用: 类不发明新的参数规则,它只是函数的一个特例31。

第 4 代(8-4):setter + 真私有。 属性换成 #x/#y 后,外部想写坐标, 只能走 set position(pos) { this.#x = pos.x; this.#y = pos.y; }。 setter 的参数类型不用标——TypeScript 从对应 getter 的返回类型推断(配对 getter/setter 的类型推断,很实用)32。

第 5 代(8-5):拆分继承。 需求变了:另一个 Shape 类只要 x、y、move, 不要 viewMode。把公共部分拆成基类,CanvasNode extends 它、只加 viewMode; 构造器里 super(options) 把公共参数传给父类33。原书还提示:Shape 可以做成 abstract class——「只要坐标和移动能力、但不该独立存在」的对象,正是抽象类的适用形状。

第 5 代的决策链值得完整记录,因为它就是「什么时候拆继承」的判据本身:

  信号出现:第二个类(Shape)要复用 CanvasNode 的一部分(x、y、move),而非全部
    │
    ├─ 误用路径:Shape extends CanvasNode——白捡一个用不上的 viewMode,
    │           还背上了「Shape 是 CanvasNode 的一种」的语义谎言
    └─ 正解路径:公共部分上提为基类(或抽象类),两个类各自 extends
                ——复用是「共同的祖先给的」,不是「谁是谁的儿子」

  图说:extends 的第一问永远是「子类是不是父类的一种」——
  「只要一部分能力」时,答案是「否」,该拆公共基类而不是硬继承[^31]。

「是一种吗」这条判据(Liskov 替换原则的口语版)能挡掉大部分继承滥用; 剩下拿不准的,第 6 幕的接口组合(implements A, B)通常是更松的替代。

五代的演进线很清晰:属性有着落 → 加行为 → 控制访问(getter/setter)→ 真私有 → 按职责拆继承。这是一段可以直接搬进项目的重构剧本。

展开第 3、4 代:访问器(getter/setter)的机制

getter 和 setter 是第 3、4 代的核心,值得把机制讲透——它们是「方法」, 但对外表现得像「属性」:

class CanvasNode {
  x: number;
  y: number;

  get position() {          // 读 position 时,实际在调这个方法
    return {x: this.x, y: this.y};
  }

  set position(pos) {       // 写 position 时,实际在调这个方法
    this.x = pos.x;
    this.y = pos.y;
  }
}

使用时完全没有方法的样子:

const node = new CanvasNode();
console.log(node.position.x); // 0 —— 读,触发 getter
node.position = {x: 5, y: 6}; // 写,触发 setter

访问器的意义在于内部表示与外部接口可以不同:内部拆存 x、y 两个数, 对外暴露一个 position 对象;将来内部改成极坐标,外部代码一行不动。 这是封装的标准定义——类的内部细节变了,行为契约不变30。

setter 的 pos 参数没写类型,这是配对推断:同一属性的 getter 返回 {x: number; y: number},setter 参数就自动是这个形状32。少了手写标注, 也少了签名漂移的机会。

访问器还有两个实战要点,原书散在练习里没有集中讲,补在这里:

要点一:只有 getter、没有 setter 的属性是「只读外观」的最简形式。 get position() 存在而 set 不存在时,node.position = ... 直接报错—— 不必依赖 readonly 字段,访问器自己就是访问控制。第 4 代把字段升级为 #x 之后, 「读走 getter、写走 setter」的分工让「只读」和「可写校验」可以按属性逐个配置, 粒度比字段修饰符更细。

要点二:getter 里可以做派生,而不是只做透传。 第 3 代的 getter 把 x、y 合成 position 对象返回——每次读取都是现算的。这意味着「存储格式」和 「读取格式」彻底解耦:内部改存极坐标,getter 里做换算,外部无感。 代价是每次读取都执行一次函数(对昂贵的派生,可以加缓存)—— 访问器 = 每次读取都可能执行的代码,这是它和字段的本质区别, 也是「别把重计算塞进 getter」这条经验法则的来源。

第 4 代把属性升级成 #x/#y 后,访问器从「便利」升格为「唯一通道」: 字段真私有,类外任何读写都必须经过 getter/setter——校验逻辑(如拒绝负数坐标) 从此有了稳定的落脚点。原书的两道练习放在一起,正好是这个设计模式的完整示范: 先开放(getter),再收权(# + setter),最后拆家(第 5 代继承)33。

implements、extends、abstract 怎么选

三类工具经常被混用,它们的差别一句话能说清:

工具关系能不能带实现能 new 吗
implements 接口类承诺形状不能(纯契约)与接口无关
extends 普通类子类复用父类能都能
extends 抽象类子类补完父类能(还有必须补的抽象成员)父类不能,子类能

选择顺序的经验法则(我们的总结,原书分散在各节):纯形状契约用接口; 有公共实现要共享、但基类不该独立存在,用抽象类;父类本身就该被实例化,才用普通类继承。 接口还有个独特优势:一个类可以实现多个接口,而继承只有一个父类名额—— 「组合优于继承」在类型层面同样成立。

合成样本:一个装备齐全的 Album 类

把五幕的设施合成一个类,看它们怎么共处:

interface AlbumContract {
  title: string;
  artist: string;
  releaseYear: number;
}

abstract class AlbumBase implements AlbumContract {
  readonly title: string;      // 契约要求 + 创建后锁定
  readonly artist: string;
  readonly releaseYear: number;
  #rating = 0;                 // 真私有,运行时也不外露

  constructor(opts: AlbumContract) {
    this.title = opts.title;   // readonly 允许在构造器里赋一次
    this.artist = opts.artist;
    this.releaseYear = opts.releaseYear;
  }

  get rating() {               // 只读访问器:看可以,改不行
    return this.#rating;
  }

  protected bumpRating() {     // 留给子类的内部接口
    this.#rating += 1;
  }
}

class StudioAlbum extends AlbumBase {
  #studio: string;

  constructor(opts: AlbumContract & {studio: string}) {
    super(opts);
    this.#studio = opts.studio;
  }

  override toString() {        // 若父类没有 toString 可挂 override 会报错
    return `${this.title} (recorded at ${this.#studio})`;
  }
}

逐行对回本章的设施:implements 逼 AlbumBase 兑现契约;readonly 属性的唯一赋值机会 就在构造器里;#rating 连运行时都封死,外部只能走 getter;protected 给子类留的 bumpRating 是继承协作的通道;子类构造器先 super 再动自己的 #studio; override 挂在 Object.prototype 自带的 toString 上同样有效——它检查的是 「父类(含所有祖先)有没有这个成员」。这就是第 03 章那条「薄语法」原则的边界以内, TypeScript 给类加的全部护栏——除了 # 前缀,它们都活不过编译。

这个合成样本还回答了一个本章没有正面回答的问题:「这些设施会打架吗?」 答案就在样本的相处方式里——

  implements × abstract:接口管「对外的形状承诺」,抽象类管「对内的共享实现」,
    一个向外一个向内,不冲突。
  readonly × 构造器赋值:readonly 的唯一例外就是构造器,
    所以「创建时锁定」和「创建必有着落」两个目标在构造器里汇合,不冲突。
  # × protected:# 防外部,protected 放子类——两个修饰符各管一群访问者,
    样本里 #rating 配 protected bumpRating,正是「子类可动、外人免进」的精确表达。
  override × extends:挂牌是给父类链看的,extends 是给实例化看的,各管一段。

  图说:设施多不等于复杂——它们各自守住一条边界,
  一个类之所以「装备齐全」,是因为每条边界都有明确的卫兵。

如果只能带走一个设计口诀,从这个样本里抽就是:先定边界(谁可见、谁可写), 再填实现——类的全部设施都是边界工具,实现只是边界之内的普通代码。 这和接口优先的设计哲学(第 07 章)一脉相承:形状先行,实现随后。

第九章拆完。回顾一下全章的形状:五幕正文是「机制」,CanvasNode 五代是「剧本」, 合成 Album 是「总装」——三种形态各读一遍,类这一章就从「一堆修饰符」 变成了「一套边界设计工具」。这也是原书把类放在第 8 章的用意: 至此,值的形状(第 04–07 章)、值的锁(第 08 章)、值的边界(本章)都已齐备, 第 10 章该清点「TypeScript 自己多造的那些运行时特性」了。

8. 这一章在全书链条中的位置

类是「类型世界」与「值世界」交叉得最密的地方:同一个名字(Album)既是运行时 能 new 的类,又是类型层面的实例形状;private、implements 这些纯类型设施 与 #、constructor 这些运行时设施混在同一个花括号里。第 10 章会抽查其中三个 只有 TypeScript 才有的运行时特性(参数属性、enum、namespace)——类是它们的主场; 第 13 章则会系统讲解「类型世界 vs 值世界」的分界线,类的双重身份在那里得到完整解释。 原书小结的清单是:构造器与明确赋值、修饰符(private 编译期 / # 运行期)、 方法两种写法的 this 差异、extends + super + override、implements、抽象类、类名当类型用31。

这张清单也值得换个顺序读一遍——按「层面」而不是按「幕」:

  纯运行时层(JavaScript 本来就有):
    class 本体、constructor、new、super、普通方法、# 字段
  纯类型层(TypeScript 加的):
    属性标注、readonly、?、public/private/protected、
    implements、abstract、override、类名当类型用
  跨界层(两个世界同框):
    属性初始化器(运行时赋值 + 类型推断)、明确赋值检查(运行时约束的类型化)、
    箭头方法(语法是值,行为被 this 绑定改写)

  图说:三层的划分不是装饰——查「这个设施在哪个层面生效」,
  是预测它行为(能不能被 @ts-ignore 绕过、擦除后剩不剩)的最快方法。

第 3 层的每个设施都是一道小考题:「它在编译产物里留下什么?」 初始化器留下赋值语句,明确赋值检查什么都不留(纯报错),箭头方法留下函数字段—— 问完这一问,第 03 章「薄语法」的例外清单你基本能自己推出来了。

9. 作者的判断与证据(分开标)

书里有据的:

  • 属性的明确赋值检查及两条合规路——语言规则,报错信息原文在案23。
  • private 只是编译期注解、@ts-ignore 可突破;# 是 ECMAScript 标准、运行时真私有—— 两个实验(包括方括号字符串访问返回 undefined)都当场可复现1112。
  • 箭头方法 this 绑实例、普通方法 this 看调用点——JavaScript 运行时事实16。
  • override + noImplicitOverride 防父类改名漂移——机制描述,场景真实2122。
  • implements 缺属性即报错、抽象类不可实例化——语言规则,报错原文在案2325。

原书没说的(我们补):

  • 箭头方法每实例一份、普通方法原型共享的内存差异——上面判断块已展开(补充,来自通用知识)。
  • 抽象类与接口的选择标准(「带实现用抽象类、纯契约为接口」)原书给了暗示但没有成文规则; 社区的另一条经验是优先接口 + 组合,抽象类留给真有共享实现的层级——补充,来自通用知识。

判断(我们的,不是书里的): 本章在全书里最该被注意的一句话,其实是那句不起眼的 「类名可以当类型用,指的是实例」——它是「值世界与类型世界共用名字」的第一个明确样本。 理解了这一点,后面三章的很多怪事(enum 既是值又是类型、namespace 里类型和值混住、 第 13 章的声明合并)都会变成同一个现象的变体,而不是一串孤立怪癖。 如果错,会错在: 双重身份只是「同名」层面的巧合,编译器内部对类和实例类型的处理 远比「一个名字两个意思」复杂(涉及声明合并和类型解析的具体规则); 但作为学习者的第一近似,这个心智模型错不了。

判断(我们的,不是书里的): 类的五幕教学顺序(属性有着落 → 修饰符 → 方法 → 继承 → 契约)是「跟着报错学」的范本:每一幕的第一个代码样本都是非法的, 红线逼出机制。自学者完全可以照这个剧本在真实代码里重演——每学一个设施, 先故意写错看它怎么骂,再写对。这比读十遍定义都快,因为它直接训练 「读报错 → 定位机制缺口」这个本书全文都在强化的循环。 如果错,会错在: 对已经熟悉其他语言类系统的读者(C#/Java 背景), 报错驱动的节奏会显得拖沓——他们更需要的是「差异清单」(TypeScript 的类 和母语哪里不一样),而报错驱动恰好能自动生成这张清单,只是顺序不同。

10. 边界与局限

  • private 的编译期属性意味着:库作者拿 private 藏起来的东西,消费者 @ts-ignore 照样摸; 真要封死必须用 #(或闭包)。
  • protected 与第 07 章 readonly 对象属性的洞同理:都是类型层面的护栏,运行时无墙。
  • 本章的类教学止步于单继承层级;更复杂的泛型类(extends 加类型参数)是第 16 章的范围。
  • implements 不检查「多余」属性之外的运行时行为——它检查的仍是形状,不是语义。
  • 箭头方法 vs 普通方法的取舍没有银弹。 原书建议「一般用箭头」,但也明说 两种写法的其余差异超出范围;内存布局(实例字段 vs 原型方法)、 可被重写性(普通方法能被子类 override,箭头字段不能)都在差异清单里。 团队该做的是选定一个默认并保持一致,而不是逐案争论。
  • 抽象类不能实例化,但「不能直接 new」不等于「不能间接创建」。 匿名子类、 工厂函数、测试替身都可能绕过「基类不该存在」的意图——抽象类防的是 「随手 new」,不是「刻意绕路」。要更强的保证,靠 lint 规则和 code review。

11. 可带走的

  1. 类的属性必须有初值或在构造器里赋值(明确赋值检查);构造器参数照常要标注。
  2. 构造器里赋了值,属性类型可省(推断),但留着当文档是常见风格。
  3. 类名可以当类型用,指的是实例的形状,不是类本身。
  4. 属性初始化器先于构造器执行;构造器赋值覆盖默认值。
  5. readonly / ? 与对象类型同款语义;都是类型层面。
  6. private 只挡编译期(@ts-ignore 可破);# 是运行时的真私有,需要私有优先 #。
  7. protected = 类外禁、子类放行。
  8. 方法两种写法:this 一个绑定义处(箭头)、一个看调用处(普通);一般场景箭头更省心(内存除外)。
  9. 继承:子类构造器先 super() 再动 this;override 挂牌防父类改名漂移; noImplicitOverride 可强制。
  10. implements 让接口成为类的合同;抽象类 = 合同 + 半成品实现,不可直接 new。
  11. getter/setter 成对时,setter 参数类型从 getter 推断。
  12. CanvasNode 五代演进(属性→方法→getter/setter→#私有→拆分继承)是一套可直接复用的重构剧本。
  13. 访问器让「内部表示」与「外部接口」解耦——校验逻辑在 setter 里有稳定的落脚点。
  14. setter 参数类型从配对 getter 推断,不用手写。
  15. implements(纯契约)vs 抽象类(契约+实现)vs 普通继承:按「要不要共享实现、基类该不该存在」选。
  16. 类名当类型用 = 实例的形状——「一个名字两个世界」的第一次出现,记住它。

这 16 条按「入门必需」和「进阶备用」分两层:1–9 是写第一个类就要用的 (漏一条就报错),10–16 是设计类层级时才出场的(用错了不会立刻炸, 但会欠下维护债)。学本章的正常路径是先前半,后半等第一次「这个类该拆该合」 的真实需求出现时再回来查——到时候第 6 幕的选择表和 CanvasNode 的第 5 代会替你省半天。

12. 原文地图

下表新增条目对应本次加厚的内容(初始化器顺序、private 越狱实验、super 细则、 override 漂移场景、implements 报错链、抽象类的两重性、8-1 双解与 8-3 三件套); 其余条目为初版已有,行号均可回核。全表覆盖原书第 8 章(Creating a Class) 的全部小节与五道练习,无一处导航章内容被引入。按这张地图回核任意一条论断, 平均只需要两次跳转:一次到行号,一次到搜词——这也是全书拆解的出处标准。 第九章的拆解至此完整,前后呼应的总纲与十七个兄弟章节的地图同构, 查证体验全书一致。读者顺藤摸瓜回原文时,建议从「本节最深的那个例子」入手—— 每一节的主线代码都先在正文里走完了,地图只是它的地址簿。 类的故事讲完了,TypeScript 的「值」这一半(形状、锁、边界)也就讲完了: 下一半的故事关于「类型的机器」——第 11 章开始推导,第 16 章造机器,第 17 章把机器装进函数。 这是全书的真正分水岭,值得在翻页之前停一秒,回头看看从第 04 章走到这里的路程—— 那些一开始觉得零散的规则,此刻应该已经连成了一条线。线的这一端是值,那一端是类型。

主题原书位置原文位置
明确赋值报错Creating a Classtext/24-fm-creating-a-class.txt:24(搜「no initializer」)
构造器与 new同上text/24-fm-creating-a-class.txt:39(搜「constructor()」)
构造器参数与 opts同上text/24-fm-creating-a-class.txt:79(搜「constructor(opts」)
构造器内推断可省标注同上text/24-fm-creating-a-class.txt:54(搜「smart inference」)
类名当类型用(指实例)同上text/24-fm-creating-a-class.txt:116(搜「refers to the instance」)
属性初始化器先于构造器同上text/24-fm-creating-a-class.txt:146(搜「before the constructor」)
private 与 @ts-ignore同上text/24-fm-creating-a-class.txt:206(搜「doesn’t actually prevent」)
# 私有与方括号访问同上text/24-fm-creating-a-class.txt:229(搜「#rating」)
箭头 vs 普通方法的 this同上text/24-fm-creating-a-class.txt:285(搜「location: 'Class'」)
箭头方法建议同上text/24-fm-creating-a-class.txt:290(搜「avoid issues with this」)
extends + super 两步同上text/24-fm-creating-a-class.txt:344(搜「super(opts)」)
override 防漂移同上text/24-fm-creating-a-class.txt:415(搜「override displayInfo」)
noImplicitOverride同上text/24-fm-creating-a-class.txt:425(搜「noImplicitOverride」)
implements 报错同上text/24-fm-creating-a-class.txt:468(搜「incorrectly implements」)
I 前缀的取舍同上text/24-fm-creating-a-class.txt:450(搜「Hungarian notation」)
抽象类不可实例化同上text/24-fm-creating-a-class.txt:531(搜「Cannot create an instance」)
抽象方法同上text/24-fm-creating-a-class.txt:554(搜「abstract addReview」)
setter 参数类型推断同上text/24-fm-creating-a-class.txt:790(搜「infer it based on the getter」)
本章小结同上text/24-fm-creating-a-class.txt:853(搜「encapsulating data and behavior」)
属性初始化器先于构造器同上text/24-fm-creating-a-class.txt:146(搜「run before the constructor」) · text/24-fm-creating-a-class.txt:158(搜「Output: Matt Pocock」)
readonly 类属性同上text/24-fm-creating-a-class.txt:164(搜「once the property is set in the constructor」)
可选类属性同上text/24-fm-creating-a-class.txt:174(搜「mark properties as optional」)
private 的 @ts-ignore 越狱同上text/24-fm-creating-a-class.txt:206(搜「doesn’t actually prevent」) · text/24-fm-creating-a-class.txt:209(搜「Output: 0」)
# 的字符串访问也走不通同上text/24-fm-creating-a-class.txt:227(搜「dynamic string」)
箭头方法 this 实验同上text/24-fm-creating-a-class.txt:285(搜「location: 'Class'」) · text/24-fm-creating-a-class.txt:288(搜「bound to the instance」)
super 的两条构造器规则同上text/24-fm-creating-a-class.txt:331(搜「match the shape used in the parent class」) · text/24-fm-creating-a-class.txt:333(搜「sets up the properties」)
override 漂移场景同上text/24-fm-creating-a-class.txt:409(搜「won’t automatically get updated」)
noImplicitOverride同上text/24-fm-creating-a-class.txt:425(搜「noImplicitOverride」)
I 前缀的不推荐同上text/24-fm-creating-a-class.txt:450(搜「We don’t recommend」)
抽象类不可实例化同上text/24-fm-creating-a-class.txt:531(搜「Cannot create an instance」)
抽象类「模糊界线」同上text/24-fm-creating-a-class.txt:545(搜「blur the line」)
练习 8-1 的两种解法同上text/24-fm-creating-a-class.txt:604(搜「readonly x: number」) · text/24-fm-creating-a-class.txt:616(搜「readonly x = 0」)
练习 8-3 的构造器三件套同上text/24-fm-creating-a-class.txt:687(搜「position?:」)

Footnotes

  1. 出处:「Creating a Class」第 8–18 段(text/24-fm-creating-a-class.txt:10,搜「class keyword」)。 ↩

  2. 出处:「Creating a Class」第 20–26 段(text/24-fm-creating-a-class.txt:24,搜「no initializer」)。 ↩ ↩2

  3. 出处:「Creating a Class」第 28–52 段(text/24-fm-creating-a-class.txt:30,搜「special method」)。 ↩ ↩2

  4. 出处:「Creating a Class」第 48–52 段(text/24-fm-creating-a-class.txt:52,搜「new keyword」)。 ↩

  5. 出处:「Creating a Class」第 72–102 段(text/24-fm-creating-a-class.txt:79,搜「constructor(opts」)。 ↩

  6. 出处:「Creating a Class」第 54–68 段(text/24-fm-creating-a-class.txt:54,搜「smart inference」)。 ↩

  7. 出处:「Creating a Class」第 70 段(text/24-fm-creating-a-class.txt:70,搜「form of documentation」)。 ↩

  8. 出处:「Creating a Class」第 104–120 段(text/24-fm-creating-a-class.txt:116,搜「refers to the instance」)。 ↩ ↩2

  9. 出处:「Creating a Class」第 126–160 段(text/24-fm-creating-a-class.txt:146,搜「before the constructor」)。 ↩ ↩2

  10. 出处:「Creating a Class」第 162–182 段(text/24-fm-creating-a-class.txt:167,搜「readonly title」)、第 176–182 段(搜「don’t need to be set」)。 ↩ ↩2

  11. 出处:「Creating a Class」第 184–211 段(text/24-fm-creating-a-class.txt:206,搜「doesn’t actually prevent」)。 ↩ ↩2 ↩3

  12. 出处:「Creating a Class」第 213–235 段(text/24-fm-creating-a-class.txt:229,搜「#rating」)。 ↩ ↩2 ↩3

  13. 出处:「Creating a Class」第 219 段(text/24-fm-creating-a-class.txt:219,搜「part of the ECMAScript standard」)。 ↩

  14. 出处:「Creating a Class」第 681–696 段(text/24-fm-creating-a-class.txt:688,搜「position?.x ?? 0」)。 ↩

  15. 出处:「Creating a Class」第 360–380 段(text/24-fm-creating-a-class.txt:362,搜「third visibility modifier」)。 ↩

  16. 出处:「Creating a Class」第 265–290 段(text/24-fm-creating-a-class.txt:285,搜「location: 'Class'」);机制解释见第 288 段(搜「bound to the instance」)。 ↩ ↩2

  17. 出处:「Creating a Class」第 290 段(text/24-fm-creating-a-class.txt:290,搜「avoid issues with this」)。 ↩

  18. 出处:「Creating a Class」第 292–327 段(text/24-fm-creating-a-class.txt:322,搜「SpecialEditionAlbum extends Album」)。 ↩

  19. 出处:「Creating a Class」第 329–347 段(text/24-fm-creating-a-class.txt:333,搜「special method that calls the constructor」);super 的作用机制见第 333 段。 ↩ ↩2 ↩3

  20. 出处:「Creating a Class」第 349–358 段(text/24-fm-creating-a-class.txt:351,搜「plasticOnoBandSpecialEdition」)。 ↩

  21. 出处:「Creating a Class」第 382–423 段(text/24-fm-creating-a-class.txt:415,搜「override displayInfo」);「父类改名子类报错」的场景描述见第 409–423 段。 ↩ ↩2

  22. 出处:「Creating a Class」第 425–431 段(text/24-fm-creating-a-class.txt:425,搜「noImplicitOverride」)。 ↩ ↩2 ↩3

  23. 出处:「Creating a Class」第 433–494 段(text/24-fm-creating-a-class.txt:468,搜「incorrectly implements」)。 ↩ ↩2

  24. 出处:「Creating a Class」第 450 段(text/24-fm-creating-a-class.txt:450,搜「Hungarian notation」)。 ↩

  25. 出处:「Creating a Class」第 496–545 段(text/24-fm-creating-a-class.txt:531,搜「Cannot create an instance」)。 ↩ ↩2

  26. 出处:「Creating a Class」第 547–567 段(text/24-fm-creating-a-class.txt:554,搜「abstract addReview」)。 ↩

  27. 出处:「Creating a Class」第 545 段(text/24-fm-creating-a-class.txt:545,搜「blur the line」)。 ↩

  28. 出处:「Creating a Class」第 569–618 段(text/24-fm-creating-a-class.txt:604,搜「readonly x: number」);无构造器版见第 615–618 段。 ↩ ↩2

  29. 出处:「Creating a Class」第 620–677 段(text/24-fm-creating-a-class.txt:662,搜「move(x: number, y: number)」)。 ↩

  30. 出处:「Creating a Class」第 679–752 段(text/24-fm-creating-a-class.txt:741,搜「get position」)。 ↩ ↩2

  31. 出处:「Creating a Class」第 851–863 段(text/24-fm-creating-a-class.txt:853,搜「encapsulating data and behavior」)。 ↩ ↩2

  32. 出处:「Creating a Class」第 754–790 段(text/24-fm-creating-a-class.txt:790,搜「infer it based on the getter」)。 ↩ ↩2

  33. 出处:「Creating a Class」第 792–849 段(text/24-fm-creating-a-class.txt:842,搜「CanvasNode extends Shape」)。 ↩ ↩2

On this page