访问者模式中的双分派是什么?
简化版
双分派是指一次操作的最终执行,取决于两个对象的运行时类型:访问者类型和元素类型。访问者模式通过 element.accept(visitor) 和 visitor.visitXxx(this) 两次分派,让不同访问者对不同元素执行不同逻辑。
详细版
普通多态通常是单分派:调用哪个方法主要取决于接收者对象的运行时类型。
访问者模式中有两个变化维度:
- 访问者类型不同:统计访问者、导出访问者、校验访问者。
- 元素类型不同:PDF 文件、Word 文件、图片文件。
调用过程:
element.accept(visitor):根据元素真实类型选择具体accept。visitor.visitPdfFile(this):根据访问者真实类型选择具体访问者实现。
最终执行哪个逻辑,由“具体元素类型 + 具体访问者类型”共同决定,这就是双分派的思想。
完整版教学
一、先理解单分派
在 Java 这类语言中,普通方法调用通常是单分派。比如:
Animal animal = new Dog();
animal.speak();
最终执行 Dog.speak(),取决于 animal 运行时实际是 Dog。这就是根据一个接收者对象的真实类型来分派方法。
但如果一个操作同时依赖两个对象的真实类型,单分派就不够直接。
二、访问者模式为什么需要双分派
访问者模式的问题是:不同访问者要处理不同元素。
例如:
- 压缩访问者处理 PDF。
- 压缩访问者处理 Word。
- 提取文本访问者处理 PDF。
- 提取文本访问者处理 Word。
最终逻辑不是只由元素决定,也不是只由访问者决定,而是由两者共同决定。
三、两次分派如何发生
第一次分派:
element.accept(visitor);
如果 element 实际是 PdfFile,就进入 PdfFile 的 accept。
第二次分派:
visitor.visitPdfFile(this);
如果 visitor 实际是 ExtractTextVisitor,就执行 ExtractTextVisitor 对 PDF 的处理逻辑。
于是最终方法由元素真实类型和访问者真实类型共同确定。
四、常见误区与工程判断
面试中不要把双分派讲成“调用了两次方法”这么简单。重点是两阶段分派分别解决不同维度:第一次按元素对象的运行时类型进入具体 accept;第二阶段由具体元素中 this 的静态类型选中 visit 重载,再按访问者对象的运行时类型执行实现。
如果语言支持多分派,访问者模式的必要性会下降。但在 Java 这类主流面向对象语言中,访问者模式是一种模拟双分派的经典方式。
工程中理解双分派能帮助你看懂编译器 AST 访问、规则引擎节点处理、文件资源操作等代码。
五、为什么双分派是访问者的关键
单分派语言里,方法调用通常只根据接收者的运行时类型决定,例如 employee.accept(visitor) 会根据 employee 的真实类型进入 Employee.accept。但 visitor.visit(x) 如果参数是父类引用,重载选择往往在编译期就确定了。访问者模式通过 accept 多走一步,让具体元素在自己的方法里把 this 以具体类型传给访问者,从而实现“元素真实类型 + 访问者真实类型”共同决定行为。
这就是双分派的价值:第一次分派确定具体元素,第二阶段共同确定具体元素对应的 visit 重载及具体访问者实现。没有这一步,访问者很容易退化成一堆 if (obj instanceof Employee) 判断,模式的结构意义就丢了。
面试时可以补一句工程理解:双分派不是为了炫技,而是为了把“类型选择”交给多态机制。代码看起来多绕了一层,但换来的是更明确的扩展点和更少的散落类型判断。
六、用工程约束检验答案
Java 普通虚方法调用只按接收者运行时类型分派;重载参数在编译期选择。访问者通过 element.accept(v) 先按元素运行时类型进入具体 accept,再由 v.visit(this) 选择与具体元素匹配的重载,因此常称“两次分派”。
| 检查项 | 核心判断 | 工程含义 |
|---|---|---|
| 第 1 次分派 | element.accept(visitor) | 由元素对象运行时类型决定 |
| 第 2 次分派 | visitor.visit(this) | accept 内 this 的具体静态类型选重载,Visitor 实现再动态分派 |
| 缺少 accept | visitor.visit(element) | 若变量类型是 Element,只能命中 visit(Element) |
把关键关系压缩成一条可复述的路径:
Element e = new Employee();
e.accept(visitor); // Employee.accept
visitor.visit(this); // visit(Employee)
最终:ConcreteVisitor.visit(Employee)
Java 并不原生按两个运行时参数做多分派;访问者是用两次单分派协作得到相同的业务效果。
落地前可以再按下面 3 步复核:
- 先说明“第 1 次分派”的核心机制:element.accept(visitor);再交代边界:由元素对象运行时类型决定。
- 接着分析“第 2 次分派”:visitor.visit(this);不能遗漏对应代价或结果:accept 内 this 的具体静态类型选重载,Visitor 实现再动态分派。
- 最后用“缺少 accept”检查方案:visitor.visit(element);验收时确认若变量类型是 Element,只能命中 visit(Element)。
这三项构成完整判断链:先讲清第 1 次分派,再说明第 2 次分派,最后用缺少 accept检验实现是否越界。
面试中若能给出违反“若变量类型是 Element,只能命中 visit(Element)”的反例,再说明修正办法,答案就从模式定义落到了可验证的工程决策。
七、常见误区与追问
- 误区:只看到“第 1 次分派”就认为方案成立。 必须同时说明核心机制“element.accept(visitor)”和工程边界“由元素对象运行时类型决定”。
- 误区:把“第 2 次分派”当成无条件结论。 只有在“visitor.visit(this)”成立时,才能据此讨论“accept 内 this 的具体静态类型选重载,Visitor 实现再动态分派”。
- 追问:双分派是否等于 Java 原生多分派? 不等于,Java 的重载仍由编译期参数类型决定。
- 追问:第一次分派看哪个对象? 看接收 accept 调用的元素对象运行时类型。
- 追问:第二次为什么能选中 Employee 重载? 因为代码位于 Employee.accept 中,此处 this 的静态类型就是 Employee。
- 追问:只写 visit(Element) 可以吗? 可以运行,但会失去针对不同具体元素的类型化操作。
- 追问:反射或 instanceof 能替代吗? 能实现类型判断,却把分派规则集中成条件链,扩展和类型检查通常更差。
八、加强记忆
记忆时抓住这条主线:单分派看一个对象运行时类型;双分派让操作由两个对象类型共同决定;访问者模式通过 accept 和 visit 实现双分派;它避免大量 instanceof 类型判断。面试回答先给出模式意图,再用调用链或数据流说明角色协作,最后主动交代适用边界与工程代价。