Skip to content

访问者模式(Visitor Pattern) ​

一句话记忆口诀:访问者将数据结构与数据操作分离,通过双分派实现运行期方法选择,编译器AST遍历是最经典的例子。

1. 🏠 生活类比 ​

税务审计:公司的各个部门(研发部、销售部、财务部)结构稳定,但审计方式经常变化(所得税审计、增值税审计、社保审计)。新增一种审计方式时,不需要修改部门结构,只需要新增一个审计员。

快递员送快递:小区的住户结构稳定(A栋、B栋、C栋),但配送方式可以变化(普通快递、生鲜冷链、大件物流)。新增一种配送方式不影响住户结构。

2. 💩 烂代码:操作放在元素类中导致职责膨胀 ​

java
// ❌ 反例:每新增一种操作,所有元素类都要修改
public class Circle {
    private double radius;

    public double area() { return Math.PI * radius * radius; }
    public String toJson() { return "{\"type\":\"circle\",\"radius\":" + radius + "}"; }
    public String toXml() { return "<circle radius=\"" + radius + "\"/>"; }
    public void render(Graphics g) { g.drawCircle(radius); }
    public void exportToSvg() { /* ... */ }
    // 新增"导出 PDF"?Circle、Rectangle、Triangle 全部要改!
    // 新增"计算周长"?所有形状类都要改!
}

public class Rectangle {
    private double width, height;

    public double area() { return width * height; }
    public String toJson() { return "{\"type\":\"rect\",\"w\":" + width + "}"; }
    public String toXml() { return "<rect width=\"" + width + "\"/>"; }
    public void render(Graphics g) { g.drawRect(width, height); }
    // 又是重复的操作方法...
}

问题根因:数据结构(形状)和数据操作(面积计算、JSON导出、渲染)耦合在一起,新增操作需要修改所有元素类,违反开闭原则。

3. ✨ 访问者模式方案 ​

将操作抽取到独立的 Visitor 类中,元素类只提供 accept() 方法:

4. 💻 完整代码实现 ​

下面的示例由多个 public 顶层类型组成,每个类型需放入同名 .java 文件、位于同一包中(Java 不允许在同一个普通源文件中声明多个 public 顶层类或接口)。示例聚焦访问者模式的职责分离,JSON 拼接与三角形边长校验均为教学简化,生产环境应使用成熟的 JSON 库并校验输入。

java
import java.util.*;

// ===== 访问者接口 =====
public interface ShapeVisitor {
    void visit(Circle circle);
    void visit(Rectangle rectangle);
    void visit(Triangle triangle);
}

// ===== 元素接口 =====
public interface Shape {
    void accept(ShapeVisitor visitor); // 双分派的关键
}

// ===== 具体元素 =====
public class Circle implements Shape {
    private final double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    public double getRadius() { return radius; }

    @Override
    public void accept(ShapeVisitor visitor) {
        // 编译期根据 this 的静态类型选择 visit(Circle);
        // 运行期根据 visitor 的实际类型分派到具体访问者实现。
        visitor.visit(this);
    }
}

public class Rectangle implements Shape {
    private final double width;
    private final double height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    public double getWidth() { return width; }
    public double getHeight() { return height; }

    @Override
    public void accept(ShapeVisitor visitor) {
        visitor.visit(this);
    }
}

public class Triangle implements Shape {
    private final double a, b, c; // 三条边

    public Triangle(double a, double b, double c) {
        this.a = a;
        this.b = b;
        this.c = c;
    }

    public double getA() { return a; }
    public double getB() { return b; }
    public double getC() { return c; }

    @Override
    public void accept(ShapeVisitor visitor) {
        visitor.visit(this);
    }
}

// ===== 具体访问者1:计算面积 =====
public class AreaCalculator implements ShapeVisitor {
    private double totalArea = 0;

    @Override
    public void visit(Circle circle) {
        totalArea += Math.PI * circle.getRadius() * circle.getRadius();
    }

    @Override
    public void visit(Rectangle rectangle) {
        totalArea += rectangle.getWidth() * rectangle.getHeight();
    }

    @Override
    public void visit(Triangle triangle) {
        // 海伦公式
        double s = (triangle.getA() + triangle.getB() + triangle.getC()) / 2;
        totalArea += Math.sqrt(s * (s - triangle.getA()) * (s - triangle.getB()) * (s - triangle.getC()));
    }

    public double getTotalArea() { return totalArea; }
}

// ===== 具体访问者2:导出 JSON =====
public class JsonExporter implements ShapeVisitor {
    private final List<String> jsonList = new ArrayList<>();

    @Override
    public void visit(Circle circle) {
        jsonList.add("{\"type\":\"circle\",\"radius\":" + circle.getRadius() + "}");
    }

    @Override
    public void visit(Rectangle rectangle) {
        jsonList.add("{\"type\":\"rectangle\",\"width\":" + rectangle.getWidth()
                + ",\"height\":" + rectangle.getHeight() + "}");
    }

    @Override
    public void visit(Triangle triangle) {
        jsonList.add("{\"type\":\"triangle\",\"a\":" + triangle.getA()
                + ",\"b\":" + triangle.getB() + ",\"c\":" + triangle.getC() + "}");
    }

    public String export() {
        return "[" + String.join(",", jsonList) + "]";
    }
}

// ===== 具体访问者3:绘制(模拟)=====
public class Renderer implements ShapeVisitor {
    @Override
    public void visit(Circle circle) {
        System.out.println("绘制圆形,半径=" + circle.getRadius());
    }

    @Override
    public void visit(Rectangle rectangle) {
        System.out.println("绘制矩形,宽=" + rectangle.getWidth() + ",高=" + rectangle.getHeight());
    }

    @Override
    public void visit(Triangle triangle) {
        System.out.println("绘制三角形,边长=" + triangle.getA() + "," + triangle.getB() + "," + triangle.getC());
    }
}

// ===== 使用示例 =====
public class Main {
    public static void main(String[] args) {
        List<Shape> shapes = List.of(
            new Circle(5.0),
            new Rectangle(3.0, 4.0),
            new Triangle(3.0, 4.0, 5.0)
        );

        // 计算总面积
        AreaCalculator calc = new AreaCalculator();
        shapes.forEach(s -> s.accept(calc));
        System.out.printf("总面积: %.2f%n", calc.getTotalArea());

        // 导出 JSON(新增操作,不修改任何 Shape 类!)
        JsonExporter json = new JsonExporter();
        shapes.forEach(s -> s.accept(json));
        System.out.println("JSON: " + json.export());

        // 绘制(新增操作,不修改任何 Shape 类!)
        Renderer renderer = new Renderer();
        shapes.forEach(s -> s.accept(renderer));
    }
}

5. 🔧 框架应用 ​

框架/类说明
编译器 AST 遍历对语法树节点执行不同操作(类型检查、代码生成)
Spring BeanDefinitionVisitor访问并处理 BeanDefinition 中的属性值
java.nio.file.FileVisitor遍历文件树时执行不同操作(visitFile、visitDirectory)
com.sun.source.util.TreeScannerJDK 编译器 API 内置的 AST 访问者,遍历语法树节点
Jackson JsonSerializer对不同类型执行不同的序列化逻辑

5.1 NIO FileVisitor 示例 ​

java
// JDK 内置的访问者模式
Path start = Paths.get("/tmp");
Files.walkFileTree(start, new SimpleFileVisitor<Path>() {
    @Override
    public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
        System.out.println("文件: " + file);
        return FileVisitResult.CONTINUE;
    }

    @Override
    public FileVisitResult visitFileFailed(Path file, IOException exc) {
        System.err.println("访问失败: " + file);
        return FileVisitResult.CONTINUE;
    }
});

FileVisitor 是访问者式接口,但它比经典 GoF 访问者多一层职责。FileVisitResult 不只是访问结果,还允许调用方通过 CONTINUE、SKIP_SUBTREE、SKIP_SIBLINGS、TERMINATE 控制后续遍历行为。因此 FileVisitor 同时承担遍历控制协议的职责,并非纯粹的双分派访问者。

6. ⚠️ 适用场景 ​

适合:

  • 数据结构稳定(元素类型不常变)
  • 需要对数据结构执行多种不同且不相关的操作
  • 操作经常变化,但元素结构很少变化
  • 典型场景:编译器、报表生成、文档导出

不适合:

  • 元素类型经常新增(每新增一种元素,所有 Visitor 都要改)
  • 操作很少,只有 1-2 种 → 直接放在元素类中更简单

6.1 双分派机制 ​

java
// 第一次动态分派:通过 Shape 引用调用 accept(visitor),运行期选择具体元素类的 accept() 实现
shape.accept(visitor);

// 编译期重载解析:accept() 内编译器根据 this 的静态类型选择对应的 visit 重载
// Circle.accept → visitor.visit(this),编译期解析为 visit(Circle)
// Rectangle.accept → visitor.visit(this),编译期解析为 visit(Rectangle)

// 第二次动态分派:运行期根据 visitor 的实际类型,分派到编译期已确定的 visit 重载的具体实现

7. 🔍 访问者 vs 策略 vs 命令 ​

对比维度访问者策略命令
目的对多种元素执行多种操作算法可替换请求参数化
元素类型多种(双分派)单一单一
操作类型多种(每种一个 Visitor)多种(每种一个 Strategy)多种(每种一个 Command)
典型场景AST 遍历、文档导出支付方式、排序算法撤销/重做、任务队列

一句话记忆口诀:访问者将数据结构与操作分离,通过双分派实现运行期方法选择,数据结构稳定但操作多变时使用,编译器AST遍历是最经典的例子。

Released under the MIT License.