Java 17 的模式匹配,正在杀死访问者模式
访问者模式大概是 GoF 23 里最招人恨的一个。每次面试问到"讲一个你实际用过的设计模式",几乎没人主动提它。不是没用,是用起来太痛苦------为了一个"在不修改类的前提下添加操作"的目标,你得造一堆 Visitor 接口、实现类,还要在原有类里加 accept 方法。
Java 17 之后,这件事变简单了。sealed class 加上 switch 的模式匹配,让访问者模式的大部分使用场景有了更直接的写法。
访问者模式到底在解决什么问题
以一个经典的表达式求值为例。你有一个表达式树,包含数字、加法、乘法三种节点。需求是:给这个表达式树添加求值、打印、求导等不同操作。
如果用访问者模式,代码长这样:
java
interface Expr { void accept(ExprVisitor v); }
class Num implements Expr {
int value;
public void accept(ExprVisitor v) { v.visit(this); }
}
class Add implements Expr {
Expr left, right;
public void accept(ExprVisitor v) { v.visit(this); }
}
class Mul implements Expr {
Expr left, right;
public void accept(ExprVisitor v) { v.visit(this); }
}
interface ExprVisitor {
void visit(Num n);
void visit(Add a);
void visit(Mul m);
}
class EvalVisitor implements ExprVisitor {
int result;
public void visit(Num n) { result = n.value; }
public void visit(Add a) {
a.left.accept(this);
int l = result;
a.right.accept(this);
result = l + result;
}
public void visit(Mul m) { /* 类似 */ }
}
这段代码的问题在哪?
第一,Expr 接口被污染了。每个节点类型都要实现 accept 方法,而这个方法跟表达式本身的语义毫无关系。它之所以存在,完全是为了支持访问者模式的技术机制。
第二,新增节点类型时,要改 ExprVisitor 接口和所有实现类。这就是访问者模式最大的讽刺------它声称"在不修改类的前提下添加操作",代价是"添加新类时必须修改所有操作"。
第三,双分派的间接层让调试变得困难。一个求值操作在 Num、EvalVisitor、accept 之间跳来跳去,调用栈深不说,断点都不知道打在哪。
Java 17 的替代方案
Java 17 引入了两样东西:sealed class(密封类)和 pattern matching for switch(switch 模式匹配)。结合起来,表达式树可以这样写:
java
sealed interface Expr permits Num, Add, Mul {}
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
record Mul(Expr left, Expr right) implements Expr {}
没有 accept 方法,没有 Visitor 接口。Expr 就是一个干净的代数数据类型。
求值操作直接用 switch 表达式:
java
int eval(Expr e) {
return switch (e) {
case Num(int v) -> v;
case Add(Expr l, Expr r) -> eval(l) + eval(r);
case Mul(Expr l, Expr r) -> eval(l) * eval(r);
};
}
打印操作同理:
java
String print(Expr e) {
return switch (e) {
case Num(int v) -> String.valueOf(v);
case Add(Expr l, Expr r) -> "(" + print(l) + " + " + print(r) + ")";
case Mul(Expr l, Expr r) -> "(" + print(l) + " * " + print(r) + ")";
};
}
对比一下,访问者模式用了 40 多行代码搭基础设施,switch 模式匹配 5 行搞定。而且 eval 和 print 是两个独立函数,互不干扰,不需要共享一个 Visitor 接口。
新增操作和新增类型,哪个更痛
访问者模式的设计假设是:类层次结构稳定,但操作经常新增。如果这个假设成立,访问者模式确实有优势------新增操作只需要加一个 Visitor 实现类,不用改 Expr 接口。
但实际情况往往相反。类层次结构(领域模型)才是经常变的,业务需求让新增节点类型比新增操作更频繁。比如表达式树后来要支持变量、函数调用、三元运算符。在访问者模式里,每加一个节点类型,所有 Visitor 都要改。
switch 模式匹配的处理方式更诚实:新增节点类型时,编译器会检查 switch 是否覆盖了所有 case,没覆盖就报错。这强制你显式处理新类型,而不是靠运行时抛异常。Java 的 switch 在 sealed class 上默认是穷尽的,漏了 case 编译不通过。
新增操作时,switch 方案只需要写一个独立函数,不碰现有代码。访问者模式需要改 ExprVisitor 接口,然后所有实现类都要跟着加方法。
所以在"类结构稳定、操作常变"的理想场景下,两者差不多。在"类结构常变"的真实场景下,switch 模式匹配完胜。
访问者模式还没完全死
有一种场景,访问者模式仍然有优势:操作需要跨多个不相关的类层次结构共享状态。
比如编译器的符号表解析阶段,你需要遍历 AST 节点,同时维护一个符号表栈、作用域链、类型环境。这些信息需要在不同节点类型的 visit 方法之间传递。访问者模式可以把这些状态封装在 Visitor 对象里,天然支持跨节点共享。
用 switch 模式匹配也能做到,但你需要显式传递状态参数,或者把状态提升到外层类。这不是做不到,是代码组织方式不同。如果你需要维护复杂的状态机,访问者模式的封装性还是有价值的。
另一个例外是语言不支持 sealed class 和模式匹配。如果你还在用 Java 8,这篇文章对你没用。但如果你在用 Java 17 或更高版本,继续使用访问者模式就是在用更复杂的方式解决一个语言已经内置解决的问题。
一个务实的迁移策略
如果你现有的代码库里有大量访问者模式,不必一次性重写。我的建议是:新模块直接用 switch 模式匹配,老模块在修改时逐步替换。
替换的信号很明显:当你发现某个 Visitor 类里大部分 visit 方法都是"根据类型做不同的事",而没有复杂的状态共享时,这个 Visitor 就该被 switch 表达式替代了。
反过来说,如果一个 Visitor 维护了三个以上的共享状态字段,且 visit 方法之间有大量交互,保留访问者模式可能是更好的选择。
设计模式不是宗教,是工具。语言进化了,工具也该跟着换。
我在做一个用卡皮巴拉讲设计模式的微信小程序「爪爪代码冒险记」,23 个设计模式用漫画 + 答题的方式讲,目前正在开发中。如果你觉得这类内容有意思,搜一下「爪爪代码冒险记」,或者等我后面的文章。