先从一段再真实不过的对话说起。前阵子有个刚工作两年的同事跑过来问我,说他在Code Review里被架构师怼了,原因是他在业务代码里写了一个五层深的继承链,然后调用了一个子类没有重写、却间接修改了父类私有状态的方法,结果线上出了诡异的数据错乱。他一脸委屈:"继承和多态我大学里学过啊,考试还拿了高分,怎么到了实战就翻车了?"
这个问题我听得太多了。面向对象里的继承和多态,几乎是所有编程语言教学里最绕不开的章节,但也恰恰是误解最深的部分。教科书喜欢告诉你"继承就是子类拥有父类的属性和方法,多态就是同一个方法在不同对象上有不同表现",这话没错,但太单薄了。真正的工程场景里,继承设计要考虑的是"这个父子关系到底意味着什么",多态要回答的是"这段代码分派到哪个实现,是谁在什么时候决定的"。这两件事背后牵扯的类型系统、内存布局、运行时机制、设计约束,才是"高级"两个字的分量所在。
这篇文章不打算给你堆概念定义。我会从实际设计出发,拆开继承和多态的底层逻辑,对比几门主流语言里的实现差异,然后把那些真正容易踩的坑一个个摆出来。适合已经会写类、但一直没搞明白"什么时候该继承、什么时候不该继承"的开发者,也适合准备面试、想深入理解OOP机制的人。
1. 继承的真相:它首先是一种契约,然后才是代码复用
很多人的直觉是"继承能复用代码",于是看到两个类有相同字段就把其中一个做成另一个的父类。这是本末倒置。继承首先传递的是类型契约:凡是能接受父类对象的地方,都必须能无差别接收子类对象,并且行为不出错。这个要求比"代码长得像"严格得多。
为了把话说清楚,我们先做一个很简单的建模,假设我们在写一个支付系统,有信用卡支付和支付宝支付两种方式,很自然地会想做一个Payment基类,然后搞两个子类去继承。这时候我问一个问题:creditCardNumber(卡号)这个字段放到父类里合适吗?如果放了,那么支付宝支付对象就被迫继承了一个它永远用不到的字段,ide 里调用的时候它也在,但这个字段的含义对它来说是空转的。这就是典型的"为了复用而继承"带来的第一个坑:父类的状态被子类无脑继承,子类背负一堆与自己语义无关的负担。
正确的做法是先把"契约"想清楚。父类里应当只保留所有子类共享的行为抽象和处理公共逻辑的受保护状态。比如支付金额、支付状态、提交支付的方法签名,这些是所有支付方式都有的,放进父类合理。而卡号、token、签名串这些是各自独有的,它们没有资格进入父类。
代码层面可以这么理解:
public abstract class Payment { protected BigDecimal amount; protected PaymentStatus status; public Payment(BigDecimal amount) { this.amount = amount; this.status = PaymentStatus.INIT; } // 所有子类必须实现,这就是契约 public abstract boolean pay(); protected void markSuccess() { this.status = PaymentStatus.SUCCESS; } } public class CreditCardPayment extends Payment { private String cardNumber; public CreditCardPayment(BigDecimal amount, String cardNumber) { super(amount); this.cardNumber = cardNumber; } @Override public boolean pay() { // 具体的扣款逻辑 boolean result = gateway.charge(cardNumber, amount); if (result) markSuccess(); return result; } }注意这里的关键点:pay()是一个抽象方法,它剔除了所有实现细节,只规定"子类必须能支付"。调用方不知道、也不需要知道底下是卡还是二维码,它只需要对着Payment说"去支付",这就是继承背后契约的价值。你复用的是"同一套调用方式",而不是某一段写好的方法体。
所以做继承设计,我的习惯是顺序反着来:先列出所有待建模的对象,找出它们共同的行为,把这些行为抽成抽象方法;再找出行为执行过程中公共的处理步骤,这些步骤才是真正值得放进父类的方法体;最后才考虑字段归属。能放进父类方法体的代码,必须是子类调用后行为不会跑偏的代码。
1.1 里氏替换原则:别让继承变成一颗定时炸弹
说继承就绕不开里氏替换原则(LSP),这大概是OOP五大原则里最容易被忽视、破坏后最致命的一个。它的通俗版表述是:所有使用父类的地方,换成子类之后,程序行为必须保持正确。注意,是"正确",不是"不报错"。子类写出来编译能通过、运行不抛异常,不等于满足了里氏替换。
我给你说个真实的反面教材。之前见过有人设计了一个Rectangle(矩形)类,带width和height,然后让Square(正方形)去继承它。正方形把setWidth重写成同时把height也改了,保证边长一致。这个设计在几何学上完全正确——正方形就是特殊的矩形嘛。但在程序世界里它彻底违反了替换原则:
public void resize(Rectangle rect) { rect.setWidth(5); rect.setHeight(10); assertRectAreaMatch(rect); // 期待面积 50 }如果传入的是Square,setWidth(5)把面积变成了25,setHeight(10)又变成100,最终断言必挂。这不是代码写的笨,是继承关系建模就错了。正方形在数学上继承矩形没问题,在行为契约上不能继承矩形——因为矩形"宽高可独立修改"这个不合理假设,被正方形破坏了。
从这个例子里能提炼出一条实用的判断准则:问你一句"子类能不能在所有父类合法的场景下工作",而不是问"子在语义上是不是一种父"。比如企鹅在生物学上是鸟,但如果你有一个Bird类带着fly()方法,企鹅就不应当继承它。解决方案通常是把这个能力拆出去。
1.2 组合优先于继承:什么时候该用has-a而不是is-a
我在代码评审里经常说一句话:"这两个类的关系,是不是更像'有一个',而不是'是一个'?"继承会把你锁死在单一的父子树上,组合则让你在运行时自由装配。
继续拿支付举例子。如果后来要接 PayPal,它和信用卡支付一样需要走"初始化→发起支付→处理回调"的流程,但底层的鉴权方式完全是另一套。与其搞一个PayPalPayment extends Payment然后复制一遍流程控制代码,不如把"流程控制"和"具体支付策略"分开:
public class PaymentService { private final PaymentStrategy strategy; public PaymentService(PaymentStrategy strategy) { this.strategy = strategy; } public PaymentResult execute(double amount) { // 统一处理日志、重试、通知等公共逻辑 return strategy.pay(amount); } } public interface PaymentStrategy { PaymentResult pay(double amount); }这样PaymentService负责公共流程,具体的支付策略通过接口注入,想加新支付方式只需要多实现一个PaymentStrategy。这就是典型的has-a关系组装出来的弹性。你不需要动PaymentService一行代码就能扩展新渠道。继承则做不到这一点,因为继承是在编译期就定死了父子关系,父类的改动会影响所有子类,而组合只在运行时通过接口建立联系,替换成本低得多。
那哪些情况适合组合?凡是"整体-部分"关系(汽车有引擎、人有手)、"运行时才能确定具体实现"(策略模式、状态模式)、"需要多种维度自由组合"(饮料加糖加热),都优先组合。而"同一家族共享同一套行为契约,且很难有其他方式替代"(支付方式共享"能支付"),才考虑继承。
2. 多态的内功:从虚表到动态分派的底层旅程
如果说继承解决的是"类型怎么组织"的问题,那么多态解决的是"行为怎么分派"的问题。每次你调用payment.pay(),你都清楚这是一个Payment类型的引用,但真正执行的是哪个类的pay()方法?这个答案取决于语言的实现机制。
大多数编译型语言,如 C++ 和 Java,采用动态绑定。编译器在编译阶段没法确定payment具体指向哪种对象,于是它在运行时的对象头里藏了一张表,通常叫虚表(vtable)。这张表记录了这个对象实际类型的每个虚方法入口地址。当你在代码里写下payment.pay(),虚拟机或运行时库做的事情是:从对象头掏出虚表 → 查表找到pay()的实际地址 → 跳过去执行。
这背后有个伴随而来的概念叫虚函数/虚方法。不是所有方法都参与动态分派。Java 里普通实例方法默认都是虚方法,而 C++ 里你必须用virtual关键字显式声明,否则子类重写了也不会在父类引用上触发多态。这个语言差异我后面展开。
理解了虚表,很多"玄学"就有了解释。比如为什么构造函数里调用虚方法容易出问题?因为构造期间对象头里的虚表指针正在逐步从父类指向子类。在基类构造函数执行的那一刻,虚表还停在基类这一层,你调用虚方法,实际执行的是基类版本——这通常不是你想要的行为。我以前在 C++ 里就吃过这个亏,基类构造函数里调用了一个log(),结果发现所有子类都输出父类日志。所以我的铁律是:构造函数里绝不调用虚方法,需要初始化回调就提供显式的init()接口让子类自己触发。
2.1 动态分派 vs 静态分派:重写和重载根本不是一回事
很多人把"重写"和"重载"混在一起,觉得它们都是多态。其实差的远了。重写(override)是子类重新实现父类的方法,方法签名完全一样,走虚表,是运行时决策;重载(overload)是同一个类里多个同名方法,参数列表不同,是在编译期由编译器根据参数类型静态决定的,跟虚表一点关系都没有。
举个容易翻车的例子:
public class Animal { public void speak() { System.out.println("Animal speak"); } } public class Dog extends Animal { @Override public void speak() { System.out.println("Dog bark"); } } public class Demo { public void hear(Animal a) { a.speak(); } public void hear(Dog d) { d.speak(); } }调用时如果把Dog对象赋给Animal引用再传给hear(Animal),永远走Animal的重载分支,里面再通过虚表分派到Dog.speak()。这往往不是你以为的"更精确的hear(Dog)会被调用"。Java 的重载决议发生在编译期,跟你传入的实际对象类型无关,只跟引用类型有关。
所以面试题里经典的"重载是编译期、重写是运行期"看似基础,实际是无数线上 bug 的源头。我见过前端同事在 TypeScript 里犯过类似的错误:以为对象类型是接口就一定能调用实现类的新方法,结果运行时崩了 "not a function"。ES6 class 的继承机制要更绕一些,方法在原型链上查找,底层同样是运行时原型链遍历,但语法上对"私有字段"的处理又和虚表语言完全不同,这都是语言设计带来的差异。
2.2 鸭子类型:当多态不需要继承的时候
动态语言走的是另一条路。Python 和 JavaScript 里的多态根本不要求继承关系,只要一个对象有speak()方法,你就可以调用speak(),这就是所谓的鸭子类型——"走起来像鸭子、叫起来像鸭子,那它就是鸭子"。
class Dog: def speak(self): return "Woof!" class Cat: def speak(self): return "Meow!" def make_speak(animal): print(animal.speak()) make_speak(Dog()) # Woof! make_speak(Cat()) # Meow!Dog和Cat完全没有继承关系,也不实现同一个接口,但只要make_speak眼里只要方法调用合法,程序就跑得通。这带来极大的自由度,但也把类型检查推到了运行时,所以动态语言经常出"AttributeError: 'Cat' object has no attribute 'speak'",真到出错的这一刻你才会发现对象形状不对。为了把好处和坏处都接住,Python 才提供了abc.ABCMeta和@abstractmethod——用abc模块显式定义抽象基类,强制子类实现某方法,让鸭子类型在关键边界上也能有契约约束。
如果你在 Python 里写过多继承,肯定体验过super()配合MRO(方法解析顺序)的微妙。Python 的多继承不是简单地把多个父类的方法拼在一起,而是通过 C3 线性化算法确定一个唯一的查找顺序,这个顺序决定了super()到底往哪个类去。不熟悉 MRO 的人写多继承,经常调着调着就跑偏到毫不相干的兄弟类上去了。
class A: def m(self): print("A") super().m() class B: def m(self): print("B") super().m() class C(A, B): def m(self): print("C") super().m() C().m()这段代码的输出顺序是C, A, B,而且上面每个super().m()都会沿着 MRO 链往下走。如果哪个类忘记调用super().m(),整条链就断了。这正是多继承里最常见的隐性 bug:你看着每个类都只做自己那点事,实际它们被 MRO 突然串成了一条协作链。所以我个人对多继承的态度是:允许用 mixin 让某个功能混入到多个类里,但绝不创建两条以上彼此独立的业务父类。
3. 跨语言继承设计对照:不同语言给"父子关系"画的圈不一样
学了这么多年面向对象,我发现同一个extends在不同语言里的含义差别大到离谱。如果不了解这些差异,照着 Java 的思路去写 C++,或者照 Python 的思路去写 TypeScript,都会撞得头破血流。
下面这张表我从设计者的视角整理了一下主流语言的继承特点,后面分别展开说背后的原因。
| 语言 | 继承类型 | 多态机制 | 关键特性 | 典型坑 |
|---|---|---|---|---|
| Java | 单继承 + 多接口 | 虚方法动态绑定 | @Override、abstract | 重载决议只看引用类型 |
| C++ | 多继承 | virtual才动态绑定 | 虚表、多重继承、虚继承 | 未声明virtual导致无多态 |
| Python | 多继承 | 鸭子类型 + 协议 | abc、MRO、mixin | super()沿 MRO 顺序传播 |
| TypeScript | 单继承 + 多接口 | 编译期结构类型(结构化子类型) | interface可继承、可联合 | 编译期类型强,运行时无影响 |
| JavaScript | 原型链继承 | 原型链动态查找 | class是语法糖,prototype本质 | class与函数构造器的语义差异 |
3.1 Java 与 C#:接口是唯一的"多父"出口
Java 设计者很早就意识到多继承容易惹祸,所以砍掉了类层面的多继承,只允许一个类继承一个父类。但同时留下了interface这个逃生口:一个类可以实现多个接口,接口里可以定义常量、抽象方法,Java 8 之后还能写default方法。这让 Java 在保证单根体系稳定性的同时,也不会丧失横向组装能力。
C# 的继承体系和 Java 几乎同构:类是单继承,接口是多实现。特别的地方在于 C# 的attribute机制——它允许你用声明式的方式给类、方法、属性附加元数据。评论区有同学问过"c# 继承attribute",其实 keyword 标题说的是Attribute的继承行为,但背后真正要理解的是:在 C# 中继承一个带有Attribute的类,并不会自动把这个 attribute 复制到子类上去,attribute 的Inherited参数控制它是否随继承链传播。比如[Obsolete]默认不向子类传播,你自己定义的[MyCustom]则可以通过[AttributeUsage(Inherited = true)]实现传播。这个机制很容易被误解为"子类自动获得父类的注解",实际多半不是。
3.2 C++ 的多继承与虚继承:自由带来的复杂度
C++ 让程序员自由选择多继承,同时把重写的可虚性交给开发者掌控。这极大增加了灵活性,但也埋了深坑。最有名的就是菱形继承:类D继承B和C,而B和C都继承自A。如果不做特殊处理,D里会有两份A的数据,导致歧义、浪费,更严重的是调用A的方法时到底该用哪一份。C++ 提供了virtual继承来解决这个问题,让D只保留一份共享的A子对象。
这个场景在真实工程里其实很罕见,更多时候是概念上觉得自己需要多继承,实际用组合和接口就够。所以我对 C++ 项目的建议是:多继承能不用就不用,非要用也把继承层次压在两三层以内,并且优先用纯虚接口类配合组合来替代。至少在我维护过的 C++ 服务代码里,菱形继承出现的地方,最后几乎都会被重构掉。
3.3 TypeScript/JavaScript:类型层面的接口继承不等于运行时继承
前端的朋友看到热搜词里有typescript interface 怎么继承?,这问题值得单独说。TypeScript 的interface可以extends另一个接口,或者使用&做交叉类型,比如interface Dog extends Animal。但请务必清楚:这些关系全部发生在编译期,TypeScript 编译器在类型检查结束后就把它擦除了。运行时根本没有接口这个概念,对象之间的关系靠的是 JavaScript 的原型链。
这就导致一个很有意思的现象:interface Dog extends Animal只是"类型上的契约",它不会像 Java 那样约束Dog的运行时结构。你在 TS 里可以写:
interface Animal { speak(): void; } interface Dog extends Animal { fetch(): void; } class GoldenRetriever implements Dog { speak() { console.log('Woof!'); } fetch() { console.log('Fetching...'); } }GoldenRetriever实现了Dog接口,这在类型系统里完全自洽。但它和 Java 那种"类的继承"有一个根本区别:如果GoldenRetriever忘了写speak(),Java 编译直接在接口检查环节就报错了,而 TypeScript 在类型检查时也会报错——但如果用的是any或者绕过类型检查,比如第三方.js文件运行时,那就完全没有保障。所以 TS 的接口继承给你的是面向开发者的安全感,而不是交给运行时的强制力。
JavaScript 的class本质上是原型链的语法糖,extends会建立原型之间的关联。和 Java 的虚表不同,JS 的方法查找是沿__proto__链动态进行的,所以"重写"非常自然,任意对象在链上的某个层级定义同名方法,就会遮蔽上层方法。
4. 实操中绕不开的坑:继承和多态踩过之后才知道的那些事
理论说得差不多了,接下来谈谈实操中那些不会写进教科书、但几乎每个项目都撞见过的坑。我不打算按语法错误列,而是按设计问题和思维误区来列,因为语法错误编译期就帮你挡住了,设计问题才是上线之后炸的雷。
4.1 坑一:父类构造器调用了可重写方法
前面提过,我再展开一遍。Java/Python 这类语言里,如果父类构造器调用了某个方法,而这个方法被子类重写,那么子类对象在构造期间,它的字段还没有初始化完成,重写方法就可能读到空值或默认值。
class Base: def __init__(self): self.setup() # 动态分派到子类 def setup(self): print("Base setup") class Child(Base): def __init__(self): self.value = [] super().__init__() def setup(self): print("Child setup") if self.value is None: # 这里可能炸 raise Exception("value not ready") self.value.append(1)这段代码Base.__init__里的self.setup()实际会调用到Child.setup(),因为 Python 里实例方法天然是"虚"的,运行时self是子类对象。此时self.value还是None。这个 bug 特别隐蔽,因为它只在特定调用顺序下触发,而且报错信息往往指向子类的setup,让人半天想不到是父类构造器惹的祸。
我个人的规避策略很简单:父类构造器除了完成父类自身字段的初始化,不要调用任何可被重写的方法。如果确实需要"构建后回调",就定义一个名为postConstruct之类的具体方法,让子类知道该在何时覆写,而不是在构造器里偷偷激发。
4.2 坑二:被子类重写的方法在父类中被调用的连锁反应
这个坑是坑一的"运行时版本"。父类有一个公共方法process(),它内部顺序调用了step1()、step2(),这三个方法都被设计成可重写。子类单独重写step1没问题,单独重写step2也没问题,但当子类两个都重写而且它们之间存在依赖时,就可能搞出父母双方都没预料的组合行为。
Java 里有一个经典案例是HashMap的putAll大量调用put方法,导致子类重写put后,putAll行为也跟着改变。这不是 Java 的设计缺陷,而是模板方法模式的固有风险:父类把流程固定下来,把变化点留给子类,但子类一旦在变化点里加入了破坏父类不变量的逻辑,连锁反应就出现了。
规避方式是:确定哪些方法属于"扩展点",哪些属于"流程骨架"。扩展点要么是抽象方法(必须重写,且语义明确),要么用final把流程方法锁死,避免别人为了省事去重写流程本身。当年 EJB 时代大量诡异 bug 就源于框架把流程方法设计为可重写,用户在子类里重写ejbCreate的时机出了问题,整个事务上下文都跟着炸。
4.3 坑三:重写的时候偷偷改变了方法的"行为契约"
比改变返回类型更隐蔽的,是在重写时改变了方法的前置条件或后置条件。基类方法文档里写着"接受非负整数,返回累加结果",子类实现却要求参数必须大于10,小于10时抛异常。调用方按基类的约定传入5,子类直接炸。这就是违背里氏替换原则的行为级表现,而不仅仅是签名级表现。
静态类型语言(Java、C++)能在签名层面强制子类方法兼容父类,但没有任何编译器能检查"行为兼容"。所以很多团队开始做契约测试(Contract Test):针对基类公共方法写一套测试用例,让每个子类都跑一遍这套测试。这比靠人肉理解里氏替换靠谱得多。我在维护大型项目时,会给每一个抽象基类配一份BaseContractTest,子类测试类去继承它,确保子类在基类所有约定场景下都能正确运行。
4.4 坑四:把"继承"当成"昵称"用
这部分是纯设计层面的问题,但杀伤力尤其大。见过很多代码库里,类名看着像继承,实际只是起了一组相似的名字。比如UserService和UserDetailService,让UserDetailService extends UserService,只为了复用getUserById这个方法。这完全不构成"is-a"关系,唯一的理由是"它们都跟用户有关"。
继承一旦建立,父类和子类就产生了强耦合:父类任何字段、方法、可见性调整都可能波及子类;子类重写某个方法,可能反向影响父类其它方法的行为。这种"昵称继承"最典型的反噬是,后面想给UserDetailService换一个底层存储,但父类UserService已经依赖了某个数据库连接池,只能被迫把连接池也一起带过去。
面对"只是想复用方法"的冲动,我的习惯是回头审视一下:这个方法是不是可以抽成一个util函数或独立的UserRepository?或者这两个类是不是应该实现同一个接口、但各写各的实现?当"继承"的表层理由只剩下"少写几行代码"时,它往往是个错误决定。
4.5 坑五:可变方法与开放-封闭原则的对抗
开放-封闭原则说"对扩展开放,对修改封闭"。继承确实给了你扩展的途径:加一个子类,重写方法,不改父类。但问题是,子类重写如果改变了父类的执行流,就等于间接修改了父类的行为,这违背了封闭性。
更麻烦的是,很多人会把父类的方法写成"可重写"的,还任由子类调用super.method()再叠自己的逻辑,于是形成了一串长长的super调用链。一旦链中间某个类的重写逻辑出错,排查的时候你得上上下下翻好几个文件,最后发现问题是"某个中间层忘记调用super.method()"。这种问题在 Python 多继承的super()链上尤其严重,因为 MRO 把所有类的同名方法串成了一条线,一个断点整条链全部失效。
我的血泪经验是:不要在重写里把super().method()和新增逻辑搅在一起。重写时要么完全不调用父类实现,要么调用后立即返回,把新增逻辑放在调用之前或之后,用一个try-finally或独立私有方法把新增逻辑隔开,保证父类原逻辑不被半路截断。
5. 从"会用"到"设计":写继承之前先过一遍自检清单
当了几年的工程师之后,我发现自己写继承的频率越来越低,用接口和组合的频率越来越高。这倒不是倒退,而是慢慢摸到了"什么时候该用继承"的脉搏。每次写extends之前,我都会过一遍下面这个清单,基本能挡住八成脑热设计。
第一,语义上真的成立吗。子类是否严格构成父类的一种特殊化?并非所有"是"的关系都能成立。能说出口的"企鹅是鸟"在程序世界可能是错的,因为程序世界的语义由方法、属性、不变式共同决定,而不由单词定义。
第二,LSP 能不能过。把任意一个子类实例塞进所有接受父类的地方,程序是否仍按预期工作?这里的预期包括返回值、异常、资源消耗、并发行为,而不仅仅是"不崩溃"。如果任何一个场景可能在行为上偏离父类承诺,这条继承就极可能是错的。
第三,继承链深度是否可控。我给自己定的红线是业务代码不超过三层:抽象基类 → 一个中间层(做通用扩展) → 具体子类。超过三层,出问题时的排查成本呈指数上升。框架代码可能深一些,但框架会给我完整文档,业务代码的维护者往往只有同事留下的注释。
第四,有没有更合理的选择。组合能做到吗?接口声明做得到吗?策略模式、模板方法模式、装饰器模式能不能替代继承?只要上面任何一个答案是"能",我就会直接放弃继承。只有当我确定"这一族对象共享一套无法被其它方式精简的行为契约"时,继承才成为首选方案。
第五,是否可以测试。每个子类是否都能对父类的每个公共方法做契约测试?如果某个子类为了满足父类签名而写了一堆空实现或抛UnsupportedOperationException,那几乎可以断定这个子类根本不配称为父类的子类。这种"实现不了就抛异常"的设计,在 Java 集合框架里就臭名昭著:继承List却抛UnsupportedOperationException的类,完全不满足 LSP。
如果这五条都顺利通过,再动手写。可能有人觉得这个清单太保守,但在我接触过的项目里,真正能让继承绽放光芒的场景,恰恰都是在这种严格约束下跑出来的:父类抽象足够干净,子类扩展足够聚焦,测试用例一个不多一个不少。
6. 我踩过多次之后最想告诉你的两句话
最后聊点实在的。第一句话是:继承和多态不是给你"省代码"的,而是给你"省调用方心智负担"的。它最大的价值在于让"使用方"只需要理解上层接口就能操作千差万别的对象,而不需要关心底层是哪个实现。所谓接口稳定、实现易变,这才是 OOP 的精气神。所以不要在建模的时候把"复用"排在第一位,要把"替换的透明性"排在第一位。
第二句话是:重写一个方法之前,先想想父类作者在同名方法里埋了什么契约。这不是让你不敢重写,而是让你在动笔之前,把父类方法的行为约定、被依赖的上下文、以及可能被其它方法触发的连锁反应都过一遍脑子。我见过太多 bug 不是因为开发者不会写代码,而是因为只看见了方法签名,没看见签名背后的行为约定。
继承和多态这套东西,教科书可以用一章讲完语法,但真正吃透它,得靠几年项目的反复捶打。希望这篇文章能把"知道"和"会用"之间那段距离帮你缩短一些。如果你在迁移、重构老系统的时候被某个继承设计卡住,不妨现在就把继承链画出来,对照上面的清单一条一条过,多半能找到问题所在。