访问者模式:当代码结构遇上“不速之客”

访问者模式,这个名字听起来有点“入侵”的味道。没错,它确实像是一个外来者,闯进你稳定的对象结构里,干点“额外”的事。

我最早接触这个模式,是在做编译器前端的时候。那时候要遍历抽象语法树(AST),做类型检查、代码生成、优化……每个节点都要处理,但处理逻辑又各不相同。如果每个节点都自己实现这些功能,那代码就乱成一锅粥了。访问者模式,就是专门解决这类问题的。

核心思想:把操作从对象中抽离出来

你想想看,一个稳定的对象结构(比如 AST 节点),经常需要增加新的操作(比如打印、求值、类型检查)。如果每次加操作都去改节点类,那节点类会越来越臃肿,而且违反了“开闭原则”。

访问者模式的做法是:把操作封装到独立的“访问者”对象中。对象结构本身只提供一个“接待”方法(accept),让访问者进来执行操作。

核心角色:

  • Visitor(访问者接口):声明一系列 visit 方法,每个方法对应一种具体元素类型。
  • ConcreteVisitor(具体访问者):实现 visit 方法,定义对每种元素的具体操作。
  • Element(元素接口):声明 accept 方法,接收一个访问者对象。
  • ConcreteElement(具体元素):实现 accept 方法,通常就是调用 visitor.visit(this)。
  • ObjectStructure(对象结构):管理元素集合,提供遍历机制。

一个简单的例子:表达式求值与打印

假设我们有一个简单的算术表达式 AST,包含数字、加法、乘法三种节点。我们要实现两种操作:计算表达式的值,以及打印表达式。

先定义元素接口和具体元素:

// 元素接口
interface Expression {
    void accept(Visitor visitor);
}

// 数字节点
class NumberExpr implements Expression {
    int value;
    NumberExpr(int value) { this.value = value; }
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

// 加法节点
class AddExpr implements Expression {
    Expression left, right;
    AddExpr(Expression left, Expression right) {
        this.left = left; this.right = right;
    }
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

// 乘法节点
class MulExpr implements Expression {
    Expression left, right;
    MulExpr(Expression left, Expression right) {
        this.left = left; this.right = right;
    }
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

然后定义访问者接口和两个具体访问者:

// 访问者接口
interface Visitor {
    void visit(NumberExpr expr);
    void visit(AddExpr expr);
    void visit(MulExpr expr);
}

// 求值访问者
class EvalVisitor implements Visitor {
    int result;
    public void visit(NumberExpr expr) {
        result = expr.value;
    }
    public void visit(AddExpr expr) {
        expr.left.accept(this);
        int leftVal = result;
        expr.right.accept(this);
        int rightVal = result;
        result = leftVal + rightVal;
    }
    public void visit(MulExpr expr) {
        expr.left.accept(this);
        int leftVal = result;
        expr.right.accept(this);
        int rightVal = result;
        result = leftVal * rightVal;
    }
}

// 打印访问者
class PrintVisitor implements Visitor {
    StringBuilder sb = new StringBuilder();
    public void visit(NumberExpr expr) {
        sb.append(expr.value);
    }
    public void visit(AddExpr expr) {
        sb.append("(");
        expr.left.accept(this);
        sb.append(" + ");
        expr.right.accept(this);
        sb.append(")");
    }
    public void visit(MulExpr expr) {
        sb.append("(");
        expr.left.accept(this);
        sb.append(" * ");
        expr.right.accept(this);
        sb.append(")");
    }
}

客户端代码:

// 构建表达式: (1 + 2) * 3
Expression expr = new MulExpr(
    new AddExpr(new NumberExpr(1), new NumberExpr(2)),
    new NumberExpr(3)
);

EvalVisitor eval = new EvalVisitor();
expr.accept(eval);
System.out.println("结果: " + eval.result); // 输出 9

PrintVisitor print = new PrintVisitor();
expr.accept(print);
System.out.println("表达式: " + print.sb.toString()); // 输出 ((1 + 2) * 3)

个人经验: 我在实际项目中,经常用访问者模式来做“多态遍历”。比如一个 UI 组件树,每个组件都要支持“渲染”、“事件处理”、“序列化”等操作。用访问者模式,这些操作可以独立开发和测试,互不干扰。

编译器 AST 遍历:访问者模式的经典应用

编译器前端处理源代码时,会生成一棵抽象语法树。这棵树的结构是固定的(比如 IfStatement、WhileStatement、BinaryExpression 等节点),但后续的语义分析、优化、代码生成等阶段,都需要遍历这棵树并执行不同的操作。

访问者模式在这里简直是天作之合。每个编译阶段就是一个具体访问者:

  • 类型检查访问者:检查每个节点的类型是否匹配。
  • 常量折叠访问者:在编译期计算常量表达式。
  • 代码生成访问者:为每个节点生成目标代码。
  • 死代码消除访问者:标记并移除不可达代码。

我记得有一次,我们需要给一个已有编译器增加“安全审计”功能。如果不用访问者模式,就得修改所有 AST 节点类,加入审计逻辑。那工作量……想想都头疼。但因为我们一开始就用了访问者模式,只需要新增一个 SecurityAuditVisitor,然后遍历一遍 AST 就行了。前后只花了两天。

避坑指南: 我曾经在一个项目中,把访问者模式用“过”了。当时每个节点都有十几个访问者,而且访问者之间还有依赖关系。结果每次修改节点结构,都要同步更新所有访问者,维护成本极高。

我的建议是:访问者模式适合“操作频繁变化,但结构相对稳定”的场景。如果节点结构本身也在频繁变化,那访问者模式反而会成为负担。

双分派机制:访问者模式的核心

访问者模式之所以能工作,依赖的是“双分派”机制。什么意思呢?

普通的单分派,是根据接收者的实际类型来调用方法。比如 obj.method(),调用哪个 method 取决于 obj 的实际类型。

双分派则是在单分派的基础上,再根据参数的实际类型进行一次分派。在访问者模式中:

  1. 首先,元素调用 visitor.visit(this),这里的 this 是元素的实际类型(比如 NumberExpr)。
  2. 然后,JVM 根据 visitor 的实际类型和 this 的实际类型,找到最匹配的 visit 方法。

说白了,就是“两次动态绑定”。第一次绑定访问者,第二次绑定元素。这样就能实现“一个操作,多种行为”。

访问者模式的优缺点

优点 缺点
符合开闭原则:增加新操作容易,只需新增访问者 增加新元素困难:需要修改所有访问者接口和实现
将相关操作集中到一个访问者中,提高内聚性 访问者需要知道元素内部细节,破坏了封装
便于积累和复用:同一访问者可用于不同对象结构 代码量增加:每个元素都要实现 accept 方法
遍历逻辑与业务逻辑分离,职责清晰 双分派机制对新手不太友好,理解成本高

什么时候用访问者模式?

我个人总结了几条经验:

  • 对象结构稳定,但操作频繁变化:比如 AST、UI 组件树、文档对象模型。
  • 需要对一个对象结构执行多种不相关的操作:比如打印、导出、校验、统计。
  • 操作需要访问元素内部状态,但又不希望污染元素类:访问者可以访问元素的公开接口,但不需要修改元素本身。
  • 操作需要跨多个元素类型协作:比如类型检查需要同时知道变量声明和使用处的类型。

一句话总结: 访问者模式,就是把“操作”从“对象”中解放出来。它让对象结构保持稳定,让操作自由生长。但前提是——对象结构得真的稳定。

SVG 结构图:访问者模式的核心流程

访问者模式核心结构 Visitor(访问者接口) ConcreteVisitor1 ConcreteVisitor2 Element(元素接口) ConcreteElementA ConcreteElementB ObjectStructure 元素集合 遍历方法 accept(visitor) accept(visitor) 访问者通过 accept 方法进入元素内部,执行 visit 操作 对象结构负责管理元素集合,并提供统一的遍历入口 核心机制:双分派(Double Dispatch)

这张图展示了访问者模式的核心结构。左边是访问者体系,右边是元素体系,中间通过 accept 方法连接。对象结构在最右侧,负责管理元素集合。整个模式的关键,就是那个红色的虚线箭头——它代表了双分派的调用链。

我的习惯: 在设计访问者模式时,我会先确定“哪些操作是可能变化的”,然后为每个操作创建一个访问者接口。如果操作之间有公共逻辑,我会用抽象类或组合的方式复用代码。另外,我建议给每个访问者起一个“动词+Visitor”的名字,比如 TypeCheckVisitorCodeGenVisitor,这样一看就知道它负责什么。

访问者模式,说白了就是“把操作外包”。对象结构只负责“接待”,具体做什么,由访问者决定。这种分离,让代码的扩展性大大提升。但也要记住,它并不是万能的。如果你的对象结构本身就不稳定,那还是老老实实用策略模式或者模板方法吧。


公众号:蓝海资料掘金营,微信 deep3321