ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

继承与多态:掌握面向对象类层次设计的核心边界与工程实践

继承与多态:掌握面向对象类层次设计的核心边界与工程实践

很多同学在简历上写“熟悉面向对象三大特性”,但一到实际项目里,要么只会用 extends 往死里继承,要么一谈继承就色变,把所有层级都改成组合。我自己带项目这些年,发现继承和多态是最容易被“背概念背熟、用起来翻车”的两个点。这篇文章想用比较务实的方式,把继承、多态以及它们延伸出来的“类的层次关系”拆开聊清楚,重点讲它们各自解决什么问题、底层是怎么运作的、以及在实际代码里怎么把握边界。示例以 Java 为主,但原理对所有主流面向对象语言通用。如果你正在设计业务系统、写领域模型,或者纯粹想把 OOP 这块地基打扎实,这篇应该能给你一些能直接上手的思路。

1. 继承:少写代码之前,先想清楚“真的是是一种吗”

1.1 继承解决的远不只是“代码复用”

在项目里给新同学讲继承,我一般不从“复用”这个词开头,而是从一段真实的业务场景进入。比如订单要做通知推送,有短信、邮件、站内信三个渠道。如果每个渠道各写一套完全独立的组装、发送、回执逻辑,第一版当然能跑。但当产品提了一个“所有渠道都要加限流”的需求时,你就会发现要在三个地方重复地改几乎相同的代码。这种痛苦会逼着你去找公共行为的上移点。

继承在这个场景里的真实价值,是把大家都有的行为和属性上提到一个共同的父类或基类里,让子类只关心自己特有的差异。父类把通用逻辑写一次,所有子类天然获得;子类只需要覆盖需要变化的方法,或者补充自己的字段。这是继承最朴素的收益——减少重复,同时让系统里关于“同类事物”的约定有一个唯一存放处。

但这里必须泼一盆冷水:继承的代价和收益一样大。两个类一旦建立继承关系,它们就不再是独立的东西了。子类会“被动”接受父类的所有公开与受保护成员,父类的任何改动都会传导到子类。所以,判断要不要继承,不能只看“代码像不像”,要看“类型之间是否真有稳定的语义关联”。这一点比“代码复用”更重要,也是很多设计文档里写歪的地方。

1.2 IS-A 关系:继承仅有的语义入口

面向对象里对继承最经典的描述是 is-a(是一个)。经理是员工,圆形是形状,微信支付网关是支付网关,这些关系成立,继承才顺理成章。反过来,很多硬造出来的继承关系,仔细想根本不成立。比如把“营销活动”和“普通订单”做成继承,因为都含有商品、金额、会员信息;或者把“阅读器”和“编辑器”做成父子,因为都能打开文件、展示文本。这种“功能长得像”不是 is-a,只是 has-a 或 can-do,用继承只会让层级越建越虚伪。

我教团队时常用一句话判断:把子类对象交给父类变量去使用,所有依赖父类语义的代码,是否仍然能不出差错地工作?如果不能,说明这个继承是生造的。比如让“订单”继承“营销活动”,会出现“订单没有推广渠道也能被当作营销活动处理”这种奇怪逻辑,这就是语义违约。

所以一个可靠的继承体系,设计阶段就要先确认类型之间的语义关系,而不是写代码时看字段重合度。字段重合度高,说明你缺的可能是公共数据结构,或一个可组合的部件,而不一定是一个父类。

1.3 实现继承和接口继承:两种不同的“承继”

主流语言里,继承可以进一步拆分。以 Java 为例,class B extends A同时做了两件事:继承了 A 的具体方法实现,也继承了类型身份,使 B 能出现在所有期待 A 的地方。这叫实现继承。而class C implements I只继承“能力契约”,方法具体怎么写由实现类自己决定,这可以叫接口继承。

这两种继承在类层次设计里的分工很不一样。实现继承负责在相近类型之间复用代码,它会额外传递内部实现——子类可能依赖父类保护的字段、父类内部的方法调用顺序,所以耦合更强。接口继承只负责统一调用方式,不需要知道实现细节,很容易换掉底层实现,耦合最弱。现在很多架构风格都倾向多依赖接口,具体的实现继承链会被控制在小范围内。

你看看维护得好的中大型项目,类层次里的具体继承链通常很浅,往往是“接口 -> 抽象骨架类 -> 个位数具体实现类”这种三层结构。大量能力通过接口暴露,复用交给组合。这也是“组合优于继承”这句老话的真正底气。

1.4 访问级别就是继承体系的“安全边界”

继承关系建立后,父类里成员变量的访问级别决定了子类的自由度。private成员子类看不见,保护了父类内部实现。protected成员子类可以直接访问,是父类主动开给子类的扩展口子。public成员是公开契约。不少人写父类时把所有字段全设成protected图省事,结果子类随便改内部状态,过一个月没人说得清状态是谁改的。

更稳妥的做法是:父类公开的是行为,不是字段。内部可变状态尽量设置成private,需要让子类定制的地方,通过protected方法暴露意图明确的扩展点。比如做一个数据导入基类,父类处理打开文件、解析、落库的流程,子类只需要实现一个protected String parseLine(String line),父类就能保证整体流程不乱。把骨架定好、把钩子暴露出来,这才是继承更正确的用法,而不是拿继承去共享一堆散装代码。

这里补一个容易被忽略的细节:继承关系中,子类对象的构造过程会从父类开始逐层初始化。如果父类构造函数里调用了被子类覆盖的方法,你可能会在对象还没完全构造好时进入子类逻辑,这是很多诡异空指针的来源。关于这一点,后面常见问题部分还会再提。

2. 多态:用不变的调用点,接住多变的行为

2.1 同一条指令,不同的执行结果

多态的日常理解,用“同一个方法调用,在不同对象身上产生不同行为”来概括就够了。animal.makeSound(),如果animal实际是一只狗,就会汪汪叫,如果是一只猫,就会喵喵叫。让这句话成立的关键,不在于animal变量声明成什么类型,而在于运行到这句代码时,运行时环境到底把这个调用分派给了哪个具体类的方法。

很多初学者以为“多态就是重写方法”。其实重写(override)只是一个前置条件,多态的本质是运行时根据对象的真实类型去找到对应的方法实现,也就是动态绑定。重写只是把不同的实现写好了,真正让“一个调用点适配多种对象”的,是动态绑定。没有动态绑定,就算你在子类里重写了方法,通过父类引用去调用时,看到的可能还是父类实现——这正是 C++ 里为什么默认方法不是虚函数的原因,不写virtual就没有多态。

2.2 动态绑定背后:编译期类型与运行时类型的“分头行动”

可以把面向对象语言的运行机制拆成两个阶段来看。编译期,编译器只认变量声明时的类型,它会检查你的调用是否合法,并生成指向父类方法的调用信息。运行期,实际对象躺在堆里,它是某个具体子类的实例,虚拟机需要判断该调用谁。

打个生活化的比方:你手里握着一个“媒体播放器”的遥控器,遥控器上只有一个播放键。编译期你知道它是媒体播放器型遥控器,但运行期你才知道遥控器背后接的是音响、投影仪还是电视。按下同一个播放键,不同设备响应完全不同,但按按钮这个动作不需要变。这就是多态。

技术实现上,Java 每个类都有一张方法分发表,常被称为虚方法表(vtable)。类加载时,这张表记录了这个类里每个方法的实际入口地址。子类覆盖过的方法,在子类虚方法表里就指向子类自己的实现;没覆盖的,继续指向父类实现。运行时,当代码调用animal.makeSound(),支持多态的语言会先去对象真实类型的虚方法表里查,找到makeSound的真正入口再执行。这个查找过程对程序员透明,但理解它,能帮你想明白为什么子类引用赋值给父类后,调用的还是子类的方法。

C++ 略有不同:成员函数没有标记virtual时,编译器默认静态绑定,调用决策在编译期就定死了,只看静态类型。Java 在这方面更激进:所有可被继承的实例方法默认都是虚的,只是语法上不用写virtual,所以天然具备多态性;只有static、private、final修饰的方法不走动态绑定。Python 则走了一条更激进的路:没有编译期类型,所有方法调用都是运行时属性查找,天然是动态的。理解了这几门语言的差异,你就会明白多态不是某个语言专属的语法糖,而是面向对象设计里必须理解的一种运行时契约。

2.3 多态的触发条件:层级、重写、上转型引用,缺一不可

想要一个调用点真正产生多态,三个条件要同时成立。第一,参与的类必须处于同一个继承体系里,或者实现同一个接口。第二,子类(实现类)必须重写了父类(接口)里的方法,把父类的默认行为换成自己的差异化行为。第三,调用方的变量类型必须是父类或接口类型,运行时赋给它的却是某个具体子类的对象。

第三点很多人会疏忽。如果变量直接声明成具体子类,编译器就直接绑定子类方法了,虽然结果一样,但设计层面你已经把范围锁死,失去了换不同实现的弹性。我判断一段代码是否真的用好了多态,会看三要素里有没有“面向抽象编程”的意识。比如支付编排服务,变量类型如果写的是AlipayService、WechatPayService,那多态基本没起作用,因为调用点已经被具体类锁死;改成PaymentService接口,才能真正容纳后续新增的支付渠道。

2.4 覆盖和重载:一个是运行时,一个是编译期

聊多态绕不开这对常见混淆。重写(overriding)是指子类重新实现父类已有签名的方法,影响的是运行时的行为选择。重载(overloading)是指在同一个类里定义多个同名方法,靠参数列表区分,影响的是编译期选哪个方法。重载和多态没有直接关系,它相当于编译器根据你传的参数匹配菜单,匹配发生在编译期。真正把行为按运行时对象类型分派出去的,只有重写配合动态绑定。

Java 里重写还要求子类方法的访问权限不能更严格,也不能抛出更宽的受检异常。这些约束都是为了维持“子类对象可以安全替代父类对象”的里氏替换原则。写代码时如果感觉某个重写方法很别扭,像是在被逼着满足一些不合理的前提,那就该停下来想想继承关系是否合理了。

2.5 多态带来最实在的三个收益

第一是开闭。新增行为时,尽量不去修改已有稳定代码,而是加一个新类。支付路由的例子最直白:每出现一种新支付方式,不需要改订单服务的主流程,只要新增一个实现PaymentService的类即可。

第二是统一迭代。所有实现类面对同一个接口,团队可以并行开发多个渠道适配器,每个渠道的差异细节被封在自己类里,不会互相干扰。

第三是让策略成为可能。策略模式之所以在业务系统里大规模使用,就是因为多态把“选择算法”变成了“选择实现类”。你可以在运行时根据配置、灰度开关、用户画像等条件,往调用点里放入不同实现,而调用点本身一成不变。

我在一些项目里还把多态用得很朴素:根据文档类型走不同的解析器,根据报表类型生成不同的导出文件。这些东西如果都靠if-else堆在一个方法里,每加一种类型就要动公共方法,测试用例也越来越臃肿。改成多态之后,主流程干净得多,新类型自己承担自己的逻辑,整个系统的变更频率被极大地压低。

3. 类的层次关系:继承不是唯一连接方式,别把所有线都画成父子

3.1 除了继承,类与类之间还有五种关系

讨论继承时,一不小心会把类之间的关系等同于继承关系。实际上一张合格的类关系图里,至少有继承、实现、关联、聚合、组合、依赖这几种关系。早年画 UML 类图时,很多人把所有关系都画成箭头,一画就画成父子树,结果系统越设计越脆。这里不展开 UML 每种画法,只讲对你日常设计最重要的两条主线:is-a(继承/实现)和 has-a(组合/聚合)。

has-a 通俗讲就是“一个对象内部持有另一个对象”。一辆自行车拥有两个轮子、一个车架、一套刹车,这不是继承,是组合——轮子显然不是自行车的一种。组合关系下,你要换刹车片,只需要替换刹车对象,不需要动自行车的整个类型定义。这种灵活性和低耦合,是组合最吸引人的地方。

判断一条关系到底该画成继承还是组合,我经常这样问:把 B 对象传给一个处理 A 的接口,合不合适?合适,A 和 B 可能是继承关系;不合适,说明 B 只是包含了一部分 A 的特征,这时候应该用组合。比如订单包含地址信息,地址在多个订单里还可能被复用,这个包含无论怎么想都不像继承。

3.2 类层次里的三种坏味道

类层次设计最容易出问题的位置,我归纳成三种坏味道。

第一种是层级过深。一个基类下面摞了四层子类,第五层子类想加一个字段,但你知道它会被当作父类类型进行序列化或持久化,于是这个字段要么因为多态丢失,要么因为基类类型转换出现问题。层级越深,“这个字段到底归谁管”的问题就越难回答。

第二种是脆弱的基类。父类方法被一堆子类信任,一旦父类内部逻辑调整,所有子类行为都可能无声改变。业界有个经典说法叫脆弱的基类问题,讲的就是父类一个微小改动可能让远处某个没想过的子类崩掉。要降低这种风险,唯一可靠的办法是让父类的方法契约尽量稳定,少提供万能方法,多提供明确、窄口的扩展点。

第三种是“大而全基类”。为了让不同子类都用得上,基类里塞进大量不属于公共契约的字段,子类被迫继承一堆永远用不上的属性。这种伪组装类看似方便,实则把每个子类都绑在一堆无用包袱上。诊断方式很简单:如果某个子类用不到父类里超过一半的属性和方法,这个继承关系基本可以判定有问题。

3.3 菱形问题:多继承把层次从“链”变成“网”的代价

多继承在某些语言里是被允许的,比如 C++。它的直接后果是可能出现菱形问题:B 和 C 都继承 A,D 同时继承 B 和 C,那么 D 里头到底该保留一份还是两份 A 的成员?这会导致名字歧义和冗余拷贝问题。标准解法是虚继承,让 D 里只有一份 A 的公共部分。但虚继承在复杂性上的代价不小,所以现代主流语言大多放弃直接多继承,比如 Java 改成单继承类加多接口,一个类只能有一个父类,但可以实现多个接口。

就算语言允许多继承,我的个人建议也是能避则避。多继承把一个相对容易理解的一维链式层次,变成了一张多维的图,类之间每多一条边,系统的理解成本就高一份。当你发现一个类需要从两个父类各取一部分能力时,多数情况下合理的解法不是多继承,而是组合:让这个类持有那两个事物作为成员,而不是让自己成为它们。

3.4 抽象类与接口:两种重要的层次锚点

在类层次设计里,抽象类和接口承担的角色不同。抽象类往往是一个半成品骨架,它把子类公共的流程和字段先实现好,把需要变化的部分留给子类;它适合用在强血缘关系的类型组之间,比如文件解析器族:父类负责打开文件、读字节、关闭资源,子类负责解析具体格式。接口更像是一张可佩戴的能力牌,它不关心实现细节,只声明能力;一个类可以同时佩戴多张能力牌,比如一个类既能实现可序列化接口,又能实现可比较接口。

这个区别决定了你在搭层级时的选择偏好:如果两个类型组之间有稳定的种属关系,抽象类更合适;如果只是能力相似,更建议用接口。很多团队的规范里甚至会直接写:继承抽象类不得超过三层,接口实现不做层级限制,目的就是防止抽象类链无限加深。

还要提醒一点:抽象类一旦出现,它与具体子类之间的关系往往就已经固定,后续很难再插一个不同种属的东西进来。所以抽象类的引入一定要慢,要等至少两三个具体类验证了公共骨架确实稳定后再提取。不要第一个类刚写完就抽出抽象类,不然你提炼的“公共骨架”很可能只是第一个类的特殊形态。

4. 实操案例:把一个 if-else 堆满的订单发货模块,重构为继承加多态的层次结构

4.1 需求背景与初版代码的问题

我用一个常见的业务场景来演示。某项目的订单系统里有一个发货服务,需要支持三种发货方式:快递发货、门店自提、虚拟商品发放。初版代码长这样:一个ship(Order order)方法里,根据order.getType()的值写if-else,每种分支里都有一段“查库存、组装发货单、更新订单状态、发送通知”的逻辑。虽然能跑,但问题很明显。第一,每种发货渠道的公共流程被重复抄了三遍。第二,每新增一种发货方式,就要回到这个公共方法里加一个else if,主流程越来越膨胀。第三,测试时,这个公共方法需要覆盖所有渠道的组合,用例数量成倍上涨。

很多业务系统走到这一步后,会在发货这个动作上继续堆叠一大批参数和分支。真正推动重构的信号,往往是产品又提了需求:所有发货方式都要增加“打印面单”的能力。你发现又要跑到三个分支里分别加代码,这个“重复改三个地方”的动作就已经非常明确地指出——公共流程该上移了。

4.2 第一刀:把公共流程抽成抽象基类

重构的第一步,把“查库存、组装发货单、更新订单状态、发送通知”这个执行骨架固定下来。定义一个抽象基类AbstractShipmentHandler,里面用一个final的handle(Order order)方法编排整体流程,流程的每一步要么用父类默认实现,要么调用抽象方法让子类去实现。

这一步的关键在于,流程顺序完全一致,只有内部细节不一样。比如库存检查,快递和自提都需要查可售库存,但虚拟商品查的是虚拟库存甚至不需要扣减,所以可以把checkStock设计成 protected 的可覆盖方法,或者干脆声明成抽象方法让子类各自管。这样父类保证了流程永不乱序,子类只需要回答每一段“这段怎么做”。

public abstract class AbstractShipmentHandler { public final void handle(Order order) { validate(order); checkStock(order); createShipment(order); updateStatus(order); notifyCustomer(order); } protected abstract void checkStock(Order order); protected abstract void createShipment(Order order); protected void validate(Order order) { if (order == null || order.getItems() == null || order.getItems().isEmpty()) { throw new IllegalArgumentException("订单为空或没有商品明细"); } } protected void updateStatus(Order order) { order.setStatus(OrderStatus.SHIPPED); // 落库更新 } protected void notifyCustomer(Order order) { // 默认发送一条短信通知,子类可覆盖 } }

实际编码时,我更喜欢用模板方法模式:父类把骨架方法写成final,防止子类重排流程;把需要扩展的点放到protected abstract方法,并给出清晰命名与注释。这样做把变化点显式暴露出来,之后的维护者一眼就能看到该在哪里扩展,也避免了子类悄悄改掉整个流程。

4.3 第二刀:用多态替换发货类型的分支

骨架搭好之后,原来的ship方法就成了一个路由器:根据订单类型找到对应的ShipmentHandler实现类,然后调用handler.handle(order)。这个路由逻辑可以用一个工厂来做,订单类型作为 key,分发表里存着 handler 实例。这样新增一种发货方式时,主流程零改动,只需要新写一个 handler 实现类,并登记到工厂的分发表里即可。

public class ShipmentRouter { private final Map<OrderType, AbstractShipmentHandler> handlers; public ShipmentRouter() { handlers = new HashMap<>(); handlers.put(OrderType.EXPRESS, new ExpressShipmentHandler()); handlers.put(OrderType.PICKUP, new PickupShipmentHandler()); handlers.put(OrderType.VIRTUAL, new VirtualShipmentHandler()); } public void ship(Order order) { AbstractShipmentHandler handler = handlers.get(order.getType()); if (handler == null) { throw new UnsupportedOperationException("不支持的订单类型:" + order.getType()); } handler.handle(order); } }

这里的关键是,所有条件判断都被挡在工厂这一层。路由和业务被拆开:路由只解决根据类型找类,业务逻辑封装到具体的 handler 中。多态发挥的作用,就是让调用点只面对AbstractShipmentHandler,能不能正确执行,取决于运行时传入的对象类型。

如果你不想写工厂,也可以直接用Map<OrderType, ShipmentHandler>在启动时注册,更直观,也方便测试时注入 mock 实现。我建议不要把这个 Map 写死在每个调用现场,而是集中在一个静态注册表里,这样整个系统的发货能力一目了然。

4.4 第三刀:用组合来收编散落的配置

把 handler 结构搭好之后,再往回看,会发现每个 handler 除了行为之外,还带一批渠道相关的配置——比如快递公司的 API 地址、门店自提点的营业时间、虚拟商品的卡密批次规则。如果把这些配置全部变成抽象基类的字段,父类又会开始膨胀。这里应该用组合:让每个 handler 持有一个ShipmentConfig对象,再按需拆分成ExpressConfig、PickupConfig、VirtualConfig等更细的配置对象。

这个阶段的设计,本质上是把“继承管行为层级、组合管数据组装”两件事分开。行为上的共性通过继承和接口来收敛,数据上的差异化通过组合来注入。这样做还有个额外好处:配置对象可以独立缓存、独立升级,不影响 handler 的继承链;如果你想让某几个渠道共享一套配置,也能通过注入同一个配置对象实例实现。

最终这个模块的类层次大概是:ShipmentHandler接口(能力协议) ->AbstractShipmentHandler抽象类(流程骨架) -> 三个具体 handler(快递、自提、虚拟商品)。每个 handler 内部组合自己的配置对象。代码里没有一层多余的继承,也没有一个调用点写着具体的发货类型分支。测试时,每一个 handler 都是独立可测的,工厂部分还能用参数化测试覆盖。

这段重构做完,我通常会告诉团队:继承是用来收敛同类行为的共性,多态是用来隔离同类行为里的差异,组合是用来组装不同维度的对象。三者聊的不是同一个问题,配合使用才是正经事。

5. 类层次设计中的常见问题与排查经验速查

5.1 “我感觉应该继承”时的五连问

当你有两个类准备建立继承关系时,建议先过一遍下面这些问题再动手。

  • 这个子类真的“是一种”父类吗?换成 has-a 描述是不是更合理?比如“订单是营销活动”肯定不对,但“订单里包含活动信息”非常自然。
  • 如果把子类对象赋值给父类变量,所有依赖父类的方法能正常工作吗?伪造关系在这里最容易暴露。
  • 父类里有没有超过一半的成员是这个子类用不上的?有就说明继承边界不对,该拆配置、拆接口。
  • 如果父类要改一个方法,你希望所有子类都不受扰动吗?如果你并不希望,说明把方法做成可覆盖扩展比直接改父类更安全。
  • 有没有可能后面会出现一个只是长得像但行为完全不同的类?有的话,接口比抽象继承更合适,因为接口不绑定实现。

这五个问题救过我很多次。有个项目里,同事想把“优惠券”和“满减活动”做成继承,因为都有金额、门槛、有效期。五个问题过完,大家发现“优惠券是满减活动的一种”这个判断根本站不住,优惠券可能不设门槛,满减活动也可能不限渠道,行为轨迹差异很大。最后我们改成“优惠券”持有“活动规则”对象,反而更灵活。

5.2 继承链上的三个经典陷阱

第一个陷阱:构造函数里调用被重写的方法。这几乎是所有讲继承的坑里翻车率最高的。如果父类构造函数里调用的方法被子类重写了,创建子类对象时,父类构造函数先执行,此时子类字段还没初始化,被重写的方法直接访问子类字段就拿到 null 或默认值。避坑方法是,父类构造函数里只做纯粹的初始化,绝不调用可覆盖的方法。

第二个陷阱:把父类字段暴露成 protected 后,子类之间互相干扰。尤其当两个子类从一个父类继承时,如果它们都直接改写父类的某个字段,而父类其他方法依赖这个字段,那么一个子类可能带上另一个子类的遗留状态,尤其是在实例池、缓存场景中。最稳妥的做法是,父类尽量维护自己的私有状态,子类的扩展字段封闭在子类内部。

第三个陷阱:序列化或持久化时的类型退化。一个Student对象赋给Person引用后,如果某个框架用Person类型做了序列化协议,子类独有的字段很容易丢失。这不算一个设计错误,但要意识到:类的层次越深,多态场景下按引用类型序列化的风险就越高。如果不想丢字段,就要明确序列化时使用真实类型,或者把可变字段放到能被父类协议包含的地方。

5.3 排查技巧:快速定位“调用的到底是哪个类的方法”

实际工作中,当你发现行为不对但找不到原因时,最快的办法就是在调用点打印this.getClass().getName()(Java),或者type(self).__name__(Python),马上能看到运行时对象的真实类型。这个技巧在处理复杂继承链时非常管用——很多时候你以为传入的是 A 类对象,实际上外层框架已经包装成 B 类对象。肉眼读代码很难发现这种重定向,打一行日志往往几秒钟就定位了。

如果你怀疑某个调用被哪个子类重写覆盖“截胡”了,可以用调试器在父类方法入口打上断点,观察调用栈。如果父类方法断点根本没命中,说明调用被更靠前的子类实现截住了;如果命中了,但行为还是不对,就要看是不是子类调用super时才走到了这里。调试器对这类问题的诊断效率远高于代码走查。

另外还有一个设计层面的习惯值得养成:在类图之外,给每个抽象方法写上“预期行为描述”注释。很多诡异的多态问题,本质上是不同子类的实现者对同一个抽象方法的语义理解不一致——有人以为parse是“解析并返回原始数据”,有人以为是“解析并返回加工结果”,结果同样一个方法在两个子类里行为不同。接口文档或注释如果缺席,层级越深这种语义漂移越严重。

5.4 给新手的三个自查问题

每次代码评审,我都会让刚入门的新同学自己过一遍这三件事。第一,这个继承关系是否真的反映领域里的“是一种”?第二,调用方是否只依赖抽象类型,而不是具体子类?第三,如果我今天新增一个子类,已有的类需要改动吗?需要改动就是扩展点没找对。这三个问题虽然简单,但能拦住绝大多数过度设计和错误继承。

我个人的经验是,继承问题讨论到最后,往往不是语法问题,而是设计取舍问题。它像一把锋利的刀,用好了可以让系统主干稳定、扩展顺畅,用不好就会挖出一堆脆弱的耦合层。与其纠结到底能不能继承,不如先想清楚调用方需要依赖什么。面向抽象编程这几个字,背起来容易,真正落到设计里,需要把继承、多态、组合搭配着反复练。真到了后边,你会发现好的类层次不是画得漂亮,而是改一个需求时,你不需要满项目跑着找该改哪里。

最后分享一个我经常用的检查手法:写任何一组类层次之前,先写一段调用方视角的伪代码——我现在要换一种实现,它需要改动多少行?如果答案是一行都不改,只是加了一个新类,那这个层次结构才算是站住了。这个方法简单又实用,我自己在项目里用它拦截了不知道多少次“先继承再说”的冲动。希望这篇文章能帮你在设计类层次时少走几条弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进