Double Dispatch 双重分派模式在 Java 中的实现:基于运行时双对象类型的多态增强

  • 示例工程
  • 教程

【免费下载链接】java-design-patterns

Design patterns implemented in Java

项目地址: https://gitcode.com/GitHub_Trending/ja/java-design-patterns
点击查看 免费下载

双重分派(Double Dispatch)是一种行为型设计模式,用于让一次方法调用的实际行为由调用方对象与参数对象两者的运行时类型共同决定。本指南以 java-design-patterns 仓库中的 double-dispatch 模块(碰撞检测示例)为主线,讲解该模式的思想来源、实现骨架、运行流程、适用场景与权衡,并深入对应源码与测试,帮助你在 Java 这种仅原生支持单分派(single dispatch)的语言中实现多分派能力。

模式定位:它要解决什么问题

Java 的对象方法调用默认是单分派(single dispatch):编译器根据「接收者对象的动态类型」选择具体实现(即多态 / 虚函数调用),而参数类型只在编译期参与重载(overload)解析。当行为还需要依据参数对象的运行时类型来变化时,单分派就无能为力了——你只能写出成堆的 instanceof 分支。

双重分派正是为了解决这一问题:它将一次「按两个对象类型共同决定行为」的调用,拆解为两次单分派调用,从而在 Java 中模拟出多分派(multiple dispatch)的效果。

意图(Intent)

双重分派模式用于基于方法调用中两个对象的运行时类型实现动态多态:方法行为随「被调用对象」与「传入参数对象」两者运行时类型的组合而不同。

别名

  • Multi-methods(多方法)

标准定义

维基百科对双重分派的界定是:它是多分派的一种特殊形式,是一种将函数调用分派到不同具体函数的机制,具体调用哪个函数取决于调用中涉及的两个对象的运行时类型。在大多数面向对象系统中,调用哪个具体函数只取决于单个对象的动态类型,因此被称为单分派调用或虚函数调用——双重分派正是对这种局限的突破。

现实世界的类比

物流公司场景:卡车、无人机、自行车等不同类型的运输车辆,要与易碎、超重、标准等不同类型的包裹交互。双重分派模式用于决定最优配送方式:卡车处理超重件、无人机快速投递轻件、自行车负责市区。每一对「车辆 × 包裹」组合都会产生不同的处理与配送策略,并且这些策略是在运行时根据车辆和包裹两者的类型动态确定的。

用直白的话说:双重分派允许程序根据一次调用中涉及的两个对象的类型,选择执行不同的函数,从而更灵活地处理两者之间的交互。

源码级实现:以游戏对象碰撞为例

仓库中的 double-dispatch 模块用「太空物体碰撞」演示该模式:FlamingAsteroid(燃烧小行星)、Meteoroid(流星体)、SpaceStationMir(和平号空间站)、SpaceStationIss(国际空间站)等游戏对象相互碰撞时,不同组合应有不同的后果——例如燃烧小行星撞上空间站会让其损坏并起火,而普通流星体撞击只会让其损坏。

继承与组合的整体结构

从源码结构可以梳理出如下类层次:

  • Rectangle:基类,保存 left/top/right/bottom 四个坐标字段,提供 intersectsWith(Rectangle) 判断两个矩形是否重叠;
  • GameObject:抽象类,继承 Rectangle,增加 damaged(是否损坏)与 onFire(是否着火)两个状态字段,声明五组抽象方法(见下);
  • Meteoroid extends GameObject、FlamingAsteroid extends Meteoroid、SpaceStationMir extends GameObject、SpaceStationIss extends SpaceStationMir:四个具体游戏对象类。

其中值得注意的继承关系:

  • FlamingAsteroid 继承自 Meteoroid,构造时通过 setOnFire(true) 将自己标记为着火状态(见 FlamingAsteroid.java);
  • SpaceStationIss 继承自 SpaceStationMir,直接复用父类的碰撞处理逻辑,仅重写入口方法 collision(见 SpaceStationIss.java)。

双重分派的两段式方法设计

GameObject 是所有参与碰撞对象的共同抽象,它声明了模式所需的全部方法签名(见 GameObject.java):

public abstract void collision(GameObject gameObject);        // 第一次分派的入口

// 第二次分派的"接收者重载"方法族:为每一类可能的对手各声明一个重载
public abstract void collisionResolve(FlamingAsteroid asteroid);
public abstract void collisionResolve(Meteoroid meteoroid);
public abstract void collisionResolve(SpaceStationMir mir);
public abstract void collisionResolve(SpaceStationIss iss);

这里 collision 是第一次分派的虚拟调用入口,而 collisionResolve(...) 的四个重载则构成第二次分派的落点。每个具体子类都需要实现这 5 个方法(其中 4 个重载负责描述「对方是某类型时我该如何反应」)。

以两个子类为例,第一次分派的实现方式是统一的(见 Meteoroid.java 与 FlamingAsteroid.java):

// Meteoroid
@Override
public void collision(GameObject gameObject) {
  gameObject.collisionResolve(this);   // 把"自己"作为参数反向调用对方
}

// FlamingAsteroid
@Override
public void collision(GameObject gameObject) {
  gameObject.collisionResolve(this);
}

关键在于 collisionResolve(this) 中的 this:它的静态类型是调用者自身的类型,因此编译期重载解析(overload resolution)就锁定了应该调用 collisionResolve(FlamingAsteroid) 还是 collisionResolve(Meteoroid) 等具体重载;而运行期再对接收者对象(即 gameObject 参数所指的对方)做一次动态分派。两次分派叠加,行为就同时由双方类型决定了。

第二次分派的具体行为:以 Meteoroid 与 SpaceStationMir 为例

Meteoroid 的四个 collisionResolve 重载都只记录一条日志(见 Meteoroid.java),日志模板取自常量类 AppConstants.HITS = "{} hits {}."(见 AppConstants.java):

@Override
public void collisionResolve(FlamingAsteroid asteroid) {
  LOGGER.info(AppConstants.HITS,
      asteroid.getClass().getSimpleName(), this.getClass().getSimpleName());
}

@Override
public void collisionResolve(Meteoroid meteoroid) {
  LOGGER.info(AppConstants.HITS,
      meteoroid.getClass().getSimpleName(), this.getClass().getSimpleName());
}

@Override
public void collisionResolve(SpaceStationMir mir) {
  LOGGER.info(AppConstants.HITS, mir.getClass().getSimpleName(), this.getClass().getSimpleName());
}

@Override
public void collisionResolve(SpaceStationIss iss) {
  LOGGER.info(AppConstants.HITS, iss.getClass().getSimpleName(), this.getClass().getSimpleName());
}

而 SpaceStationMir 则展示了「不同对手产生不同后果」的差异化逻辑(见 SpaceStationMir.java):

  • 被 FlamingAsteroid 撞击:setDamaged(true) 且 setOnFire(true)——损坏并起火;
  • 被 Meteoroid / 另一个 SpaceStationMir / SpaceStationIss 撞击:仅 setDamaged(true)——只损坏不起火。

由于 SpaceStationIss 继承自 SpaceStationMir,它会直接复用上述逻辑;FlamingAsteroid 继承自 Meteoroid 且未重写任何 collisionResolve,因此小行星在作为「被撞击方」时也复用 Meteoroid 的仅记录日志行为。

调用链与运行流程

程序入口 App.java 演示了完整用法:初始化四个坐标不同的对象后,对所有「对象对」做双重循环,跳过自身,用 intersectsWith 判断矩形是否重叠,重叠则调用 o1.collision(o2):

public static void main(String[] args) {
  LOGGER.info("Init objects and print their status");
  var objects = List.of(
      new FlamingAsteroid(0, 0, 5, 5),
      new SpaceStationMir(1, 1, 2, 2),
      new Meteoroid(10, 10, 15, 15),
      new SpaceStationIss(12, 12, 14, 14));
  objects.forEach(o -> LOGGER.info(o.toString()));

  LOGGER.info("Collision check");
  objects.forEach(o1 -> objects.forEach(o2 -> {
    if (o1 != o2 && o1.intersectsWith(o2)) {
      o1.collision(o2);
    }
  }));

  LOGGER.info("Print object status after collision checks");
  objects.forEach(o -> LOGGER.info(o.toString()));
}

以 FlamingAsteroid 撞上 SpaceStationMir 为例,完整的两次分派过程是:

  1. 入口处调用 o1.collision(o2),其中 o1 的运行时类型是 FlamingAsteroid,因此第一次动态分派到 FlamingAsteroid.collision(GameObject);
  2. 该方法内部执行 gameObject.collisionResolve(this),this 的静态类型是 FlamingAsteroid,编译期即选定重载 collisionResolve(FlamingAsteroid);
  3. 由于 gameObject 的运行时类型是 SpaceStationMir,第二次动态分派把该调用送往 SpaceStationMir.collisionResolve(FlamingAsteroid),从而执行「损坏 + 起火」逻辑。

矩形重叠判断本身由 Rectangle.intersectsWith 提供,其判定条件为四个反向不等式均不成立:

boolean intersectsWith(Rectangle r) {
  return !(r.getLeft() > getRight()
      || r.getRight() < getLeft()
      || r.getTop() > getBottom()
      || r.getBottom() < getTop());
}

运行输出

执行 App.main(该模块的 main class 为 com.iluwatar.doubledispatch.App,由 pom.xml 中的 maven-assembly-plugin 配置)后,典型输出如下:

15:47:23.763 [main] INFO com.iluwatar.doubledispatch.App -- Init objects and print their status
15:47:23.772 [main] INFO com.iluwatar.doubledispatch.App -- FlamingAsteroid at [0,0,5,5] damaged=false onFire=true
15:47:23.772 [main] INFO com.iluwatar.doubledispatch.App -- SpaceStationMir at [1,1,2,2] damaged=false onFire=false
15:47:23.772 [main] INFO com.iluwatar.doubledispatch.App -- Meteoroid at [10,10,15,15] damaged=false onFire=false
15:47:23.772 [main] INFO com.iluwatar.doubledispatch.App -- SpaceStationIss at [12,12,14,14] damaged=false onFire=false
15:47:23.772 [main] INFO com.iluwatar.doubledispatch.App -- Collision check
15:47:23.772 [main] INFO com.iluwatar.doubledispatch.SpaceStationMir -- FlamingAsteroid hits SpaceStationMir. SpaceStationMir is damaged! SpaceStationMir is set on fire!
15:47:23.773 [main] INFO com.iluwatar.doubledispatch.Meteoroid -- SpaceStationMir hits FlamingAsteroid.
15:47:23.773 [main] INFO com.iluwatar.doubledispatch.SpaceStationMir --  {} is damaged! hits Meteoroid.
15:47:23.773 [main] INFO com.iluwatar.doubledispatch.Meteoroid -- SpaceStationIss hits Meteoroid.
15:47:23.773 [main] INFO com.iluwatar.doubledispatch.App -- Print object status after collision checks
15:47:23.773 [main] INFO com.iluwatar.doubledispatch.App -- FlamingAsteroid at [0,0,5,5] damaged=false onFire=true
15:47:23.773 [main] INFO com.iluwatar.doubledispatch.App -- SpaceStationMir at [1,1,2,2] damaged=true onFire=true
15:47:23.773 [main] INFO com.iluwatar.doubledispatch.App -- Meteoroid at [10,10,15,15] damaged=false onFire=false
15:47:23.773 [main] INFO com.iluwatar.doubledispatch.App -- SpaceStationIss at [12,12,14,14] damaged=true onFire=false

可以看到碰撞后 SpaceStationMir 变为 damaged=true onFire=true,SpaceStationIss 变为 damaged=true onFire=false——正是由双方类型组合决定的差异化结果。

为什么不建议用 instanceof:模式的设计动机

在 App.java 的类注释中,作者明确阐述了模式动机:

  • 若用 instanceof 链判断参数类型,每当新增类型时都必须修改现有方法的实现并追加新的 instanceof 分支,这违反单一职责原则(一个类不应有多个变更理由);
  • 双重分派通过在参数对象上再做一次虚拟调用来替代 instanceof 判断,使新功能可以在不修改既有实现的前提下加入——这正是开闭原则(Open/Closed Principle)的体现。

测试对模式行为的验证

仓库为每个游戏对象类都提供了针对性测试,共同构成对双重分派行为的验证:

  • AppTest.java:断言 App.main 执行不抛异常;
  • CollisionTest.java:抽象测试基类,定义 testCollision(other, otherDamaged, otherOnFire, thisDamaged, thisOnFire),用被测对象依次与各类对手碰撞,并断言双方碰撞后的 damaged / onFire 状态是否符合预期;
  • FlamingAsteroidTest、MeteoroidTest、SpaceStationMirTest、SpaceStationIssTest 等则分别继承 CollisionTest,用具体参数组合(例如「小行星撞击某对象后,对方应损坏并起火」)固化各组合的行为契约。

这些测试从实现层面证实:每次 collision(other) 调用都会沿两条分派路径落到与「双方具体类型组合」对应的 collisionResolve 重载上。

何时使用双重分派

根据 README 与源码设计,以下场景适合采用该模式:

  • 方法行为不仅取决于调用它的对象,还取决于参数的类型;
  • 当用 if-else / switch-case 对参数类型做判断显得繁琐且难以扩展时;
  • 希望在领域类中实现对象间交互操作,同时不让领域类代码被关于其他领域类的复杂决策逻辑污染(例如示例中的 GameObject 及其子类只关心「我是谁、我如何被撞击」)。

现实世界的典型应用还包括:

  • 图形用户界面:不同类型鼠标事件(单击、双击、拖拽)与不同类型界面元素交互时触发不同动作;
  • 仿真系统:不同类型对象之间的交互需要触发截然不同的行为。

收益与代价

收益(Benefits)

  • 提升灵活性:对象间交互逻辑以易理解、易维护的方式组织;
  • 符合开闭原则:新增对象类型时无需修改既有类,只需新增子类并实现对应的方法族(示例中新增 SpaceStationIss 仅靠继承与一个入口方法即完成扩展)。

代价(Trade-offs)

  • 代码结构更复杂:在 Java 这类不原生支持该模式的语言中,需要为每种可能的对手类型都维护一个重载方法,方法族随类型数量增长;
  • 维护与扩展成本:每新增一个对象类型,通常需要给所有参与交互的类补充对应的 collisionResolve 重载,工作量随参与类型数量呈组合式增长。

与其他设计模式的关系

  • Strategy(策略):两者意图相近——都在运行时选择算法;但 Strategy 聚焦于单一对象的上下文选择,而双重分派关注多个对象间的交互。
  • Visitor(访问者):二者常配合使用。Visitor 模式本身也依赖双重分派机制来将操作封装到元素对象之外,与本文示例中「按双方类型选择碰撞行为」的思想一脉相承。

延伸阅读

如需进一步探索,可以在仓库中对比阅读以下相关模块:

  • visitor:访问者模式,其中 accept / visit 的双向调用即经典的双重分派结构;
  • strategy:策略模式,运行时算法选择;
  • double-checked-locking 与 double-dispatch 同属行为/并发类模式库,可对比不同「双」字头模式的设计取舍。

结语

双重分派是 Java 开发者手头被低估的多态工具:当交互行为由两个对象的运行时类型共同决定时,与其堆砌 instanceof 分支,不如像本仓库 double-dispatch 模块那样,将入口方法做第一次分派、再用「以自身类型为参数的反向调用」完成第二次分派,从而在保持开闭原则的同时获得清晰、可测试的组合式行为。理解并掌握这一模式,对实现访问者、事件系统与仿真交互逻辑都有直接的实践价值。

  • 示例工程
  • 教程

【免费下载链接】java-design-patterns

Design patterns implemented in Java

项目地址: https://gitcode.com/GitHub_Trending/ja/java-design-patterns
点击查看 免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值