
开发做了几年很多人写代码还是在“照着模板堆函数”问到对象是什么能说两句问到继承为什么要这么设计、不同语言里继承到底差在哪就含糊了。这篇是“对象”系列的第3篇专门把“继承”掰开揉碎讲清楚。内容会覆盖类继承、原型链继承、方法重写、多重继承、组合优先原则以及几个我实际踩过的坑。面向对象有三个基本特征封装、继承、多态。封装解决的是“怎么把数据和操作绑在一起”多态解决的是“同一个接口在不同对象上有不同表现”而继承是连接封装和多态中间的那座桥。如果你能把继承吃透再去理解设计模式、框架源码、类型系统都会顺很多。文章会以 JavaScript 和 Java 为主语言展开因为这两门语言代表了两种完全不同的继承模型对比着看收获最大。Python、C、C# 里的实现也会穿插提到。适合正在学面向对象、或者想搞清楚“原型链到底怎么回事”的开发者。1. 继承不是“复制代码”而是一种抽象契约很多人第一反应是继承不就是子类复用父类代码吗这种理解不能说错但会把人带偏。如果只是为了复用代码组合has-a比继承is-a更安全、更灵活。继承真正的价值是它建立了一套类型之间的抽象关系。1.1 从“复用代码”到“建立类型体系”先看一个最简单的例子。class Animal { protected String name; public Animal(String name) { this.name name; } public void eat() { System.out.println(name is eating); } } class Dog extends Animal { public Dog(String name) { super(name); } public void bark() { System.out.println(name is barking); } }这段代码里Dog 确实复用了 Animal 的 name 字段和 eat 方法但这只是表面收益。真正的好处是Dog 同时也是一个 Animal因此所有接受 Animal 参数的函数都可以传入 Dog 对象。这就是继承为多态打下的地基。如果只是要复用代码完全可以把 eat 方法抽成一个公共工具函数然后让 Dog 内部持有 name 字段并调用这个工具。但这样 Dog 和 Animal 之间就没有了“is-a”关系你无法把它们放进同一个容器也无法对它们做统一处理。所以在设计继承时要反复问自己子类真的“是一种”父类吗如果答案是否定的那就不应该用继承哪怕代码看起来能省不少。这里不是咬文嚼字而是要养成一种建模直觉否则写出来的类层次会越做越别扭。1.2 继承要解决的核心矛盾共性与差异继承体系里的基类父类、超类抽象的是共性子类扩展的是差异。这是设计的出发点。比如有一个支付系统微信支付和支付宝支付有共同行为创建订单、签名、发起支付、处理回调、退款。这些共性放进一个 Payment 基类里各自的差异加签算法不同、回调验签方式不同、退款接口不同放到子类里覆写。abstract class Payment { protected String appId; protected String secret; public Payment(String appId, String secret) { this.appId appId; this.secret secret; } public final String createOrderPayUrl(String orderNo, int amount) { MapString, String params buildPayParams(orderNo, amount); String sign sign(params); params.put(sign, sign); return buildPayUrl(params); } protected abstract MapString, String buildPayParams(String orderNo, int amount); protected abstract String sign(MapString, String params); protected abstract String buildPayUrl(MapString, String params); }这样设计之后上层业务只需要依赖 Payment 类型不需要知道具体是微信还是支付宝。新增一个云闪付只需要再写一个子类上层代码一行不改。这就是继承在真实项目里最大的价值把会变化的部分隔离在子类里让上层依赖稳定抽象。如果你在设计继承时只想着“能少写几十行代码”大概率会设计出一个不合理的体系。先找共性和差异再决定哪些放父类、哪些放子类、哪些做成抽象方法这才是正确姿势。2. 不同语言里的继承长得完全不一样继承不是一个统一概念不同语言实现差异巨大。核心分两派以 Java/C/C#/Python 为代表的类继承和以 JavaScript 为代表的原型继承。这两者最大的区别在于类是模板还是对象是模板。2.1 类继承与原型继承的本质差异类继承的世界里类定义了对象的结构和行为对象是类的实例。创建对象必须通过 new 来实例化某个类类型检查instanceof也是基于类的关系链。原型继承的世界里没有严格的“类”和“实例”之分。对象可以直接继承另一个普通对象。在 JavaScript 里每个对象都有一条隐式的原型链属性查找不到时就沿着这条链往上找。JavaScript 里虽然也有 class 关键字但它本质上还是原型链class 只是语法糖。理解这一点对排查 JS 继承问题至关重要。看一个原型链的例子const animal { name: , eat() { console.log(this.name is eating); } }; const dog Object.create(animal); dog.name WangCai; dog.bark function() { console.log(this.name is barking); }; dog.bark(); // WangCai is barking dog.eat(); // WangCai is eatingdog 对象本身没有 eat 方法但它的原型指向 animal所以访问 dog.eat 时JavaScript 引擎会沿着原型链在 animal 上找到这个函数。这个模型和 Java 的类继承最大的区别是dog 的原型是实实在在的对象而不是模板。你可以随时给 animal 加方法dog 能立刻访问到也可以让 objectC 的原型指向 objectD形成任意对象之间的继承关系。而在类继承里如果给父类加方法已经创建的子类实例也能访问到Java 里 JVM 是按类元数据查找方法的但你不能在运行时动态给某个类的单个实例设置父类。两者的灵活性差异很大。2.2 Java、Python、C 里继承的关键差异同为类继承不同语言的处理方式又有差别。这里挑几个最关键的对比点。Java 是典型的单根继承世界一个类只能有一个直接父类但可以实现多个接口。它用 final 关键字禁止继承或方法重写。方法默认是非虚的不对Java 实例方法默认是虚的除非用 final 修饰。不过 Java 的虚方法和 C 的 virtual 在底层实现上不一样这个后面讲。Python 支持多重继承并且引入 C3 线性化算法MRO方法解析顺序来解决菱形继承问题。MRO 算出来的顺序决定了同名方法到底调用谁。很多新手写 Python 多重继承翻车都是因为没搞懂 MRO。C 也支持多重继承还引入了虚继承来解决菱形问题。但虚继承的使用贼麻烦基类构造函数的调用规则、虚继承和普通继承的混合都是高发坑点。虽然 C 程序员常年背着这些复杂度但实际项目里谨慎的人还是尽量少用多重继承。C# 的继承和 Java 很像单继承、多接口但 C# 有 sealed 关键字阻止继承而且它的虚方法、抽象方法、接口实现都有更细粒度的控制比如 event、property override、new 隐藏等。C# 的方法隐藏new 关键字和 Java 的默认方法覆写还不一样需要特别注意。2.3 JavaScript 的不同继承方式别再只会 extendsJavaScript 的继承从 ES6 开始有了 class/extends但实际项目中你还会遇到原型写法、构造函数写法、Object.create 写法、组合继承、寄生继承等一堆名词。先看 ES6 classclass Animal { constructor(name) { this.name name; } eat() { console.log(this.name is eating); } } class Dog extends Animal { constructor(name) { super(name); this.type dog; } bark() { console.log(this.name is barking); } }这段代码和 Java 版几乎一一对应。但背后的机制还是原型链Dog.prototype 的原型是 Animal.prototypeDog 实例的原型是 Dog.prototypedog instanceof Animal 为 true是因为 instanceof 算法沿原型链查找 Animal.prototype。实际项目里常见的坑是子类构造函数里忘了调用 super或者调用了 super 但用了 this这样会报 ReferenceError。JS 引擎要求在访问 this 之前必须先调用 super这背后的原因很微妙ES6 class 的构造过程和普通函数不同this 是被父类构造函数创建出来的子类自己拿不到 this 的“创建权”。还有一类继承方式是 Object.setPrototypeOf 或直接改proto这种动态改原型的方式生产环境中要少用。它虽然灵活但会破坏引擎优化而且代码可读性极差维护的人要沿着运行时的逻辑才能推测对象结构非常不友善。2.4 一张表看明白常见语言的继承模型语言继承模型单继承/多继承实现方式关键字Java类继承单继承 多接口虚方法表extends, implementsC类继承多继承虚函数表指针:访问说明符C#类继承单继承 多接口虚方法表:, virtual/overridePython类继承多重继承 MRO字典查找 C3线性化class A(B)JavaScript原型继承对象链式继承原型链class/extends 或 Object.create这张表不是让你背而是帮你建立坐标遇到一个语言的继承问题先想清楚它属于哪一阵营再去套具体语法。3. 继承的实操要点从入门到有个像样的设计这一段是重点。我默认你已经能写简单的继承代码了所以直接讲那些容易被忽略、但实际项目里影响很大的细节。3.1 方法重写、重载、隐藏三种看似相似实则不同的特性方法重写override是子类重新定义父类中的同名方法Java、C、C#、Python 里都很常见。重载overload是在同一个类里定义多个同名但参数列表不同的方法它和继承没有直接关系因为重载方法的调用在编译期就确定了。方法隐藏hide则是 C# 里一个比较容易误用的特性。看这个例子class Base { public void Print() { Console.WriteLine(Base.Print); } } class Derived : Base { public new void Print() { Console.WriteLine(Derived.Print); } }Derived 用 new 关键字隐藏了 Base.Print。调用方式不同执行结果不同Derived d new Derived(); Base b d; d.Print(); // Derived.Print b.Print(); // Base.Print如果用 virtual/override 来代替 new那么上面两行都会输出 Derived.Print。这个话题经常被忽略但在 C# 团队协作时一个方法该写成 virtual 还是 new其实是在定义“这个类未来的扩展契约”值得认真对待。3.2 super 调用规则构造顺序决定了很多隐性问题在类继承里构造顺序是本话题的核心也是 Windows、Java、Python 的 C 各不一样的地方。Java 中子类构造器如果没有显式调用 super编译器会隐式调用父类的无参构造器。如果父类没有无参构造器就会编译报错。class Base { public Base(String name) { } } class Sub extends Base { public Sub() { // 编译错误Base 没有无参构造器 } }这个报错很常见解决办法是在子类构造器里显式调用 super(name)。C 的规则是如果你不在子类构造函数的初始化列表里指定基类构造函数那基类的默认构造函数会被调用。如果基类没有默认构造函数就会编译失败。Python 里的 super 调用要特别注意尤其是多重继承时super().init() 会把初始化调用沿着 MRO 传下去而不是只调直接父类。class A: def __init__(self): print(A) super().__init__() class B: def __init__(self): print(B) class C(A, B): def __init__(self): super().__init__() print(C) C() # 输出A B C这个例子里 C 的 MRO 是 C - A - B - object所以 super().init() 会依次调用 A.init和 B.init。如果 B 里没有 super().init()链条就断了。Python 的中 super 设计是协作式多重继承要求整个继承树都配合使用 super否则就会出现初始化缺失。3.3 访问修饰符protected 不是让你随便用继承体系里的访问修饰符直接影响子类能访问什么。Java 里四种访问级别修饰符同类同包子类所有public是是是是protected是是是否默认包私有是是否否private是否否否子类能访问父类的 protected但不能访问父类的 private。这个设计是为了保护父类的内部状态同时给子类足够的扩展空间。不过实际项目里protected 字段字段级别的 protected要谨慎使用。一旦把字段设为 protected它就成了类对外的一部分后续父类重构这个字段时所有子类都会受影响。更推荐的做法是字段全部 private子类通过 protected 方法访问或者修改。这样父类可以在方法内部校验数据、加日志也能在将来改变内部存储结构而不影响子类。3.4 虚方法和动态绑定的底层直觉Java 里方法默认支持动态绑定C 里需要显式加 virtual 关键字才能实现运行时多态。这个差异值得展开。Cclass Base { public: void normal() { cout Base::normal endl; } virtual void dynamic() { cout Base::dynamic endl; } }; class Derived : public Base { public: void normal() { cout Derived::normal endl; } void dynamic() override { cout Derived::dynamic endl; } }; int main() { Base* b new Derived(); b-normal(); // 输出 Base::normal b-dynamic(); // 输出 Derived::dynamic delete b; }第一个调用在编译期就绑定了 Base::normal第二个调用因为 virtual 存在运行时查虚函数表才找到 Derived::dynamic。这就是动态绑定与静态绑定最典型的差异。从底层直觉来看C 对象里有虚函数指针指向一张虚函数表。Java 类也有类似的虚方法表概念只不过 Java 对所有实例方法都做了动态分发设计上更省心代价是性能略低一点。这也是为什么我会建议如果要深入理解继承和多态C 的虚函数机制是非常值得研究的一块。它逼你搞清楚运行时多态到底是怎么发生的而不只是会写 override。3.5 组合优先于继承什么时候真的不该用继承这一点是面向对象设计里最被忽视、也最重要的议题之一。“组合优于继承”不是口号而是一套可以在代码里落地的判断标准。核心判断方法很简单如果 A is a B那用继承。如果 A has a B那用组合。举例来说需求是做一个“会飞的企鹅”。企鹅是鸟按 is-a 关系企鹅应该继承鸟但鸟通常会飞这就尴尬了。这时候如果硬用继承就得在企鹅类里把 fly 方法重写成“抛异常”这就违反里氏替换原则了。更好的设计是让企鹅和飞行为组合企鹅有一个飞行为接口但设置为“不会飞”的实现。这个例子烂大街但足够说明问题。实际项目中继承最大的问题不是语法而是它把父子类耦合得太紧。父类的一个小改动可能波及一大片子类。而组合可以把变化控制在需要的局部。我之前重构过一个订单模块原来所有订单类型都继承 Order 基类后来加了三种外部渠道订单继承树的深度从2层涨到5层改一个字段要翻好几个子类。后来改成组合方式把不同的“订单行为策略”注入到 Order 里代码量没多多少但可维护性明显提升。4. 进阶场景多继承、泛型、序列化这些坑要提前了解这一部分主要讲我在实际项目中遇到的“继承引发的连锁反应”。4.1 菱形继承问题C 虚继承与 Python MRO菱形继承指的是子类 D 同时继承 B 和 C而 B 和 C 都继承 A。此时 D 会得到两份 A 的数据还是共享一份这是个经典问题。C 里普通继承会复制出两份 A 的成员访问时会产生歧义。虚继承可以解决共享问题但代价是构造顺序变得反直觉而且虚基类的成员访问略慢。Python 里没有真正意义上的“菱形问题”因为 C3 线性化保证每个类在 MRO 里只出现一次同名方法调用只会命中一个。但这不意味着没坑MRO 顺序一旦没搞对初始化顺序就会出错前面 3.2 节的 super 例子已经说明了。Java 没有多重继承但是接口可以多实现接口之间的默认方法冲突需要手动解决。看这个interface A { default void hello() { System.out.println(A.hello); } } interface B { default void hello() { System.out.println(B.hello); } } class C implements A, B { Override public void hello() { A.super.hello(); // 必须手动指定调用哪个接口的默认方法 } }如果 C 不重写 hello()编译器会直接报错。这种冲突比 C 的菱形问题好处理得多但也提醒我们接口默认方法不是给你随意叠加的多实现时一定要想清楚冲突怎么解。4.2 泛型与继承ListString 和 ListObject 没有父子关系这是 Java 里很隐蔽但又特别常见的坑。ListString strs new ArrayList(); ListObject objs strs; // 编译错误表面上看 String 是 Object 的子类List 也应该是 List的子类但泛型容器没有这种协变关系。如果允许这样赋值那么往 objs 里塞一个 Integer运行时就会污染 strs导致取出时类型转换失败。Java 的解决办法是使用通配符List? extends Object list strs;这个写法表示“某种 Object 子类型的 List”可以读但不能往里写除了 null。理解这个对处理集合继承关系很有帮助像函数参数设计里用 List? extends T 接收只读集合、用 List? super T 接收写集合就是这个思想的应用。4.3 序列化、拷贝与继承字段的冲突如果你的对象要序列化JSON、Java Serializable、Python pickle继承关系会把字段逻辑变得复杂。Java 里如果父类没有实现 Serializable 而子类实现了父类字段不会被序列化反序列化时父类会用无参构造器重建。这种“半序列化”状态非常容易导致数据丢失。解决办法是父类也实现 Serializable或者给类加 serialVersionUID 来保证版本兼容。JavaScript 里 JSON.stringify 只序列化对象自身可枚举属性原型链上的属性不会被输出。这是一个很多人忽略但实际影响巨大的点你用 class 定义的方法在 prototype 上序列化出去没问题但如果你把方法定义在构造函数里通过 this.method function 绑上去JSON 序列化会把这些方法也带出去。所以写 JS 继承时方法放 class 里、字段放 constructor 里不仅是习惯也直接影响序列化结果。对象的深拷贝也有类似问题。普通深拷贝库通常只拷贝对象自身属性不维护原型链。如果两个类之间有继承关系直接深拷贝出来的对象可能不再拥有父类的方法。这个坑在把 JS 对象通过 web worker 或 postMessage 传输时尤其明显。5. 常见继承问题速查表与排查技巧实际开发里继承相关的问题通常不是语法报错而是行为不符合预期。这里整理一张速查表按症状来找原因最直接。症状可能原因解决思路Java 子类构造报“无法解析父类构造器”父类没有无参构造器子类未显式调用 super子类构造器里显式调用 super(参数)JS 子类构造函数访问 this 抛 ReferenceError在 super() 之前使用了 this确保 super() 在 this 之前调用C 调用子类方法却执行了父类实现方法没有加 virtual给父类方法加 virtual子类加 overridePython 多重继承初始化只执行了一部分MRO 链路中某个类漏掉 super().init检查所有参与类的init是否都调用 superJava 泛型无法把 ListString 传给 ListObject泛型不变性改用 ? extends 或 ? superJS JSON.stringify 丢失继承方法方法定义在实例上而非原型上用 class 定义方法用 constructor 定义字段修改父类方法后子类行为全变继承耦合过深考虑用组合替代继承明确扩展点Java 集合里同时存多个类的实例取出要判断类型没有充分利用多态用抽象父类引用接收调用虚方法而非强转排查这类问题时我自己的习惯是三步走先确认“这个对象到底是什么类型”。Java 里打印 getClass()JS 里打印 constructorPython 里用 type()。很多时候行为不对是因为实际类型不是你以为的那个。再确认“这个方法是从哪一层解析到的”。Java 里看类继承树JS 里沿着proto手动查一遍Python 里打印 ClassName.mro。最后确认“有没有方法被隐藏或意外解析到别的层”。特别是 C# 的 new 隐藏、JavaScript 原型链上同名的属性遮蔽这两类问题非常隐蔽。举个例子有一次一个前端同学在调用一个第三方组件的实例方法时发现报“not a function”。排查后发现组件内部用 Object.create(SomeBase.prototype) 创建了新对象但没把 constructor 指回新对象导致实例的 constructor 还是 Base实例方法查找链也和预期不一样。这种问题用“沿原型链手动走一遍”的方式最有效一眼就能看到问题出在哪个节点。6. 对象合并、判空、去重日常操作里和“继承”有关的细节这部分可能偏离继承主线但既然系列主题是“对象”有热度很高的几个对象操作问题也经常会和继承纠缠在一起所以一并说清楚。6.1 JavaScript 合并对象时prototype 会被合过去吗很多人在做对象合并时用的都是 Object.assign或者扩展运算符const target Object.assign({}, source); const target2 { ...source };这两种方式都只拷贝 source 的自身可枚举属性不会拷贝原型链上的属性。所以如果你在一个类实例上做合并得到的新对象是普通对象不再继承原型链上的方法。class Foo { constructor() { this.a 1; } getA() { return this.a; } } const foo new Foo(); const plain { ...foo }; console.log(plain.getA); // undefined解决方法也很简单如果你需要保留类型不要做浅合并而是让目标对象就是该类实例const clonedFoo new Foo(); Object.assign(clonedFoo, foo);这个坑在 redux、vue 和各类状态管理工具里很常见尤其是把类实例塞进全局 store 再取出来用的时候。状态管理框架一般会做深比较和深拷贝很容易把原型链弄丢。6.2 判断对象是否为空别再用糟糕的 if 判断“判断对象为空”看起来简单实际上坑不少。JS 里最容易翻车的写法是if (obj)因为空对象{}是 truthy直接 if 判断永远进不去分支。正确的判断方式const isEmpty (obj) { if (obj null || obj undefined) return true; return Object.keys(obj).length 0 obj.constructor Object; };这里把Object.keys和obj.constructor一起判断能规避两个问题一是 Object.keys 只数自身可枚举属性不会把原型链上的属性算进去适合大多数场景二是要判断是不是纯对象因为new Date()的 Object.keys 长度可能是 0但你不能说它是空对象。Java 里判断对象是否为空的常见工具是Objects.isNull谨慎用容易和 Optional 混淆和Optional。但 Optional 本身不是对象判空的神器它更偏向于链式调用时的空安全。我自己在项目里更常用工具类里封装好的 isEmpty 方法比如 Apache Commons 的MapUtils.isEmpty和CollectionUtils.isEmpty语义更清晰。6.3 对象数组去重和继承有什么关系对象数组去重的难点在于比较依据。JS 里直接Set去重是按引用两个内容相同但引用不同的对象会被视为不同元素所以对对象数组无效。正确的做法一般是按唯一字段去重const arr [{id: 1, name: a}, {id: 2, name: b}, {id: 1, name: c}]; const unique [...new Map(arr.map(item [item.id, item])).values()];这里用 Map 的 key 去手动去重最后拿到的是一个保留了首次出现顺序的去重数组。如果对象来自继承关系比如一个子类实例和父类实例混在数组里按引用去重可能误判。例如同一个子类对象出现了两次但第一次被当作父类型引用存储第二次是子类型引用存储用引用判断它们是两个引用但实际上指向同一个对象。这种情况建议先把对象转成可对比的签名比如 JSON.stringify 指定字段再去重或者统一封装一个 equals 方法像 Java 里重写 equals/hashCode 那样JS 里没有内建机制但可以在工具层做统一。7. 一次完整的实操用继承重构一段真实代码理论讲再多不如把一次真实小重构走一遍。这里用 Java 写一个员工薪资计算场景完整展示继承的设计和实现过程。7.1 需求描述与初始设计需求是公司有正式员工、小时工、实习生薪资计算规则各不相同。正式员工按月薪计算小时工按小时数乘时薪计算实习生按月薪但不参与奖金计算。如果不用继承代码会写成一坨 if-elsepublic class SalaryCalculator { public double calculate(Employee e) { switch (e.getType()) { case fulltime: return e.getMonthlySalary(); case hourly: return e.getHours() * e.getHourlyRate(); case intern: return e.getMonthlySalary(); default: throw new IllegalArgumentException(unknown type); } } }这种写法的问题很明显每增加一种员工类型就要改这个 switch违反开闭原则。而且 Employee 类要同时承载所有类型的字段小时工的 hourlyRate 和 hours 对正式员工来说是无意义的字段模型也被污染了。7.2 用继承重构第一步定义抽象基类 Employee抽出所有类型的共性工号、姓名、计算月薪的抽象方法。public abstract class Employee { protected String id; protected String name; protected int type; // 新增类型时不再需要这个字段 public Employee(String id, String name) { this.id id; this.name name; } public abstract double calculateMonthlyPay(); }第二步定义三个子类public class FullTimeEmployee extends Employee { private double monthlySalary; public FullTimeEmployee(String id, String name, double monthlySalary) { super(id, name); this.monthlySalary monthlySalary; } Override public double calculateMonthlyPay() { return monthlySalary; } } public class HourlyEmployee extends Employee { private double hourlyRate; private int hours; public HourlyEmployee(String id, String name, double hourlyRate, int hours) { super(id, name); this.hourlyRate hourlyRate; this.hours hours; } Override public double calculateMonthlyPay() { return hourlyRate * hours; } } public class Intern extends Employee { private double monthlySalary; public Intern(String id, String name, double monthlySalary) { super(id, name); this.monthlySalary monthlySalary; } Override public double calculateMonthlyPay() { return monthlySalary; } }第三步薪资计算入口变成public class Payroll { private ListEmployee employees new ArrayList(); public void addEmployee(Employee e) { employees.add(e); } public void printPayroll() { for (Employee e : employees) { System.out.printf(%s %s: %.2f%n, e.id, e.name, e.calculateMonthlyPay()); } } }这一段代码的核心收益是新增员工类型时只需要新增一个子类Payroll 和其它业务层代码完全不用动。业务层只依赖 Employee 抽象不依赖具体类型这就是开闭原则的落地。7.3 重构过程中的三个决策点第一个决策点type字段要不要保留。原实现了 type 字段用来做 switch 分发重构后不再需要因为多态已经替我们完成了分发。这是判断继承设计是否成功的一个标志如果你在子类里发现还要用 type 字段去区分行为那说明你的继承设计还没有到位可以考虑把行为上提到更合理的抽象层。第二个决策点id和name放在基类合理吗。合理因为它们对所有员工都有意义而且语义上“员工 is-a 有工号有姓名的个体”成立。但hourlyRate和hours放在 Employee 里就不合理因为它们只对小时工有意义。基类里出现无意义字段就是设计上“共性抽取过多”的警报。第三个决策点calculateMonthlyPay是抽象方法还是普通方法。这里必须是抽象方法因为它的共性是“每个员工都能算月薪”但计算方法各不相同。基类无法给出合理默认实现抽象方法是最合适的。这种重构做多了你会形成一种直觉看到 if-else 重复出现就会条件反射地思考是否可以用继承或策略模式来去掉。需要说明的是如果分支不会频繁变化写 if-else 完全没问题。过度抽象是比不抽象更难维护的。继承也好多态也罢都是为了应对“变化的方向”而设计的把不会变的代码硬抽象只会让代码更绕。7.4 这个场景里什么时候再用组合前面说了“组合优先于继承”这个例子如果继续扩展也会遇到继承的边界。假设实习生和正式员工都有“每月奖金”的概念但规则不同。如果继续往继承里塞就得让 Intern 重写一个支持奖金的方法或者加一个 BonusEligible 接口。再假设公司还有外聘顾问不按月薪算按项目结算这时他就不是 Employee 的合理子类而是另一种类型。更复杂的情况下员工类型可能两两组合全职 按小时加班、实习生 项目奖金、顾问 小时服务。这会让继承树的扩展指数上升这时候就值得把“薪酬策略”从“员工类型”里抽出来用组合方式注入了public class Employee { private PayStrategy payStrategy; // ... public double pay() { return payStrategy.calculate(); } }这样员工类型可以横向扩展薪酬策略也可以横向扩展两者不再绑定在一根继承上。这就是组合优于继承的真正含义。8. 个人经验与最后提示写了这么多年代码踩过继承的坑也不少最后分享几个对我来说最实用的体会。一是千万别把继承当默认选项。写子类之前先想清楚是不是真的 is-a 关系。如果只是想让新类现有类的部分代码组合是更稳妥的选择。继承一旦铺开非常难回头因为它在类型层面建立了强耦合改起来波及面很大。二是父类千万不要做太多“猜子类”的事情。设计父类时应该聚焦在子类共有的行为上而不是为将来的子类预留各种空洞接口。预留接口这事看着好看实际代码里十有七八用不上反而让大家不知道怎么实现才是合理的。三是在 JavaScript 里尽量用 class 语法而不是手动改 prototype。虽然手动改原型在功能上完全可行但项目维护时大家看到 class 一眼就懂看到手动改 prototype 就要花时间脑补原型链。开发是团队协作的事代码的可读性和设计的一致性比一时的灵活重要得多。这篇文章是“对象”系列的第三篇从继承的本质、不同语言的继承模型一路讲到了实操重构和常见坑。如果你能看到这里说明你对“对象”这件事是真的想弄明白。学习建议只有一条打开你手边的项目找一两个类继承或者原型链的代码用这篇文章的思路重新审视一遍比看多少篇理论都管用。