设计模式-访问者模式

文章目录

  • 一、概述
    • [1.1 结构与角色](#1.1 结构与角色)
    • [1.2 适用场景](#1.2 适用场景)
  • 二、双分派机制
    • [2.1 单分派 vs 双分派](#2.1 单分派 vs 双分派)
    • [2.2 双分派的工作流程](#2.2 双分派的工作流程)
  • 三、实现方式
    • [3.1 基础实现](#3.1 基础实现)
    • [3.2 带状态累积的访问者](#3.2 带状态累积的访问者)
    • [3.3 拓展:新增访问者](#3.3 拓展:新增访问者)
  • 四、总结

一、概述

在软件开发中,经常会遇到这样的场景:一个对象结构 包含多种不同类型的元素,需要对它们执行一系列不相关的操作 。例如,编译器的 AST(抽象语法树)中包含了变量节点、赋值节点、表达式节点等多种节点类型,需要对它们执行类型检查、代码生成、格式化打印等多种操作;电商系统中包含普通商品、电子商品、食品等多种商品类型,需要对它们执行计算运费、计算税费、生成报表等操作。如果把这些操作全部写在元素类中,每新增一种操作就要修改所有元素类,严重违反开闭原则
修改
修改
修改
修改
修改
修改
修改
修改
修改
类型检查
变量节点类
赋值节点类
表达式节点类
代码生成
格式化打印
每新增一种操作就要修改所有元素类

访问者模式(Visitor Pattern)正是为了解决这个问题而诞生的------它将作用于某种数据结构中各元素的操作分离出来,封装成独立的访问者类,使得新增操作时无需修改元素类本身。访问者模式的核心是**双分派(Double Dispatch)**机制:具体调用哪个方法,由访问者的类型和元素的具体类型两个维度共同决定。

生活中的访问者模式例子:

  • 超市收银员:收银员(Visitor)扫描购物车中的不同商品(Element)------水果、蔬菜、日用品,每种商品的计价规则不同(水果按斤、蔬菜按把、日用品按件),收银员对每种商品执行相应的计价操作
  • 景区检票员:检票员(Visitor)面对不同的游客(Element)------成人票、学生票、老年票,对每种票执行不同的验票逻辑(验身份证、验学生证、免票等)
  • 汽车年检站:检测员(Visitor)对汽车的不同部件(Element)------发动机、刹车、灯光、尾气,执行不同的检测标准
  • 编译器后端:代码优化器、汇编代码生成器等(Visitor)遍历 AST 中的不同节点(Element),执行相应的处理

核心:表示一个作用于某对象结构中的各元素的操作,它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作

1.1 结构与角色

访问者模式包含以下角色:
持有
实现
实现
实现
实现
accept(visitor)
accept(visitor) → visitor.visit(this)
accept(visitor) → visitor.visit(this)
Client 客户端
ObjectStructure 对象结构
Element 抽象元素
ConcreteElementA 具体元素A
ConcreteElementB 具体元素B
Visitor 抽象访问者
ConcreteVisitor1 具体访问者1
ConcreteVisitor2 具体访问者2

  • Visitor(抽象访问者) :为对象结构中的每种具体元素类声明一个 visit() 方法(通过方法重载区分元素类型)
  • ConcreteVisitor(具体访问者) :实现抽象访问者声明的所有 visit() 方法,定义对每种元素的具体操作
  • Element(抽象元素) :定义一个 accept() 方法,接收一个访问者对象作为参数
  • ConcreteElement(具体元素) :实现 accept() 方法,在其中调用访问者的 visit(this) 方法,将自身传递给访问者(完成第一次分派)
  • ObjectStructure(对象结构):持有元素集合,提供遍历元素的方法让访问者逐一访问所有元素
  • Client(客户端):创建具体访问者对象和对象结构,调用对象结构的遍历方法,将访问者传入

1.2 适用场景

  • 一个对象结构包含多种类型的元素,需要对它们执行多种不同的、不相关的操作
  • 元素类很少变化,但经常需要在此结构上定义新的操作------数据结构稳定,算法易变
  • 需要对对象结构中的元素进行很多不同且不相关的操作,并且你不想让这些操作"污染"元素类的代码
  • 对象结构中元素的具体类型在编译期已知,而非运行时动态确定

二、双分派机制

访问者模式的精髓在于双分派(Double Dispatch),它是理解访问者模式的关键。单分派与双分派的区别如下:

2.1 单分派 vs 双分派

单分派(Single Dispatch) :调用哪个方法,只取决于一个因素------通常是方法的接收者(调用对象)。Java 的方法调用默认就是单分派(虚方法根据运行时类型分派),但方法的参数类型是静态绑定的(重载方法在编译期确定)------这正是问题的根源。

来看一个经典的反例:

java 复制代码
// 反例:Java 的单分派无法根据参数运行时类型选择正确的重载方法
abstract class Element {
    // 元素没有 accept 方法------直接让 Visitor 处理
}

class ConcreteElementA extends Element { }
class ConcreteElementB extends Element { }

class Visitor {
    public void visit(ConcreteElementA e) { System.out.println("处理 A"); }
    public void visit(ConcreteElementB e) { System.out.println("处理 B"); }
}

// 客户端
Element element = new ConcreteElementA();  // 运行时类型是 A
Visitor visitor = new Visitor();
// visitor.visit(element);  // ❌ 编译错误!visit(Element) 方法不存在
// Java 重载方法在编译期根据参数声明类型(Element)决定调用哪个,
// 而 Element 类型没有匹配的 visit 方法

问题在于 Java 的重载方法是编译期绑定 的------参数类型在编译时就确定了。如果传入 Element 类型(父类),编译器找不到对应的 visit(Element) 方法,直接报错。即便定义了 visit(Element),运行时也不会根据实际类型 ConcreteElementA 去选择 visit(ConcreteElementA)

双分派(Double Dispatch) :调用哪个方法,取决于两个因素------既取决于访问者的具体类型,又取决于元素的具体类型。访问者模式通过两次单分派来实现双分派:
根据 element 的运行时类型选择对应的 accept() 实现
第二步:在 accept() 内部调用 visitor.visit(this)
根据 visitor 的运行时类型选择对应的 visit() 实现
第一步:client 调用 element.accept(visitor)
ConcreteElementA.accept(visitor)
this 是 ConcreteElementA 类型
ConcreteVisitor.visit(ConcreteElementA)
第一次分派:根据 Element 类型选择 accept()
第二次分派:根据 Visitor 类型选择 visit()

2.2 双分派的工作流程

下面是双分派的详细步骤演示:

java 复制代码
// 1. 客户端创建元素和访问者
Element elem = new ConcreteElementA();  // 运行时类型:A
Visitor vis = new ConcreteVisitorX();   // 运行时类型:X

// 2. 调用 accept------第一次分派(虚方法调用)
//    JVM 根据 elem 的运行时类型(ConcreteElementA)选择 accept(visitor) 的实现
elem.accept(vis);
// ↓ 进入 ConcreteElementA.accept(visitor)

// 3. accept 内部调用 visit(this)------第二次分派(虚方法调用)
//    this 的编译期类型是 ConcreteElementA,因此调用 visit(ConcreteElementA)
//    JVM 根据 vis 的运行时类型(ConcreteVisitorX)选择 visit(ConcreteElementA) 的实现
visitor.visit(this); // this 即 ConcreteElementA 实例
// ↓ 进入 ConcreteVisitorX.visit(ConcreteElementA e)

为什么需要双分派? 如果只用单分派(直接调用 visitor.visit(element)),Java 的重载方法在编译期就根据参数声明类型Element)决定了匹配的 visit 方法。而双分派将第一次分派封装在 element.accept() 内部,此时 this 的具体类型已确定,再调用 visitor.visit(this) 就能精确匹配到重载的 visit(ConcreteElementA) 方法。


三、实现方式

访问者模式的核心实现思路是:在元素类中定义 accept(visitor) 方法,在其中回调访问者的 visit(this) 方法,通过两次虚方法调用来实现双分派。新增访问者只需要新增一个实现类,无需修改任何元素类。

以"超市购物车结算"为例,购物车中包含书籍、水果、电子产品三种商品,收银员需要计算总价,促销员需要计算折扣后价格:
持有
实现
实现
实现
实现
实现
accept(visitor)
accept → visitor.visit(this)
accept → visitor.visit(this)
accept → visitor.visit(this)
客户端
Cart 购物车 ObjectStructure
Good 抽象商品
Book 书籍
Fruit 水果
Electronic 电子产品
Visitor 抽象访问者
Cashier 收银员
Promoter 促销员

3.1 基础实现

(1)抽象访问者------访问者接口

java 复制代码
/**
 * 抽象访问者:定义对每种具体元素的 visit 操作
 * 每种元素类型对应一个重载的 visit 方法
 */
public interface Visitor {

    /**
     * 访问书籍
     *
     * @param book 书籍对象
     */
    void visit(Book book);

    /**
     * 访问水果
     *
     * @param fruit 水果对象
     */
    void visit(Fruit fruit);

    /**
     * 访问电子产品
     *
     * @param electronic 电子产品对象
     */
    void visit(Electronic electronic);
}

(2)抽象元素------商品接口

java 复制代码
/**
 * 抽象元素:商品
 * 定义 accept() 方法,接收一个访问者
 */
public interface Good {

    /**
     * 接受访问者访问
     * 在实现中回调 visitor.visit(this),完成双分派
     *
     * @param visitor 访问者
     */
    void accept(Visitor visitor);

    /**
     * 获取商品名称
     *
     * @return 商品名称
     */
    String getName();

    /**
     * 获取商品原价
     *
     * @return 原价
     */
    double getPrice();
}

(3)具体元素------书籍

java 复制代码
/**
 * 具体元素:书籍
 * 实现 accept() 方法,回调访问者的 visit(Book) 方法
 */
public class Book implements Good {

    private final String name;
    private final double price;

    /** 书籍的 ISBN 编号(书籍特有的属性) */
    private final String isbn;

    public Book(String name, double price, String isbn) {
        this.name = name;
        this.price = price;
        this.isbn = isbn;
    }

    @Override
    public void accept(Visitor visitor) {
        // 第二次分派:根据 visitor 的运行时类型选择 visit(Book) 实现
        visitor.visit(this);
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public double getPrice() {
        return price;
    }

    public String getIsbn() {
        return isbn;
    }
}

(4)具体元素------水果

java 复制代码
/**
 * 具体元素:水果
 */
public class Fruit implements Good {

    private final String name;
    private final double price;

    /** 水果的重量(kg)(水果特有的属性) */
    private final double weight;

    public Fruit(String name, double price, double weight) {
        this.name = name;
        this.price = price;
        this.weight = weight;
    }

    @Override
    public void accept(Visitor visitor) {
        // 第二次分派:根据 visitor 的运行时类型选择 visit(Fruit) 实现
        visitor.visit(this);
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public double getPrice() {
        return price;
    }

    public double getWeight() {
        return weight;
    }
}

(5)具体元素------电子产品

java 复制代码
/**
 * 具体元素:电子产品
 */
public class Electronic implements Good {

    private final String name;
    private final double price;

    /** 电子产品品牌(电子产品特有的属性) */
    private final String brand;

    public Electronic(String name, double price, String brand) {
        this.name = name;
        this.price = price;
        this.brand = brand;
    }

    @Override
    public void accept(Visitor visitor) {
        // 第二次分派:根据 visitor 的运行时类型选择 visit(Electronic) 实现
        visitor.visit(this);
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public double getPrice() {
        return price;
    }

    public String getBrand() {
        return brand;
    }
}

(6)具体访问者------收银员(计算总价)

java 复制代码
/**
 * 具体访问者:收银员
 * 负责计算每种商品的实际售价
 * - 书籍:原价
 * - 水果:按重量计价(单价 × 重量)
 * - 电子产品:原价
 */
public class Cashier implements Visitor {

    /** 累计总价 */
    private double totalPrice = 0.0;

    @Override
    public void visit(Book book) {
        double price = book.getPrice();
        System.out.printf("书籍「%s」(ISBN: %s):售价 %.2f 元%n",
                book.getName(), book.getIsbn(), price);
        totalPrice += price;
    }

    @Override
    public void visit(Fruit fruit) {
        double price = fruit.getPrice() * fruit.getWeight();
        System.out.printf("水果「%s」(%.2f kg):售价 %.2f 元%n",
                fruit.getName(), fruit.getWeight(), price);
        totalPrice += price;
    }

    @Override
    public void visit(Electronic electronic) {
        double price = electronic.getPrice();
        System.out.printf("电子产品「%s」(品牌: %s):售价 %.2f 元%n",
                electronic.getName(), electronic.getBrand(), price);
        totalPrice += price;
    }

    /**
     * 获取结算总价
     *
     * @return 总价
     */
    public double getTotalPrice() {
        return totalPrice;
    }
}

(7)具体访问者------促销员(计算折扣价)

java 复制代码
/**
 * 具体访问者:促销员
 * 负责计算每种商品打折后的价格
 * - 书籍:打 9 折
 * - 水果:打 8 折
 * - 电子产品:打 95 折
 */
public class Promoter implements Visitor {

    /** 折扣后总价 */
    private double discountedTotal = 0.0;

    @Override
    public void visit(Book book) {
        double original = book.getPrice();
        double discounted = original * 0.9;
        System.out.printf("书籍「%s」:原价 %.2f → 折后 %.2f 元(9折)%n",
                book.getName(), original, discounted);
        discountedTotal += discounted;
    }

    @Override
    public void visit(Fruit fruit) {
        double original = fruit.getPrice() * fruit.getWeight();
        double discounted = original * 0.8;
        System.out.printf("水果「%s」:原价 %.2f → 折后 %.2f 元(8折)%n",
                fruit.getName(), original, discounted);
        discountedTotal += discounted;
    }

    @Override
    public void visit(Electronic electronic) {
        double original = electronic.getPrice();
        double discounted = original * 0.95;
        System.out.printf("电子产品「%s」:原价 %.2f → 折后 %.2f 元(95折)%n",
                electronic.getName(), original, discounted);
        discountedTotal += discounted;
    }

    /**
     * 获取折扣后总价
     *
     * @return 折扣后总价
     */
    public double getDiscountedTotal() {
        return discountedTotal;
    }
}

(8)对象结构------购物车

java 复制代码
import java.util.ArrayList;
import java.util.List;

/**
 * 对象结构:购物车
 * 持有商品集合,提供遍历方法让访问者逐一访问所有商品
 */
public class Cart {

    /** 商品列表 */
    private final List<Good> goods = new ArrayList<>();

    /**
     * 添加商品
     *
     * @param good 商品
     */
    public void addGood(Good good) {
        goods.add(good);
    }

    /**
     * 移除商品
     *
     * @param good 商品
     */
    public void removeGood(Good good) {
        goods.remove(good);
    }

    /**
     * 接受访问者------遍历所有商品,让访问者依次访问
     *
     * @param visitor 访问者
     */
    public void accept(Visitor visitor) {
        for (Good good : goods) {
            good.accept(visitor);
        }
    }
}

(9)客户端调用

java 复制代码
public class VisitorDemo {
    public static void main(String[] args) {
        // 创建购物车(对象结构)
        Cart cart = new Cart();
        cart.addGood(new Book("设计模式之禅", 79.00, "978-7-111-43072-8"));
        cart.addGood(new Fruit("苹果", 12.00, 2.5));
        cart.addGood(new Electronic("机械键盘", 399.00, "Filco"));
        cart.addGood(new Book("Effective Java", 119.00, "978-7-111-30250-4"));
        cart.addGood(new Fruit("榴莲", 58.00, 3.0));

        System.out.println("========== 收银员结算 ==========");
        Cashier cashier = new Cashier();
        cart.accept(cashier);
        System.out.printf(">>> 总价:%.2f 元%n%n", cashier.getTotalPrice());

        System.out.println("========== 促销员计算折扣 ==========");
        Promoter promoter = new Promoter();
        cart.accept(promoter);
        System.out.printf(">>> 折扣后总价:%.2f 元%n", promoter.getDiscountedTotal());
    }
}

运行结果:

复制代码
========== 收银员结算 ==========
书籍「设计模式之禅」(ISBN: 978-7-111-43072-8):售价 79.00 元
水果「苹果」(2.50 kg):售价 30.00 元
电子产品「机械键盘」(品牌: Filco):售价 399.00 元
书籍「Effective Java」(ISBN: 978-7-111-30250-4):售价 119.00 元
水果「榴莲」(3.00 kg):售价 174.00 元
>>> 总价:801.00 元

========== 促销员计算折扣 ==========
书籍「设计模式之禅」:原价 79.00 → 折后 71.10 元(9折)
水果「苹果」:原价 30.00 → 折后 24.00 元(8折)
电子产品「机械键盘」:原价 399.00 → 折后 379.05 元(95折)
书籍「Effective Java」:原价 119.00 → 折后 107.10 元(9折)
水果「榴莲」:原价 174.00 → 折后 139.20 元(8折)
>>> 折扣后总价:720.45 元

关键设计 :新增一种操作(如"库存盘点员")只需要新增一个 Visitor 实现类(如 StockChecker),无需修改任何 Good 子类。这正是访问者模式的核心优势------操作新增开放,元素修改关闭

3.2 带状态累积的访问者

访问者可以在遍历过程中累积状态,实现更复杂的统计功能。以下展示一个"商品统计员",统计购物车中各类商品的数量和总价:

java 复制代码
/**
 * 具体访问者:商品统计员
 * 累积统计各品类商品的数量与金额
 */
public class GoodsStatistician implements Visitor {

    private int bookCount = 0;
    private double bookTotal = 0.0;
    private int fruitCount = 0;
    private double fruitTotal = 0.0;
    private int electronicCount = 0;
    private double electronicTotal = 0.0;

    @Override
    public void visit(Book book) {
        bookCount++;
        bookTotal += book.getPrice();
    }

    @Override
    public void visit(Fruit fruit) {
        fruitCount++;
        fruitTotal += fruit.getPrice() * fruit.getWeight();
    }

    @Override
    public void visit(Electronic electronic) {
        electronicCount++;
        electronicTotal += electronic.getPrice();
    }

    /**
     * 打印统计报告
     */
    public void printReport() {
        System.out.println("========== 商品统计报告 ==========");
        System.out.printf("书籍类:%d 件,金额 %.2f 元%n", bookCount, bookTotal);
        System.out.printf("水果类:%d 件,金额 %.2f 元%n", fruitCount, fruitTotal);
        System.out.printf("电子类:%d 件,金额 %.2f 元%n", electronicCount, electronicTotal);
        int totalCount = bookCount + fruitCount + electronicCount;
        double totalAmount = bookTotal + fruitTotal + electronicTotal;
        System.out.printf("合计:%d 件,总金额 %.2f 元%n", totalCount, totalAmount);
    }
}

客户端调用:

java 复制代码
public class StatisticianDemo {
    public static void main(String[] args) {
        Cart cart = new Cart();
        cart.addGood(new Book("设计模式之禅", 79.00, "978-7-111-43072-8"));
        cart.addGood(new Fruit("苹果", 12.00, 2.5));
        cart.addGood(new Electronic("机械键盘", 399.00, "Filco"));
        cart.addGood(new Book("Effective Java", 119.00, "978-7-111-30250-4"));
        cart.addGood(new Fruit("榴莲", 58.00, 3.0));

        GoodsStatistician statistician = new GoodsStatistician();
        cart.accept(statistician);
        statistician.printReport();
        // 输出:
        // ========== 商品统计报告 ==========
        // 书籍类:2 件,金额 198.00 元
        // 水果类:2 件,金额 204.00 元
        // 电子类:1 件,金额 399.00 元
        // 合计:5 件,总金额 801.00 元
    }
}

累积状态的设计要点 :访问者可以在自身保存遍历过程中的累积数据。GoodsStatistician 将书籍、水果、电子产品的数量和金额分别累加,最后统一输出。这种方式比在元素类中添加统计逻辑更清晰------统计逻辑完全集中在访问者中,元素类保持纯净。

3.3 拓展:新增访问者

演示访问者模式的核心优势------新增操作时无需修改元素类。新增一个"库存盘点员",检查每件商品的库存信息:

java 复制代码
/**
 * 具体访问者:库存盘点员
 * 无需修改任何 Good 子类,直接新增 Visitor 实现即可
 */
public class StockChecker implements Visitor {

    @Override
    public void visit(Book book) {
        System.out.printf("[盘点] 书籍「%s」(ISBN: %s) ------ 库存充足%n",
                book.getName(), book.getIsbn());
    }

    @Override
    public void visit(Fruit fruit) {
        String status = fruit.getWeight() > 5 ? "需补货" : "库存充足";
        System.out.printf("[盘点] 水果「%s」(%.2f kg) ------ %s%n",
                fruit.getName(), fruit.getWeight(), status);
    }

    @Override
    public void visit(Electronic electronic) {
        System.out.printf("[盘点] 电子产品「%s」(品牌: %s) ------ 库存正常%n",
                electronic.getName(), electronic.getBrand());
    }
}

只需新增一个类,购物车的所有商品都自动支持了库存盘点功能------完全符合开闭原则


四、总结

访问者模式的核心思想是将作用于某种数据结构中各元素的操作分离出来,封装成独立的访问者类,使得新增操作时无需修改元素类本身。其关键是双分派机制 ------通过 accept(visitor) + visitor.visit(this) 两次虚方法调用,让方法的选择由访问者类型和元素类型共同决定。

优点:

  • 开闭原则:新增操作只需新增一个访问者类,无需修改任何元素类,操作扩展极其灵活
  • 单一职责:每个访问者只负责一种操作,元素类只负责数据结构,职责清晰分离
  • 集中管理:对同一结构的所有操作逻辑集中在访问者中,便于统一维护和复用
  • 累积状态:访问者可以在遍历过程中累积状态,实现复杂的聚合统计功能

缺点:

  • 元素类变更困难:新增或删除元素类型时,所有访问者都必须修改,增加维护成本
  • 破坏封装:访问者需要访问元素的内部细节,元素可能需要暴露一些原本私有的数据
  • 对象结构固定:元素类型必须在编译期确定,不支持运行时动态增减元素类型
  • 复杂度较高:双分派机制对初学者不直观,类之间的耦合关系比较复杂

访问者模式 vs 策略模式:

维度 访问者模式 策略模式
目标 对一组不同类型的元素执行不同操作 同一类型的对象切换不同算法
分派机制 双分派(accept + visit) 单分派(虚方法调用)
扩展方向 新增操作容易,新增元素困难 新增策略容易,客户端切换灵活
耦合关系 访问者与元素双向依赖 策略与上下文单向依赖

选择建议 :当对象结构中的元素类型基本固定,而需要频繁新增操作时,访问者模式是最佳选择(如编译器 AST 的各种遍历操作)。如果元素类型经常变化,则不建议使用访问者模式。如果只是同一类型对象的不同算法替换,使用策略模式更合适。

适用场景:

  • 对象结构中包含多种类型的元素,且元素类型相对稳定
  • 需要对该对象结构执行许多不同且不相关的操作
  • 希望将数据结构与操作逻辑分离,让它们可以独立变化
  • 编译器设计(AST 遍历)、报表生成、统计系统等"数据结构稳定 + 算法多变"的场景

参考博客:

访问者模式 | 菜鸟教程:https://www.runoob.com/design-pattern/visitor-pattern.html

相关推荐
咖啡八杯3 小时前
GoF设计模式——原型模式
java·后端·设计模式·原型模式
多加点辣也没关系3 小时前
设计模式-状态模式
设计模式·状态模式
多加点辣也没关系3 小时前
设计模式-备忘录模式
设计模式·备忘录模式
雪度娃娃3 小时前
行为型设计模式——中介者模式
microsoft·设计模式·中介者模式
多加点辣也没关系3 小时前
设计模式-中介者模式
设计模式·中介者模式
geovindu21 小时前
go: Read-Write Lock Pattern
开发语言·后端·设计模式·golang·读写锁模式
行走的陀螺仪1 天前
[特殊字符] JavaScript 设计模式完全指南:从入门到精通(含20种模式)
开发语言·javascript·设计模式
小陶来咯1 天前
AI Agent 设计模式:ReAct 深度解析
人工智能·react.js·设计模式
多加点辣也没关系1 天前
设计模式-责任链模式
设计模式·责任链模式