继承的本质:从类继承到原型链,掌握面向对象的类型体系
2026/9/16 3:08:37 网站建设 项目流程

开发做了几年,很多人写代码还是在“照着模板堆函数”,问到对象是什么能说两句,问到继承为什么要这么设计、不同语言里继承到底差在哪,就含糊了。这篇是“对象”系列的第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) { Map<String, String> params = buildPayParams(orderNo, amount); String sign = sign(params); params.put("sign", sign); return buildPayUrl(params); } protected abstract Map<String, String> buildPayParams(String orderNo, int amount); protected abstract String sign(Map<String, String> params); protected abstract String buildPayUrl(Map<String, 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 eating

dog 对象本身没有 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 的不同继承方式,别再只会 extends

JavaScript 的继承从 ES6 开始有了 class/extends,但实际项目中,你还会遇到原型写法、构造函数写法、Object.create 写法、组合继承、寄生继承等一堆名词。

先看 ES6 class:

class 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.prototype,Dog 实例的原型是 Dog.prototype,dog instanceof Animal 为 true,是因为 instanceof 算法沿原型链查找 Animal.prototype。

实际项目里常见的坑是:子类构造函数里忘了调用 super,或者调用了 super 但用了 this,这样会报 ReferenceError。JS 引擎要求在访问 this 之前必须先调用 super,这背后的原因很微妙:ES6 class 的构造过程和普通函数不同,this 是被父类构造函数创建出来的,子类自己拿不到 this 的“创建权”。

还有一类继承方式是 Object.setPrototypeOf 或直接改proto,这种动态改原型的方式生产环境中要少用。它虽然灵活,但会破坏引擎优化,而且代码可读性极差,维护的人要沿着运行时的逻辑才能推测对象结构,非常不友善。

2.4 一张表看明白常见语言的继承模型

语言继承模型单继承/多继承实现方式关键字
Java类继承单继承 + 多接口虚方法表extends, implements
C++类继承多继承虚函数表指针:访问说明符
C#类继承单继承 + 多接口虚方法表:, virtual/override
Python类继承多重继承 + 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 关键字才能实现运行时多态。这个差异值得展开。

C++:

class 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 泛型与继承:List<String> 和 List<Object> 没有父子关系

这是 Java 里很隐蔽但又特别常见的坑。

List<String> strs = new ArrayList<>(); List<Object> 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,子类加 override
Python 多重继承初始化只执行了一部分MRO 链路中某个类漏掉 super().init检查所有参与类的init是否都调用 super
Java 泛型无法把 List<String> 传给 List<Object>泛型不变性改用 ? extends 或 ? super
JS JSON.stringify 丢失继承方法方法定义在实例上而非原型上用 class 定义方法,用 constructor 定义字段
修改父类方法后子类行为全变继承耦合过深考虑用组合替代继承,明确扩展点
Java 集合里同时存多个类的实例,取出要判断类型没有充分利用多态用抽象父类引用接收,调用虚方法而非强转

排查这类问题时,我自己的习惯是三步走:

  1. 先确认“这个对象到底是什么类型”。Java 里打印 getClass(),JS 里打印 constructor,Python 里用 type()。很多时候行为不对,是因为实际类型不是你以为的那个。
  2. 再确认“这个方法是从哪一层解析到的”。Java 里看类继承树,JS 里沿着proto手动查一遍,Python 里打印 ClassName.mro
  3. 最后确认“有没有方法被隐藏或意外解析到别的层”。特别是 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.keysobj.constructor一起判断,能规避两个问题:一是 Object.keys 只数自身可枚举属性,不会把原型链上的属性算进去,适合大多数场景;二是要判断是不是纯对象,因为new Date()的 Object.keys 长度可能是 0,但你不能说它是空对象。

Java 里判断对象是否为空的常见工具是Objects.isNull(谨慎用,容易和 Optional 混淆)和Optional。但 Optional 本身不是对象判空的神器,它更偏向于链式调用时的空安全。我自己在项目里更常用工具类里封装好的 isEmpty 方法,比如 Apache Commons 的MapUtils.isEmptyCollectionUtils.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-else:

public 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 List<Employee> 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 字段去区分行为,那说明你的继承设计还没有到位,可以考虑把行为上提到更合理的抽象层。

第二个决策点:idname放在基类合理吗。合理,因为它们对所有员工都有意义,而且语义上“员工 is-a 有工号有姓名的个体”成立。但hourlyRatehours放在 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 就要花时间脑补原型链。开发是团队协作的事,代码的可读性和设计的一致性比一时的灵活重要得多。

这篇文章是“对象”系列的第三篇,从继承的本质、不同语言的继承模型,一路讲到了实操重构和常见坑。如果你能看到这里,说明你对“对象”这件事是真的想弄明白。学习建议只有一条:打开你手边的项目,找一两个类继承或者原型链的代码,用这篇文章的思路重新审视一遍,比看多少篇理论都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询