访问者模式(Visitor):当数据结构遇到行为变更

访问者模式,说实话,是我早期学习设计模式时觉得最绕的一个。它不像单例、工厂那样直观,甚至第一次看代码时我有点懵——为什么要搞这么复杂?直到我在一个大型报表系统中遇到了真正的痛点,才明白这个模式的精妙之处。

今天我们就来拆解它。我会从结构讲起,再到核心机制——双分派,最后看看Spring框架中是怎么用的。

访问者模式的结构

先看一个经典场景:你有一套对象结构,比如各种类型的员工(经理、程序员、设计师)。现在你想给这些对象增加一些操作,比如计算薪资、生成报表、导出数据。你当然可以在每个类里加方法,但这样每加一个新操作,就得改所有类。

访问者模式的做法是:把操作从对象结构中抽离出来。操作变成“访问者”,对象结构变成“被访问者”。

核心角色就四个:

  • Visitor(访问者接口):定义对每种元素类型的访问方法,比如 visit(Manager)visit(Programmer)
  • ConcreteVisitor(具体访问者):实现具体的操作逻辑,比如 SalaryCalculatorReportGenerator
  • Element(元素接口):定义一个 accept(Visitor) 方法,接收访问者
  • ConcreteElement(具体元素):实现 accept 方法,调用访问者对应的 visit 方法

我画了一张结构图,帮你理清关系:

<<interface>> Visitor +visit(ConcreteElementA) ConcreteVisitor1 实现具体操作逻辑 ConcreteVisitor2 实现另一种操作逻辑 <<interface>> Element +accept(Visitor) ConcreteElementA accept(v) { v.visit(this) } ConcreteElementB accept(v) { v.visit(this) } accept(visitor) ObjectStructure(对象结构) 持有元素集合,遍历并调用 accept

你看,访问者和元素之间通过 accept 方法建立联系。元素把自己的类型通过 this 传给访问者,访问者就知道该执行哪个 visit 方法了。

双分派机制:为什么叫“双”分派?

这是访问者模式最核心、也最容易让人困惑的地方。我当年理解它花了不少时间。

先说说普通的方法调用。在Java、C#这类语言里,方法调用是“单分派”的:运行时只根据调用者的实际类型来分派,参数类型在编译时就定死了。

举个例子:

// 单分派的问题
class Animal { }
class Dog extends Animal { }

void feed(Animal a) { System.out.println("喂动物"); }
void feed(Dog d) { System.out.println("喂狗"); }

Animal a = new Dog();
feed(a);  // 输出:喂动物(不是喂狗!)

看到了吗?虽然 a 实际是 Dog,但编译器只看声明类型 Animal,所以调用了 feed(Animal)。这就是单分派的局限。

访问者模式用了一个技巧:两次分派

  1. 第一次分派:调用 element.accept(visitor)。根据 element 的实际类型,找到对应的 accept 方法。
  2. 第二次分派:在 accept 方法内部,调用 visitor.visit(this)。这里的 this 是具体元素类型,所以 visit 方法能精确匹配。

我画个流程图,你一看就明白:

客户端代码 第1次分派 element.accept(visitor) 第2次分派 visitor.visit(this) 为什么需要两次? 第一次:根据元素实际类型,找到正确的 accept 方法 第二次:在 accept 内部,this 是具体类型,visit 方法精确匹配 效果:实现了“根据两个对象的实际类型”来调用方法

核心理解:双分派就是让方法调用同时依赖于“访问者的类型”和“元素的类型”。没有访问者模式,你做不到这一点。

代码示例:员工薪资计算

光说不练假把式。我们写个实际例子。

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

// 具体元素:经理
public class Manager implements Employee {
    private double baseSalary = 30000;
    private double bonus = 10000;
    
    public void accept(Visitor visitor) {
        visitor.visit(this);  // 关键:this 是 Manager 类型
    }
    
    public double getBaseSalary() { return baseSalary; }
    public double getBonus() { return bonus; }
}

// 具体元素:程序员
public class Programmer implements Employee {
    private double hourlyRate = 200;
    private int hoursWorked = 160;
    
    public void accept(Visitor visitor) {
        visitor.visit(this);  // 关键:this 是 Programmer 类型
    }
    
    public double getHourlyRate() { return hourlyRate; }
    public int getHoursWorked() { return hoursWorked; }
}

// 访问者接口
public interface Visitor {
    void visit(Manager manager);
    void visit(Programmer programmer);
}

// 具体访问者:薪资计算器
public class SalaryCalculator implements Visitor {
    public void visit(Manager manager) {
        double total = manager.getBaseSalary() + manager.getBonus();
        System.out.println("经理薪资:" + total);
    }
    
    public void visit(Programmer programmer) {
        double total = programmer.getHourlyRate() * programmer.getHoursWorked();
        System.out.println("程序员薪资:" + total);
    }
}

// 使用
public class Main {
    public static void main(String[] args) {
        Employee[] employees = {new Manager(), new Programmer()};
        Visitor calculator = new SalaryCalculator();
        
        for (Employee e : employees) {
            e.accept(calculator);  // 双分派发生在这里
        }
    }
}

运行结果:

经理薪资:40000.0
程序员薪资:32000.0

我的经验:如果你在写 if (employee instanceof Manager) 这样的代码,说明你正在手动模拟双分派。访问者模式就是帮你干掉这些 if-else 的。

访问者模式在 Spring 中的应用:BeanDefinitionVisitor

Spring 框架里有一个 BeanDefinitionVisitor 类,它就是访问者模式的典型应用。不过说实话,这个类在 Spring 3.x 之后用得少了,但它的设计思路非常值得学习。

它的作用是:遍历 BeanDefinition 的属性,并对属性值进行替换或处理。比如,把占位符 ${...} 替换成实际配置值。

核心代码大致是这样的:

// Spring 中的简化版本
public class BeanDefinitionVisitor {
    
    // 访问 BeanDefinition
    public void visitBeanDefinition(BeanDefinition beanDefinition) {
        // 处理构造参数
        visitConstructorArguments(beanDefinition.getConstructorArgumentValues());
        // 处理属性值
        visitPropertyValues(beanDefinition.getPropertyValues());
    }
    
    // 访问构造参数
    private void visitConstructorArguments(ConstructorArgumentValues args) {
        for (ConstructorArgumentValues.ValueHolder holder : args.getIndexedArgumentValues().values()) {
            Object value = holder.getValue();
            if (value instanceof TypedStringValue) {
                // 处理字符串值,替换占位符
                String resolved = resolveStringValue(((TypedStringValue) value).getValue());
                ((TypedStringValue) value).setValue(resolved);
            } else if (value instanceof RuntimeBeanReference) {
                // 处理 Bean 引用
                String resolved = resolveStringValue(((RuntimeBeanReference) value).getBeanName());
                ((RuntimeBeanReference) value).setBeanName(resolved);
            }
        }
    }
    
    // 访问属性值(类似逻辑)
    private void visitPropertyValues(MutablePropertyValues pvs) {
        // ... 遍历并处理每个属性值
    }
    
    // 占位符解析(子类可覆盖)
    protected String resolveStringValue(String strVal) {
        // 默认实现:直接返回
        return strVal;
    }
}

你看,BeanDefinitionVisitor 就像一个访问者,它“访问”了 BeanDefinition 内部的各个属性节点。每个节点(构造参数、属性值)都接受访问者的处理。

我曾经踩过的坑:有一次我自定义了一个 BeanDefinitionVisitor 子类,想对某些特殊属性做额外处理。结果忘了调用父类的 visitBeanDefinition 方法,导致占位符没被解析,Bean 创建时直接报错。记住:覆盖访问者方法时,一定要调用 super 方法,除非你确定要完全替换逻辑。

什么时候用访问者模式?

我总结了几条经验:

适用场景 不适用场景
对象结构稳定,但操作经常变化 对象结构经常变化(新增元素类型)
需要对对象结构做多种不相关的操作 操作很少,且不会扩展
想避免操作代码污染元素类 元素类很少,且操作简单
元素类型数量有限且已知 元素类型数量不确定或会频繁增加

一句话总结:访问者模式把“数据”和“操作”解耦。数据稳定,操作多变时,它就是你的好帮手。但如果你经常要新增元素类型,那还是别用访问者了——每加一个元素,所有访问者都得改,那叫一个酸爽。

好了,关于访问者模式就聊到这儿。记住双分派这个核心机制,下次看到 acceptvisit 成对出现时,你就知道它们在搞什么名堂了。


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