跳到主要内容

访问者模式

定义

访问者模式在实际的开发中使用的非常少,因为它比较难以实现,并且应用该模式可能会导致代码的可读性变差,在没有必要时,不建议使用访问者模式。

访问者模式(Visitor pattern)的原始定义时:允许在运行时将一个或多个操作应用于一组对象,将操作与对象结构分离。

  • 一个是:运行时使用一组对象的一个或多个操作,比如,对不同类型的文件(.pdf、.xml、.properties) 进行扫描;
  • 另一个是:分离对象的操作和对象本身的结构,比如,扫描多个文件夹下的多个文件,对于文件来说,扫描是额外的业务操作,如果在每个文件对象上都加一个扫描操作,太过于冗余,而扫描操作具有统一性,非常适合访问者模式。

访问者模式主要解决的是数据与算法的耦合问题,尤其是在数据结构比较稳定,而算法多变的情况下,为了不污染数据本身,访问者会将多种算法独立归档,并在访问数据时根据数据类型自动切换到对应的算法,实现数据的自动响应机制,并确保算法的自由扩展。

模式原理

  • Visitor 接口:访问者接口,用于处理元素对象,参数也是元素接口
  • Element 接口:元素接口,包含 accept 接口,接受 Visitor 参数。目的是想让每一个元素都可以让访问者去访问到。
  • ConcreteElement 类:具体的元素角色,实现 Element 接口,并实现 accept 方法,该方法会调用访问者对象的 visit 方法。
  • ConcreteVisitor 类:具体的访问者角色,实现 Visitor 角色,并实现 visit 方法,该方法会处理元素对象。
  • Client:初始化各种数据元素类,并选择合适的访问者处理容器中的所有数据对象。
  • ObjectStructure:对象结构,管理元素集合,提供遍历它的方法

访问者模式类图

模式实现

场景描述

以超市购物为例,假设超市中的三类商品:水果、糖果、酒水进行售卖。我们可以忽略每种商品的计价方法,因为最终结账由收银员统一集中处理,在商品类添加计价方法是不合理的设计。

核心接口定义

1. 访问者接口(Visitor)

/**
* 访问者接口
* 根据入参的不同,调用对应的重载方法
*/
public interface Visitor {
void visit(Candy candy); // 糖果重载方法
void visit(Wine wine); // 酒水重载方法
void visit(Fruit fruit); // 水果重载方法
}

2. 接待者接口(Acceptable)

/**
* 接待者接口(抽象元素角色)
*/
public interface Acceptable {
// 接受所有的 Visitor 访问者的子类
void accept(Visitor visitor);
}

抽象基类

3. 抽象商品类(Product)

/**
* 抽象商品类
*/
public abstract class Product {
private String name; // 商品名
private LocalDate producedDate; // 生产日期
private double price; // 单品价格

public Product(String name, LocalDate producedDate, double price) {
this.name = name;
this.producedDate = producedDate;
this.price = price;
}

// getter 和 setter 方法
}

具体元素类

4. 糖果类(Candy)

/**
* 糖果类
*/
public class Candy extends Product implements Acceptable {
public Candy(String name, LocalDate producedDate, double price) {
super(name, producedDate, price);
}

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

5. 酒水类(Wine)

/**
* 酒水类
*/
public class Wine extends Product implements Acceptable {
public Wine(String name, LocalDate producedDate, double price) {
super(name, producedDate, price);
}

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

6. 水果类(Fruit)

/**
* 水果类
*/
public class Fruit extends Product implements Acceptable {
private double weight;

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

public double getWeight() {
return weight;
}

public void setWeight(double weight) {
this.weight = weight;
}

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

具体访问者类

7. 折扣计价访问者(DiscountVisitor)

import java.text.NumberFormat;
import java.time.LocalDate;

/**
* 折扣计价访问者类
*/
public class DiscountVisitor implements Visitor {
private LocalDate billDate;

public DiscountVisitor(LocalDate billDate) {
this.billDate = billDate;
System.out.println("结算日期:" + billDate);
}

@Override
public void visit(Candy candy) {
System.out.println("糖果:" + candy.getName());
// 糖果大于 180 天,禁止售卖,否则糖果一律九折
long days = billDate.toEpochDay() - candy.getProducedDate().toEpochDay();
if (days > 180) {
System.out.println("超过半年的糖果,请勿使用!");
} else {
double realPrice = candy.getPrice() * 0.9;
System.out.println("糖果打折后的价格为:" + NumberFormat.getCurrencyInstance().format(realPrice));
}
}

@Override
public void visit(Wine wine) {
System.out.println("酒类" + wine.getName() + "无折扣价格!");
System.out.println("原价售卖:" + NumberFormat.getCurrencyInstance().format(wine.getPrice()));
}

@Override
public void visit(Fruit fruit) {
System.out.println("水果:" + fruit.getName());
long days = billDate.toEpochDay() - fruit.getProducedDate().toEpochDay();
double rate = 0;
if (days > 7) {
System.out.println("超过 7 天的水果,请勿食用!");
} else if (days > 3) {
rate = 0.5;
} else {
rate = 1;
}
double realPrice = fruit.getPrice() * fruit.getWeight() * rate;
System.out.println("水果价格为:" + NumberFormat.getCurrencyInstance().format(realPrice));
}
}

客户端测试

8. 客户端类(Client)

import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import org.junit.Test;

public class Client {

@Test
public void test1() {
Candy candy = new Candy("德芙巧克力", LocalDate.of(2022, 5, 1), 10.0);
Visitor visitor = new DiscountVisitor(LocalDate.of(2022, 10, 5));
visitor.visit(candy);
}

/**
* 模拟结算三件商品(错误示例)
*/
@Test
public void test2() {
List<Product> products = Arrays.asList(
new Candy("金丝猴奶糖", LocalDate.of(2022, 10, 1), 10),
new Wine("郎酒", LocalDate.of(2022, 10, 1), 1000),
new Fruit("草莓", LocalDate.of(2022, 10, 8), 50, 2)
);

// 创建 Visitor
DiscountVisitor visitor = new DiscountVisitor(LocalDate.of(2022, 10, 5));

// 这种方式无法调用 accept 方法,因为 Product 没有实现 Acceptable 接口
for (Product product : products) {
// product.accept(visitor); // 编译错误
}
}

/**
* 模拟结算三件商品(正确示例)
*/
@Test
public void test3() {
List<Acceptable> products = Arrays.asList(
new Candy("金丝猴奶糖", LocalDate.of(2022, 10, 1), 10),
new Wine("郎酒", LocalDate.of(2022, 10, 1), 1000),
new Fruit("草莓", LocalDate.of(2022, 10, 8), 50, 2)
);

// 创建 Visitor
DiscountVisitor visitor = new DiscountVisitor(LocalDate.of(2022, 10, 5));

// 正确使用访问者模式
for (Acceptable product : products) {
product.accept(visitor);
}
}
}

运行结果示例

结算日期:2022-10-05
糖果:金丝猴奶糖
糖果打折后的价格为:¥9.00
酒类郎酒无折扣价格!
原价售卖:¥1,000.00
水果:草莓
水果价格为:¥100.00

模式分析

核心机制

  1. 双重分发:访问者模式使用了双重分发机制

    • 第一次分发:客户端调用 product.accept(visitor)
    • 第二次分发:accept 方法调用 visitor.visit(this)
  2. 类型安全:通过方法重载确保类型安全,每种商品类型都有对应的 visit 方法

  3. 开闭原则:新增访问者操作时,不需要修改现有的元素类

设计要点

  1. 元素类职责单一:元素类只负责数据存储和基本的 accept 方法
  2. 访问者类职责明确:每个访问者类负责特定的业务逻辑
  3. 接口隔离:通过 Acceptable 接口实现元素与访问者的解耦

总结

优点

  • 扩展性好:在不改变对象结构中元素的情况下,为对象结构中的元素添加新的功能。
  • 复用性好:通过访问者来定义整个对象结构通用的功能,从而提高复用性。
  • 分离无关行为:通过访问者来分离无关的行为,把相关的行为封装在一起,构成一个访问者,这样每一个访问者的功能都比较单一。
  • 类型安全:通过方法重载确保编译时类型检查。

缺点

  • 对象结构变化很困难:在访问者模式中,每增加一个新的元素类,都要在每一个具体访问者中增加相应的具体操作,这违背了开闭原则。
  • 违反了依赖倒置原则:访问者模式依赖具体类,而没有依赖抽象类。
  • 增加系统复杂度:引入了额外的接口和类,增加了系统的复杂度。
  • 破坏封装性:访问者需要访问元素的内部状态,可能破坏封装性。

使用场景

  • 当对象的数据结构相对稳定,而操作却经常变化的时候。比如,路由器本身的内部构造(数据结构)不会怎么变化,但是在不同的操作系统下的操作可能会经常变化,比如,发送数据、接受数据等。

  • 需要将数据结构与不常用的操作进行分离的时候。比如,扫描文件内容这个动作通常不是文件常用的操作,但是对于文件夹和文件来说,和数据结构本身没有太大关系(树形结构的遍历操作),扫描是一个额外的动作,如果给每个文件都添加一个扫描操作会太过于重复,这时采用访问者模式是非常合适的,能够很好分离文件自身的遍历操作和外部的扫描操作。

  • 需要在运行时动态决定使用那些对象和方法的时候。比如,对于监控系统来说,很多时候需要监控运行时的程序状态,但大多数时候又无法预知对象编译时的状态和参数,这时使用访问者模式就可以动态增加监控行为。

  • 需要对复杂对象结构进行多种不同操作时。比如,编译器对抽象语法树进行语法检查、代码生成、优化等不同操作。

最佳实践

  1. 谨慎使用:访问者模式比较复杂,只有在确实需要时才使用
  2. 保持简单:避免在访问者中放置过多的业务逻辑
  3. 考虑替代方案:优先考虑策略模式、命令模式等更简单的替代方案
  4. 文档化:详细记录访问者模式的实现和使用方式,便于维护