今天在V2社区冲浪的时候看到了一篇讨论访问者模式这个设计模式的帖子,发现自己还不了解这个模式于是便学习了一下;没想到这个设计模式的出现竟然和编译器的动态链接相关行为的局限有关…… 还蛮有意思的,开帖子记录一下。
背景问题
先贴一段代码
public class FruitTest {
public static void main(String[] args) {
Parent boy = new Boy();
Fruit apple = new Apple();
Orange orange = new Orange();
boy.eat(apple);
boy.eat(orange);
}
}
class Fruit {}
class Apple extends Fruit {}
class Orange extends Fruit {}
class Parent{
public void eat(Fruit fruit) {
System.out.println("parent.eat Fruit");
}
public void eat(Apple apple) {
System.out.println("parent.eat Apple");
}
public void eat(Orange orange) {
System.out.println("parent.eat Orange");
}
}
class Boy extends Parent {
@Override
public void eat(Apple apple) {
System.out.println("boy eat Apple");
}
@Override
public void eat(Fruit fruit) {
System.out.println("boy eat Fruit");
}
@Override
public void eat(Orange orange) {
System.out.println("boy eat Orange");
}
}
那么在不看答案的情况下,你觉得这段代码的输出是什么呢?
答案是输出为“一行“boy eat Fruit,一行“boy eat orange”
但是这是为什么呢? 为什么编译器看起来能够根据被调用的类的实际类型来选择方法,但是没能根据参数的实际类型来选择合适的方法呢?
这就引出了这个所谓的“局限”: 单分派机制与重载机制 (有时候也把他们叫做动态分派、静态分派)
单分派与方法重载
- 单分派是方法调用的一种分派机制,java、C++、PHP等多数语言都使用这种机制;指的是在运行时根据被调用对象的动态类型[1]来决定调用的具体实现。
- 方法重载则是编译时首先根据被调用对象的静态类型和传入参数的静态类型选择最匹配的方法。
返回到最开始boy和apple的例子:
- 在编译期,编译器会根据调用对象的静态类型和入参的静态类型来选择一个最匹配的方法,而apple变量的静态类型是Fruit,所以在编译器就决定了最后输出一定是"eat fruit"
- 在运行期,虽然boy的静态类型是Parent类型,但是JVM根据单分派原则需要根据boy变量的动态类型来决定选择哪一个方法的实现来执行[2],所以最后可以输出"bot eat XXX"
练练手
下面这个例子还掺杂了接口,会稍稍复杂一些,但是也更贴近日常会写的代码,来猜猜他的输出?
interface Element {
void accept(Visitor visitor);
}
class ConcreteElementA implements Element {
@Override
public void accept(Visitor visitor) {
visitor.visit(this); // 这里需要传递自己以匹配正确的方法
}
}
class ConcreteElementB implements Element {
@Override
public void accept(Visitor visitor) {
visitor.visit(this); // 同上
}
}
interface Visitor {
void visit(Element element); // 通用方法
}
class ConcreteVisitor implements Visitor {
@Override
public void visit(Element element) {
System.out.println("访问者处理通用的元素");
}
public void visit(ConcreteElementA element) {
System.out.println("访问者处理 A 类型的元素");
}
void visit(ConcreteElementB element) {
System.out.println("访问者处理 B 类型的元素");
}
}
public class ShapeTest {
public static void main(String[] args) {
ConcreteElementA element = new ConcreteElementA();
ConcreteVisitor visitor = new ConcreteVisitor();
element.accept(visitor); // 调用哪个 visit?
}
}
你看,这里visitor和element的静态类型都是实现类的类型,那么是不是编译期的方法重载就不会干扰我们了?
错~ 实际上这段代码的输出还是“访问者处理通用的元素”!
编译器在编译的时候发现ConcreteElementA类型只有一个accept(Visitor)的方法,方法内容是调用Visitor接口的visit方法,而visitor接口的visit方法只有一个接收Element的默认实现……那么任你如何分派,都不会去调用ConcreteVisitor实现类中的其他方法了……
这个异常是不是藏得很深了呢,但是我觉的日常我们应该也不会写出这么绕的代码……
这段代码其实是基于访问者模式做了一点小改动,下面我们来正式介绍这个模式
访问者模式
先提一句,访问者并不是常用的设计模式, 因为它不仅复杂, 应用范围也比较狭窄。
既然上面已经给出了访问者模式的错误实现,那么这里就先给出正确实现,之后再讨论这个设计模式的其他细节。
package top.tarvis.util2024;
interface Element {
void accept(Visitor visitor);
}
class ConcreteElementA implements Element {
@Override
public void accept(Visitor visitor) {
visitor.visit(this); // 这里需要传递自己以匹配正确的方法
}
}
class ConcreteElementB implements Element {
@Override
public void accept(Visitor visitor) {
visitor.visit(this); // 同上
}
}
interface Visitor {
void visit(Element element); // 通用方法
//下面两行是与上文中错误实现的唯一区别,这样可以帮助编译器根据入参的类型来调用不同的方法
void visit(ConcreteElementA element);
void visit(ConcreteElementB element);
}
class ConcreteVisitor implements Visitor {
@Override
public void visit(Element element) {
System.out.println("访问者处理通用的元素");
}
public void visit(ConcreteElementA element) {
System.out.println("访问者处理 A 类型的元素");
}
void visit(ConcreteElementB element) {
System.out.println("访问者处理 B 类型的元素");
}
}
public class ShapeTest {
public static void main(String[] args) {
ConcreteElementA element = new ConcreteElementA();
ConcreteVisitor visitor = new ConcreteVisitor();
element.accept(visitor); // 调用哪个 visit?
}
}
评论