如果你写过面向对象代码,大概率见过这样一个关键字:extends。它从 Java 到 TypeScript、从 PHP 到 JavaScript,几乎无处不在,成员变量、构造函数、方法覆写、泛型约束里都有它的身影。但说实话,这个只有七个字母的单词,往往是很多项目里隐藏最深的问题源头:有人用它把继承链拉得比业务还长,有人因为乱用 super 调出诡异的空指针,还有人把接口和抽象类混为一谈,最后重构时痛不欲生。这篇内容不打算从教科书定义开始念,而是结合我这些年写业务代码和维护老系统的实际经验,把这个关键字彻底拆开,讲清楚它到底是什么、在不同语言里表现有何不同、什么时候可以放心用、什么时候应该果断避开,以及最常见的那些坑怎么快速排查。
这篇内容适合三类人:第一类是刚接触面向对象没多久的初学者,你能从这里得到一套可以直接套用的建模思路;第二类是写了两三年业务代码、会用但不太清楚底层逻辑的开发者,你能补上那些平时很少被讲透的"为什么";第三类是需要在团队里做代码评审或接手老项目的人,你能拿到一份很实用的继承坏味道自查清单。不管你是前端、后端还是全栈,只要日常写过类,这份内容都值得花十几分钟读完。
1. extends 到底是什么:一个被低估的关键字
先从一个真实的场景说起。有次我帮一个同事排查线上问题,现象很简单:某个订单子类在支付完成后,状态没走到"已支付",直接跳到了"已完成"。代码翻了两遍,最后定位到原因,是子类覆写了父类的pay()方法,却漏掉了super.pay()这一行,导致父类里那段最核心的状态校验逻辑根本没执行。这种问题不罕见,也不复杂,但它恰好说明了 extends 的本质:它是类与类之间建立"父子关系"的契约,子类默认拥有父类的全部能力,同时可以覆写、扩展这些能力。这个默认行为,既是便利,也是风险。
1.1 继承到底解决了什么问题
很多人学继承时背过一句话:继承是为了复用代码。这句话没错,但不完整。如果只是为了复用,完全可以把公共方法抽成一个工具函数直接调用,何必引入类与类之间的强耦合关系。继承真正解决的,是另外三件事。
第一是统一抽象。业务里有多种订单,普通订单、会员订单、跨境订单,它们的差异只在计价规则和配送方式,而单号生成、状态流转、支付入口这些骨架是相同的。这时把公共骨架放进一个基类,让各种订单去 extends 它,上层代码就可以只面向基类编程,完全不用关心具体是什么订单。
第二是多态。这才是继承最值钱的地方。一个函数接收BaseOrder类型的参数,传入VipOrder或者CrossBorderOrder时,调用同一个方法却得到不同行为。如果没有继承,这种"同一接口、多种实现"的能力只能靠一堆 if else 硬堆出来,代码越堆越臭。
第三是扩展能力。系统已经上线,突然要新增一种"拼团订单",只要它 extends 基类,实现基类里定义的抽象方法,原有逻辑几乎没有改动,就能无缝接入。这也就是所谓的"开闭原则":对扩展开放,对修改关闭。
1.2 一个生活化的类比
我一直喜欢用"中央厨房和连锁门店"来解释继承。中央厨房定好了做菜的标准流程:洗菜、切配、加热、出餐,这就是基类;不同的连锁门店在保留主流程的前提下,可以调整口味,比如川味门店在加热环节多一步加辣椒,这就是方法覆写;门店必须遵守整体的食品安全流程,不能跳过清洗环节,这就是super调用父类逻辑的意义。门店可以加自己的招牌菜,这是新增方法;但门店不能反过来去改变中央厨房对其他门店的供货标准,这就是子类不能破坏父类契约。
用这个类比去理解代码里的继承,很多问题就自然浮现了:如果一个门店跳过了标准流程,相当于覆写方法时漏调了super;如果一个连锁品牌几百家门店都强依赖同一套中央厨房流程,那中央厨房的任何一次流程调整,都会带来全量回归测试的压力——这就是脆弱的基类问题。
1.3 但继承不是免费的午餐
明确一点,继承是所有代码复用方式里耦合最重的一种。子类和父类之间不只是"调用了父类方法"这么简单,它是一种编译期的、静态的关系。你改父类的一个 protected 字段,所有子类的内存布局和初始化顺序都会受影响;你给父类加一个非私有方法,可能无意中改变了子类的行为;你觉得自己只是"微调"一下基类,结果影响范围顺着继承树一路扩散。这跟组合模式完全不同,组合是"我持有你、我调用你",关系在运行期通过接口界定,随时可以替换;继承是"我就是你",关系在类定义那一刻就定死了。
所以在动手写 extends 之前,先问自己三个问题:这个子类真的"是一种"父类吗?还是只是"用到了"父类的某些能力?父类的变化我能接受全部传导到子类吗?这三个问题基本能筛掉一半错误的继承设计。
2. 不同语言里的 extends:同一语义,不同脾气
extends 虽然长得一样,但在不同语言里,它的规则和边界差别很大。我见过不少开发者从 Java 切到 TypeScript 后,下意识按 Java 的思维写继承,结果在条件类型和泛型约束上绕了远路;也见过写 PHP 的同事完全不懂 trait 存在的意义,强行用多层继承解决问题。熟悉一门语言的 extends,不等于理解所有语言的 extends。
2.1 Java:单继承的坚定守卫者
Java 的继承规则可能是所有主流语言里最严格的。一个类只能 extends 一个父类,多重继承用接口来实现。这个设计是有意为之,主要就是为了绕开 C++ 里著名的菱形继承问题。Java 的 extends 有几个关键细节值得注意。
第一,父类构造方法的调用链。子类的每个构造方法,如果不显式写super(...),编译器会强制调用父类的无参构造方法。如果父类没有无参构造方法,子类就编译不过。这个机制保证了"先有父,后有子"的初始化顺序。很多新手在父类里只写了带参构造方法,结果子类怎么都编译不过,其实就是这个原因。
第二,覆写方法的访问权限不能更严格。父类方法是protected,子类覆写就不能降级成private。这是为了维持父类的契约:如果你把方法藏起来了,调用方按父类类型访问时就可能炸掉。
第三,final 关键字和 extends 是死对头。父类方法用 final 修饰,子类就不能覆写;类本身用 final 修饰,就不能被继承。这一点配合抽象方法,构成了 Java 设计继承时的"三件套":abstract 定义"必须做什么",final 定义"不能改什么",普通方法定义"默认怎么做"。
2.2 TypeScript:不止类继承
TypeScript 里 extends 的出场频率远高于 Java,因为它除了类继承,还深度参与了类型编程。在类继承层面,TypeScript 的规则跟 Java 类似:单继承、支持抽象类、子类必须调用 super 才能访问 this。但真正让人眼前一亮的,是 extends 在泛型和条件类型里的作用。
type IsArray<T> = T extends any[] ? true : false; type A = IsArray<string[]>; // true type B = IsArray<number>; // false这里extends不再是类与类之间的关系,而是一种"类型约束与类型分发"操作,判断一个类型是否能赋值给另一个类型。还有泛型约束的写法,比如<T extends HasId>,表示 T 必须具备id字段。这个语法跟类继承长得一样,但语义完全不同,很多从 Java 过来的人第一次看到会愣住:extends 怎么还能用在尖括号里?
TypeScript 里还有一种很实用的玩法,是用 extends 做条件类型递归展开,典型例子是DeepReadonly。把一个嵌套对象类型的所有属性都变成 readonly,普通写法做不到,但用 extends 递归就可以干净利落地实现。这部分我在第六节重点讲。
2.3 PHP:混入与接口的组合拳
PHP 的继承体系比较特殊。早期的 PHP 只有类和单继承,后来引入了接口,再后来又加了 trait。很多人不理解 trait 到底是什么,其实可以把它看作是"带实现的接口",或者叫"代码块的横向混入"。
abstract class BaseReport { public function render(): string { $data = $this->collect(); return $this->format($data); } abstract protected function collect(): array; protected function format(array $data): string { return json_encode($data, JSON_UNESCAPED_UNICODE); } } class SalesReport extends BaseReport { protected function collect(): array { // 这里查销售数据 return []; } }这个例子展示了 PHP 继承里典型的"模板方法模式":父类定义执行骨架,子类只负责实现缺失的部分。在 PHP 项目里,我强烈建议优先用这种骨架式继承,而不是把一堆公共方法塞进基类让子类随意覆写。因为 PHP 的动态特性比较强,方法覆写如果没有类型声明约束,很容易在运行时出现行为偏差,而且排查起来比 Java 和 TypeScript 都费劲。
2.4 JavaScript:原型链上的语法糖
JavaScript 的 extends 和其他语言不太一样。严格来说,JavaScript 的继承底层还是基于原型链,class 和 extends 只是 ES6 引入的语法糖。理解这一点,很多诡异的行为就说得通了。
class Animal { constructor(name) { this.name = name; } speak() { console.log(`${this.name} makes a sound.`); } } class Dog extends Animal { constructor(name, breed) { super(name); this.breed = breed; } speak() { super.speak(); console.log(`${this.name} barks.`); } }这里有两点值得注意。第一,super()必须先于this的访问调用,否则会报错。因为在继承时,this 的初始化由父类构造方法完成,子类必须先让父类完成初始化,才能在自己的构造方法里使用 this。这个规则保证了原型链上各层的初始化不产生歧义。
第二,super不仅能调用父类构造方法,还能通过super.methodName()调用父类的原型方法。这在 Java 里对应的是super.method()的调用,但 JavaScript 的动态特性使得这种调用需要在方法内部明确绑定 this。实际开发中,如果覆写方法里忘了这一行,往往不会直接报错,只是行为静默地缺失,跟我在开头说的那个订单状态问题一模一样。
不同语言 extends 的核心规则差异总结如下:
| 语言 | 继承形式 | 多继承支持 | 构造方法特点 | 额外特性 |
|---|---|---|---|---|
| Java | 类单继承,接口多实现 | 否 | 子类首行必须调 super 或无参父类构造 | final 限制、抽象类 |
| TypeScript | 类单继承,接口多实现 | 否 | 同 Java,super 必须在 this 前 | 条件类型、泛型约束 |
| PHP | 类单继承,接口多实现 | 否 | 子类不自动调父类构造 | trait 混入 |
| JavaScript | 类继承基于原型链 | 否 | super 必须先于 this | 动态原型链、super 方法调用 |
3. 一次真实的继承建模:会员订单系统的设计实战
讲完理论,用一个我实际做过的会员订单系统作为案例,完整走一遍继承建模的流程。本来可以用更简单的例子,但业务模型才是继承最能发光发热的地方。
3.1 业务场景与核心需求
某电商系统需要支持三种订单:普通订单、会员订单、跨境订单。它们共享以下能力:生成订单号、计算实付金额、状态流转、支付回调处理。但各有差异:会员订单按会员等级打折,跨境订单需要额外计算关税和汇率,普通订单就是基础逻辑。要求新增订单类型时,尽量不动已有代码。这个场景非常适合用继承建模,因为"XX订单"天然符合"是一种订单"的关系,而不是"用到了订单能力"的关系。
3.2 基类设计:把变化留到子类
设计基类时,我的核心思路是把流程写死,把变化点留给抽象方法。流程写死能保证所有子类的行为骨架一致,抽象方法则强制子类提供差异化的实现。这样既能统一约束,又能释放扩展空间。
这里用 Java 示例来展示基类骨架:
public abstract class BaseOrder { protected String orderNo; protected BigDecimal amount; protected OrderStatus status; public BaseOrder(String orderNo, BigDecimal amount) { this.orderNo = orderNo; this.amount = amount; this.status = OrderStatus.CREATED; } public void pay() { if (!status.canTransitTo(OrderStatus.PAID)) { throw new IllegalStateException("当前状态不能支付"); } BigDecimal payable = this.calculatePayable(); doPay(payable); this.status = OrderStatus.PAID; } private BigDecimal calculatePayable() { return this.amount.subtract(this.discount()); } protected abstract BigDecimal discount(); protected void doPay(BigDecimal payable) { // 默认走第三方支付网关,子类可覆写 } public String describe() { return orderNo + " 金额 " + amount.toPlainString(); } }这里discount()是抽象方法,子类必须实现,这就是我留给子类的"变化入口"。doPay()是普通可覆写方法,子类如果需要定制支付通道,可以直接覆写。pay()方法本身不开放覆写,保证状态机逻辑统一不被破坏。
这样设计的好处是,上层业务代码只需要面向BaseOrder编程。支付、查询、对账都建立在基类之上,新增一种订单类型时,就多写一个子类,不用改任何调用方代码。实际项目里这套设计让我们的订单模块在一年内新增了三种订单类型,核心代码几乎零改动。
3.3 子类实现与多态效果
会员订单和跨境订单分别实现抽象方法即可:
public class VipOrder extends BaseOrder { private final BigDecimal discountRate; public VipOrder(String orderNo, BigDecimal amount, BigDecimal discountRate) { super(orderNo, amount); this.discountRate = discountRate; } @Override protected BigDecimal discount() { return this.amount.multiply(discountRate); } } public class CrossBorderOrder extends BaseOrder { private final BigDecimal taxRate; public CrossBorderOrder(String orderNo, BigDecimal amount, BigDecimal taxRate) { super(orderNo, amount); this.taxRate = taxRate; } @Override protected BigDecimal discount() { return BigDecimal.ZERO; } @Override protected void doPay(BigDecimal payable) { BigDecimal tax = payable.multiply(taxRate); super.doPay(payable.add(tax)); } }调用方的代码写成这样即可:
public void handleOrder(BaseOrder order) { order.pay(); // 打印订单信息,多态保证这里输出的是子类的 describe System.out.println(order.describe()); }同一个handleOrder,传入VipOrder和CrossBorderOrder时,discount()走不同实现,doPay()也有不同表现。这就是继承加多态的核心价值:调用方不感知具体类型,新增类型也不需要改动调用方。
这里有一个很容易被忽视的细节:CrossBorderOrder的doPay里调用了super.doPay(payable.add(tax)),而不是自己重新实现整个支付逻辑。这是覆写方法时的黄金法则——尽量复用父类逻辑,只在父类逻辑的前后做增量扩展。如果直接把整个支付逻辑复制到子类里,父类后续修复一个支付 bug,子类就会漏掉,改起来特别痛苦。
4. 什么时候不该用 extends:继承的取舍与替代方案
写了这么多年代码,我越来越觉得:判断"什么时候不用继承"比"什么时候用继承"更重要。继承的滥用是很多项目后期腐化的主要原因之一,尤其是那种三层五层甚至七层的继承链,每一次需求变更都像在雷区里走路。
4.1 里氏替换原则:继承的及格线
面向对象设计里有一条里氏替换原则,说白了就是:所有能用父类的地方,都应该能被任何子类替代,并且程序行为不发生错误。这是检验继承设计是否合理的及格线。
举个例子。如果有个Rectangle父类,子类Square继承它,然后覆写了宽高设置方法以保证长宽相等。表面上这很合理,正方形"是一种"长方形嘛。但实际用起来,设置宽后再设置高,正方形的行为可能让宽度也跟着变,调用方用父类接口操纵时就会出现意外结果。这违反了里氏替换原则,那这个继承就是错的。
正确做法是把Rectangle和Square都抽象为接口Shape,各自实现,不互相继承。接口定义了"计算面积"的能力,具体实现互不干扰。
4.2 组合优于继承的实战判断
组合优于继承这条原则,落到实战里就是一句话:能用成员变量持有另一个对象,就不要用继承去继承那个类。
举个例子,假设有一个Logger类负责写日志。现在需要一个带时间戳的TimestampLogger。新手往往直接class TimestampLogger extends Logger,覆写输出方法,在前面加上时间戳。看起来没什么问题,但一旦Logger的构造方法需要很多参数,或者Logger有多个输出通道需要动态配置,继承就会让情况变得很僵化。
用组合是这样:
public class TimestampLogger { private final Logger delegate; private final Clock clock; public TimestampLogger(Logger delegate, Clock clock) { this.delegate = delegate; this.clock = clock; } public void log(String message) { delegate.log("[" + clock.now() + "] " + message); } }组合的方式下,TimestampLogger不是一个"Logger",而是"包装了一个 Logger",依赖通过构造方法注入,将来想换一个 Logger 实现也很容易。继承呢?一旦和特定父类绑死,替换成本极高。判断标准很简单:如果只是"想拥有某个类的能力",用组合;如果是"想成为某个类、并且和它存在明显的分类关系",才用继承。
4.3 抽象类与接口的选型
就算确认要用继承,还有一个经典选择题:抽象类和接口该选哪个。我的经验是看"语义类型"和"代码复用"的权重。
抽象类的核心优势是可以承载状态和公共逻辑。比如BaseOrder里有status字段,有describe()的默认实现,这些是可以直接被子类继承的实质性代码。接口则更纯粹,只定义能力,不提供实现。
一个比较实用的判断流程:
- 如果子类之间存在大量共享代码,且共享的不只是方法实现,还包括成员变量,优先用抽象类。
- 如果只是定义"必须实现哪些方法",不考虑复用,优先用接口。
- 如果需要"既是某类型,又具备某些能力",可以在继承抽象类的同时实现多个接口。
- 业务对象建模时,我更倾向抽象类;而系统间交互、策略配置这类场景,接口更干净。
这套判断流程帮我避免了很多"为了架构而架构"的过度设计。继承和接口并不是对立的,它们是互补的工具,搞清楚各自适合的场景,设计就会自然很多。
5. 高频踩坑:extends 带来的典型问题与排查实录
这部分整理了我在实际开发和代码评审里遇到最多的继承问题,每一条都有真实成因和排查思路。建议收藏,等踩坑的时候翻出来对号入座。
5.1 super 调用顺序:最先踩的坑
继承相关的 bug 里,super 调用顺序的问题出现频率最高。Java 和 TypeScript 里,子类构造方法必须先调用父类构造方法;JavaScript 里则必须在访问 this 之前完成 super 调用。实际业务里,很多人会在子类构造方法里先做自己的初始化,再调 super,结果出现两种现象:要么编译报错,要么子类初始化依赖的父类状态还没准备好,出现空指针或 undefined。
我的习惯是:子类构造方法第一行直接写 super,把所有父类需要的参数先准备好,之后再做子类自身的初始化和校验。这样逻辑顺序和初始化顺序就一致了。覆写方法也是同理,如果子类的覆写方法需要保留父类逻辑,就在开头调用super.method(),不要放在最后,因为后续代码可能依赖父类方法执行后的状态。这一条是我在踩了两次坑之后总结下来的铁律。
5.2 菱形继承与多继承
多继承最头疼的问题是菱形继承:B 和 C 都继承 A,D 又同时继承 B 和 C,那 D 里到底保留一份 A 还是两份 A?调用一个继承来的方法时,走的 B 路径还是 C 路径?主流语言里 Java、TypeScript、PHP 都通过"单类继承 + 多接口"绕开了这个问题。真正有菱形继承问题的是 C++,但即便如此,不少开发者还是会试图用多层类继承去模拟多继承,比如 A 继承 B 并实现类似 C 的行为,导致继承树变成一张混乱的图。
排查思路很简单:如果发现某个类体系里,同一个方法在多个祖先里都有定义,或者子类为了"融合"两个父类特性写出了大量覆写代码,说明继承关系已经不对了。更合理的做法是抽接口定义能力,再用组合把需要的行为包进来。
5.3 原型链污染与变量遮蔽
JavaScript 场景下有个很隐蔽的问题:通过实例修改属性,可能意外遮蔽原型链上的同名属性。用 extends 继承时,如果父类构造函数里给实例设置了this.name,子类构造函数里又给this.name赋值,这是正常的实例属性遮蔽。真正危险的是在原型对象上直接改属性,比如Dog.prototype.speak = ...,这会影响所有实例,包括那些不应该被影响的子类实例。
排查这类问题时,我一般会先写一个最小复现用例,然后在浏览器或 Node 里打印对象的 own property 和原型链。重点检查:这个方法到底是来自父类原型还是子类原型?实例上是否意外出现了本该在原型上的属性?如果发现原型链被某个实例污染了,常见原因是往this上动态挂了一个原型方法名。我的建议是,子类里新增的属性和方法尽量不要覆盖父类已有名称,命名上做明显区分,比如父类叫load,子类就叫loadDetail,能省掉很多定位成本。
5.4 深层继承与脆弱的基类
继承层数一旦超过三层,代码的脆弱性就开始指数级上升。我见过一个老项目的继承链长达六层:最底层的子类想改一个字段含义,结果要向上追溯三层父类,每一层都有不同的默认值和使用逻辑,最后改动波及了十几个类。这种问题很难通过看代码发现,通常要等线上出 bug 才能暴露。
我在代码评审里立过一条规矩:一个继承分支的深度不允许超过三层,同时新增子类时必须能回答"它和直接父类的差异点是什么"。如果差异点超过了三个,就说明这个基类抽象得不够好,应该把差异点拆成独立的接口或组合策略,而不是继续往下堆子类。
5.5 常见问题速查
| 问题现象 | 可能原因 | 排查方向 | 推荐解法 |
|---|---|---|---|
| 子类构造报错,提示父类构造函数参数不对 | 父类没有无参构造,子类未显式调用 super | 查看父类构造方法签名 | 子类构造首行补上 super 传参 |
| 覆写方法没生效 | 方法签名和父类不完全一致,或父类方法为 final/private | 对比父类方法注解和修饰符 | 加上对应语言的覆写注解检查 |
| 父类状态未初始化就使用 | super 调用太靠后 | 检查构造方法里 this 的访问位置 | 子类构造首行调 super |
| 实例属性意外共享 | 在原型或静态属性上保存了可变数据 | 打印实例属性和原型属性 | 可变数据放实例,不放原型/static |
| 改动父类后大量子类行为变更 | 继承深度过大、基类职责过重 | 画继承树,看差异点数量 | 用接口或组合拆分差异 |
| 子类同时继承多个类行为导致混乱 | 误用多层继承模拟多继承 | 梳理方法归属和覆写关系 | 拆成接口 + 组合 |
6. 进阶玩法:extends 在类型世界里的魅力
extends 的高阶用法,尤其在 TypeScript 的类型系统里,能写出非常优雅且有用的类型工具。这部分算是给已经有一定基础的读者准备的进阶内容,掌握之后,再看复杂类型定义会轻松很多。
6.1 条件类型与 extends
TypeScript 里T extends U ? X : Y其实是一个条件类型:如果类型 T 可以赋值给类型 U,就取 X,否则取 Y。这和类继承的"父子关系"不同,它更像是一种"类型是否满足约束"的判断。
type IsString<T> = T extends string ? true : false; type R1 = IsString<'hello'>; // true type R2 = IsString<123>; // false这个能力可以让类型定义具备"逻辑分支"的能力。比如要根据传入类型是否是数组,来决定返回元素类型还是原类型:
type Unpack<T> = T extends Array<infer E> ? E : T; type A = Unpack<string[]>; // string type B = Unpack<number>; // number这里还出现了另一个关键字infer,它的作用是在条件类型里"捕获"一个待推断的类型变量。Array<infer E>能从传入的数组类型里把元素类型提取出来。这是 extends 和 infer 的经典组合,几乎每个工具类型库都离不开它。
6.2 用 extends 做泛型约束
开发通用工具函数时,经常需要限定传入类型必须具备某些字段。比如写一个按 id 查找的函数,希望传入的类型必须包含id,这时候就可以这样约束:
interface HasId { id: number; } function findById<T extends HasId>(list: T[], id: number): T | undefined { return list.find((item) => item.id === id); }T extends HasId表示 T 必须兼容HasId这个类型。好处是,传入类型如果根本没有 id 字段,在编译期就会报错,而不是运行到一半才出错。这个用法和 Java 里T extends Comparable<T>的写法非常像,但 TypeScript 的类型约束更加灵活,可以约束"属性结构"而不仅仅是"类的父类"。
6.3 一个实用工具类型的实现
来看一个综合例子:深度只读类型。它能递归地把一个对象类型的所有属性变成 readonly。普通浅层的Readonly<T>只能处理第一层,嵌套对象内部的属性不会受影响。利用 extends 结合递归就可以解决:
type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]; }; interface User { name: string; profile: { age: number; settings: { theme: string; }; }; } type ReadonlyUser = DeepReadonly<User>;在这个类型里,T[K] extends object会判断当前属性值是不是对象,如果是就递归继续处理,否则保持原类型。配合映射类型[K in keyof T],整个对象的每一层都会被 readonly 覆盖。这样的类型工具写一次就能在整个项目里复用,大大减少手写重复类型定义的负担。
这类使用 extends 的进阶玩法,在实际项目里可以用来做表单状态推导、接口返回类型的精细化展开、复杂配置对象的深层约束等。它让我觉得 extends 不只是"类继承"那么简单,更是类型编程领域里最重要的逻辑基石之一。如果读者想深入 TypeScript 的类型体操,从理解 extends 在条件类型中的作用开始,是最高效的切入点。
我在实际项目里用过很多次继承,也从踩坑中形成了几个固定的习惯:写任何继承前先默问一句"这是 is-a 关系吗",覆写方法时下意识在开头保留必要的 super 调用,遇到逻辑重复先想到组合和接口而不是急着建父类。这些习惯说不上多高明,但确实让我写的代码少了很多莫名其妙的线上问题。extends 本身没有好坏,它是一个极其锋利的工具,用得对能大幅提升扩展性,用得滥则会让系统陷入维护泥潭。希望这篇内容能帮大家在使用继承时多一分判断力,少踩一些我已经踩过的坑。