🛒访问者模式:代码界的“超市管家”,让数据操作更灵活!
一、生活中的访问者模式
✨想象你去超市购物:商品(数据结构)在货架上保持不变,但不同的角色(访问者)会对它们执行不同操作 —— 收银员计算总价、保洁员清洁商品、理货员整理库存。这就是访问者模式的核心思想:将数据结构与操作分离,使得在不改变数据结构的前提下,可以定义作用于这些元素的新操作。
二、访问者模式核心解析
2.1 模式定义与原理
访问者模式,简单来说,就是把数据结构和对数据的操作分离开。定义是:将作用于某种数据结构中的各元素的操作分离出来,封装成独立的类,使得在不改变数据结构的前提下,可以添加新的操作。它的核心原理基于 “双重分派”,即对象的方法调用和方法的实现绑定过程分两步进行。在访问者模式中,首先是元素对象接收访问者对象,这是第一次分派;然后元素对象调用访问者对象中针对自己类型的访问方法,这是第二次分派。
用代码来简单示意一下原理:
// 抽象访问者
interface Visitor {
void visit(ConcreteElementA element);
void visit(ConcreteElementB element);
}
// 具体访问者
class ConcreteVisitor implements Visitor {
@Override
public void visit(ConcreteElementA element) {
System.out.println("访问具体元素A");
}
@Override
public void visit(ConcreteElementB element) {
System.out.println("访问具体元素B");
}
}
// 抽象元素
interface Element {
void accept(Visitor visitor);
}
// 具体元素A
class ConcreteElementA implements Element {
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
// 具体元素B
class ConcreteElementB implements Element {
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
在上述代码中,Visitor
接口定义了对不同具体元素的访问方法,ConcreteVisitor
实现了这些访问方法。Element
接口定义了接受访问者的方法accept
,ConcreteElementA
和ConcreteElementB
实现了accept
方法,并在其中调用访问者的相应访问方法。这样,通过元素对象的accept
方法和访问者对象的访问方法,实现了数据结构与操作的分离,体现了访问者模式的原理。
2.2 五大核心角色
抽象元素(Element):定义一个接受访问者的接口accept(Visitor visitor)
,为所有具体元素提供一个统一的接受访问的方法声明。比如在电商系统中商品抽象类,不管是书籍、电子产品还是其他商品,都继承这个抽象类并实现accept
方法。
// 抽象元素
interface Element {
void accept(Visitor visitor);
}
具体元素(ConcreteElement):实现抽象元素定义的accept
方法,通常是调用访问者的具体访问方法,并将自身作为参数传递。以电商商品为例,书籍类、电子产品类就是具体元素。
// 具体元素 - 书籍
class Book implements Element {
private String name;
private double price;
public Book(String name, double price) {
this.name = name;
this.price = price;
}
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
public String getName() {
return name;
}
public double getPrice() {
return price;
}
}
抽象访问者(Visitor):声明一系列访问具体元素的方法,这些方法的参数通常是具体元素类型。在电商场景中,比如操作接口(计算价格、打印商品信息等)。
// 抽象访问者
interface Visitor {
void visit(Book book);
void visit(ElectronicProduct product);
}
具体访问者(ConcreteVisitor):实现抽象访问者声明的方法,完成对具体元素的特定操作。例如电商中的价格计算器、报表生成器。
// 具体访问者 - 价格计算器
class PriceCalculator implements Visitor {
private double totalPrice;
@Override
public void visit(Book book) {
totalPrice += book.getPrice();
}
@Override
public void visit(ElectronicProduct product) {
totalPrice += product.getPrice();
}
public double getTotalPrice() {
return totalPrice;
}
}
对象结构(ObjectStructure):管理元素集合,可以是一个列表、树等数据结构,并提供遍历元素的方法,以便访问者能够访问到每一个元素。比如电商中的商品列表。
// 对象结构 - 商品列表
class ProductList {
private List<Element> elements = new ArrayList<>();
public void addElement(Element element) {
elements.add(element);
}
public void accept(Visitor visitor) {
for (Element element : elements) {
element.accept(visitor);
}
}
}
这五个角色相互协作,共同完成访问者模式的数据结构与操作的解耦,使得在不修改元素类的情况下,可以方便地添加新的操作。
三、Java 代码实战:超市商品管理
3.1 基础实现
接下来通过一个超市商品管理系统的例子来进一步理解访问者模式。假设超市中有不同类型的商品,如水果、糖果、酒水,现在要实现对这些商品的计价和库存管理等操作。
定义抽象元素(商品)
import java.time.LocalDate;
// 抽象商品类,所有商品的父类,作为抽象元素角色
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;
}
public String getName() {
return name;
}
public LocalDate getProducedDate() {
return producedDate;
}
public double getPrice() {
return price;
}
// 接受访问者的方法,所有具体商品类都要实现这个方法来接受访问者访问
public abstract void accept(Visitor visitor);
}
定义具体元素(具体商品)
// 糖果类,继承自Product,是具体元素角色
public class Candy extends Product {
public Candy(String name, LocalDate producedDate, double price) {
super(name, producedDate, price);
}
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
// 酒水类,继承自Product,是具体元素角色
public class Wine extends Product {
public Wine(String name, LocalDate producedDate, double price) {
super(name, producedDate, price);
}
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
// 水果类,继承自Product,是具体元素角色
public class Fruit extends Product {
private float weight; // 水果重量
public Fruit(String name, LocalDate producedDate, double price, float weight) {
super(name, producedDate, price);
this.weight = weight;
}
public float getWeight() {
return weight;
}
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
定义抽象访问者
import java.time.LocalDate;
// 抽象访问者接口,定义了对不同商品的访问操作
public interface Visitor {
void visit(Candy candy); // 访问糖果的方法
void visit(Wine wine); // 访问酒水的方法
void visit(Fruit fruit); // 访问水果的方法
}
定义具体访问者(以计价为例)
import java.time.LocalDate;
import java.text.NumberFormat;
import java.util.Locale;
// 折扣计价访问者类,实现Visitor接口,是具体访问者角色
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());
// 计算糖果生产天数
long days = billDate.toEpochDay() - candy.getProducedDate().toEpochDay();
if (days > 180) {
System.out.println("超过半年的糖果,请勿食用!");
} else {
double rate = 0.9; // 折扣率
double discountPrice = candy.getPrice() * rate;
// 使用NumberFormat格式化输出价格
System.out.println("糖果打折后的价格" + NumberFormat.getCurrencyInstance(Locale.CHINA).format(discountPrice));
}
}
@Override
public void visit(Wine wine) {
System.out.println("酒类: " + wine.getName() + ",无折扣价格!");
System.out.println("原价: " + NumberFormat.getCurrencyInstance(Locale.CHINA).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("超过七天的水果,请勿食用!");
} else {
rate = 0.8; // 折扣率
double discountPrice = fruit.getPrice() * rate;
System.out.println("水果打折后的价格" + NumberFormat.getCurrencyInstance(Locale.CHINA).format(discountPrice));
}
}
}
定义对象结构(商品集合)
import java.util.ArrayList;
import java.util.List;
// 商品列表类,作为对象结构角色,管理商品集合
public class ProductList {
private List<Product> products = new ArrayList<>(); // 存储商品的列表
public void addProduct(Product product) {
products.add(product);
}
// 接受访问者访问,遍历商品列表,让每个商品接受访问者访问
public void accept(Visitor visitor) {
for (Product product : products) {
product.accept(visitor);
}
}
}
四、应用场景大揭秘
4.1 电商平台
在电商平台中,商品是一个复杂的数据结构。以商品列表为例,商品可能有不同的类型(如电子产品、服装、食品等)。当需要进行多维度操作时,比如计算商品总价、统计库存、生成促销报表等,就可以使用访问者模式。不同的操作由不同的访问者实现,商品类只需要实现接受访问者的方法,这样可以将操作与商品的数据结构分离,方便后续添加新的操作。
// 抽象商品类
abstract class Product {
private String name;
private double price;
public Product(String name, double price) {
this.name = name;
this.price = price;
}
public String getName() {
return name;
}
public double getPrice() {
return price;
}
public abstract void accept(Visitor visitor);
}
// 电子产品类
class ElectronicProduct extends Product {
public ElectronicProduct(String name, double price) {
super(name, price);
}
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
// 服装类
class Clothing extends Product {
public Clothing(String name, double price) {
super(name, price);
}
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
}
// 抽象访问者接口
interface Visitor {
void visit(ElectronicProduct product);
void visit(Clothing product);
}
// 总价计算访问者
class PriceCalculator implements Visitor {
private double totalPrice;
@Override
public void visit(ElectronicProduct product) {
totalPrice += product.getPrice();
}
@Override
public void visit(Clothing product) {
totalPrice += product.getPrice();
}
public double getTotalPrice() {
return totalPrice;
}
}
// 库存统计访问者
class InventoryCounter implements Visitor {
private int totalInventory;
@Override
public void visit(ElectronicProduct product) {
// 假设每个电子产品库存为10,实际可从数据库获取
totalInventory += 10;
}
@Override
public void visit(Clothing product) {
// 假设每件衣服库存为20,实际可从数据库获取
totalInventory += 20;
}
public int getTotalInventory() {
return totalInventory;
}
}
// 对象结构:商品列表
class ProductList {
private List<Product> products = new ArrayList<>();
public void addProduct(Product product) {
products.add(product);
}
public void accept(Visitor visitor) {
for (Product product : products) {
product.accept(visitor);
}
}
}
// 测试代码
public class ECommerceApp {
public static void main(String[] args) {
ProductList productList = new ProductList();
productList.addProduct(new ElectronicProduct("手机", 5000));
productList.addProduct(new Clothing("T恤", 100));
PriceCalculator priceCalculator = new PriceCalculator();
productList.accept(priceCalculator);
System.out.println("商品总价: " + priceCalculator.getTotalPrice());
InventoryCounter inventoryCounter = new InventoryCounter();
productList.accept(inventoryCounter);
System.out.println("商品总库存: " + inventoryCounter.getTotalInventory());
}
}
4.2 多格式输出
在生成数据报表时,数据可能以多种格式输出,如 HTML、PDF、Excel。数据本身是一个稳定的数据结构,而不同的输出格式是对数据的不同操作。可以使用访问者模式,将不同格式的生成逻辑封装在不同的访问者中。例如,HtmlVisitor
负责将数据转换为 HTML 格式,PdfVisitor
负责转换为 PDF 格式。这样,当需要新增一种输出格式时,只需要添加一个新的访问者类,而不需要修改数据结构相关的代码。
// 抽象数据元素
abstract class DataElement {
public abstract void accept(ReportVisitor visitor);
}
// 具体数据元素:订单数据
class OrderData extends DataElement {
private String orderId;
private double amount;
public OrderData(String orderId, double amount) {
this.orderId = orderId;
this.amount = amount;
}
public String getOrderId() {
return orderId;
}
public double getAmount() {
return amount;
}
@Override
public void accept(ReportVisitor visitor) {
visitor.visit(this);
}
}
// 抽象访问者:报表生成访问者
interface ReportVisitor {
void visit(OrderData orderData);
}
// HTML报表访问者
class HtmlVisitor implements ReportVisitor {
@Override
public void visit(OrderData orderData) {
System.out.println("<html><body>");
System.out.println("<h2>订单信息</h2>");
System.out.println("<p>订单ID: " + orderData.getOrderId() + "</p>");
System.out.println("<p>订单金额: " + orderData.getAmount() + "</p>");
System.out.println("</body></html>");
}
}
// PDF报表访问者(这里简单示意,实际生成PDF较复杂)
class PdfVisitor implements ReportVisitor {
@Override
public void visit(OrderData orderData) {
System.out.println("生成PDF报表,订单ID: " + orderData.getOrderId() + ", 金额: " + orderData.getAmount());
}
}
// 对象结构:数据集合
class DataCollection {
private List<DataElement> elements = new ArrayList<>();
public void addElement(DataElement element) {
elements.add(element);
}
public void accept(ReportVisitor visitor) {
for (DataElement element : elements) {
element.accept(visitor);
}
}
}
// 测试代码
public class ReportGenerator {
public static void main(String[] args) {
DataCollection dataCollection = new DataCollection();
dataCollection.addElement(new OrderData("1001", 1000.0));
HtmlVisitor htmlVisitor = new HtmlVisitor();
dataCollection.accept(htmlVisitor);
PdfVisitor pdfVisitor = new PdfVisitor();
dataCollection.accept(pdfVisitor);
}
}
4.3 编译器设计
在编译器设计中,语法树是一个复杂的数据结构。编译器需要对语法树进行各种操作,如语法检查、代码优化、代码生成等。通过访问者模式,可以将这些操作分别封装在不同的访问者类中。例如,OptimizationVisitor
负责对语法树节点进行优化,CodeGeneratorVisitor
负责生成目标代码。语法树节点类只需要实现接受访问者的方法,这样可以使编译器的功能扩展更加方便,当有新的操作需求时,只需要添加新的访问者类即可。
4.4 游戏开发
在游戏开发中,角色状态统计和装备系统可以使用访问者模式。角色是数据结构,不同的操作(如统计角色属性、计算装备加成等)由不同的访问者实现。比如,AttributeStatVisitor
用于统计角色的生命值、攻击力等属性,EquipmentBonusVisitor
用于计算装备给角色带来的加成。这样,当游戏需要增加新的角色属性或装备效果时,通过添加新的访问者类就能轻松实现,而不需要修改角色类的代码。
五、优缺点大 PK
5.1 优点
解耦数据与操作:将数据结构和操作分离,比如电商商品的数据结构不变,新的统计操作只需要添加新访问者,不影响商品类。
扩展性强:符合开闭原则,新增操作时,只需要添加新的访问者类,不需要修改已有的元素类和对象结构类。例如在电商平台添加新的促销规则计算,只需新增促销规则访问者。
集中管理操作:同类操作可以集中在一个访问者类中,使代码结构更清晰,便于维护。如超市商品管理中,计价操作集中在计价访问者中。
5.2 缺点
元素变更困难:如果要在元素类中添加新的属性或方法,所有的访问者类都需要进行相应的修改,维护成本高。例如超市商品新增保质期属性,所有访问者都要修改。
学习成本高:访问者模式的结构和双重分派机制相对复杂,理解和实现起来有一定难度,需要花费时间学习和掌握。
六、避坑指南
在使用访问者模式时,为了避免一些常见的问题,需要注意以下几点:
6.1 优先使用重载
在定义访问者接口方法时,尽量利用方法重载来区分不同的元素类型。这样可以让代码更清晰,不同元素类型的操作逻辑更明确。比如在电商商品访问者中,visit(ElectronicProduct product)
和visit(Clothing product)
分别处理电子产品和服装,避免在一个方法中通过条件判断来处理不同类型元素。
6.2 限制元素类型
访问者模式要求元素类型相对稳定。如果元素类型频繁变动,比如电商商品类型不断新增,就需要频繁修改访问者接口及其实现类,违背了开闭原则。所以在使用访问者模式前,要确保元素结构在未来一段时间内不会有大的变动。
6.3 结合其他模式
访问者模式可以和其他模式结合使用。例如与迭代器模式结合,在对象结构中使用迭代器遍历元素,简化遍历逻辑。在超市商品管理系统中,商品列表可以使用迭代器来遍历商品,让访问者更方便地访问每个商品。
6.4 谨慎使用 AOP
虽然访问者模式可以用于 AOP(面向切面编程),将切面逻辑作为访问者,被切对象作为元素。但要注意,AOP 本身就比较复杂,使用访问者模式实现 AOP 时,要避免过度设计。在简单场景下,使用策略模式等更简单的方式可能更合适。
七、模式对比
为了更清晰地理解访问者模式,将它与策略模式、迭代器模式进行对比:
模式 | 核心差异 | 适用场景 |
---|---|---|
策略 | 动态选择算法 | 支付方式切换 |
访问者 | 数据结构与操作解耦 | 多维度数据分析 |
迭代器 | 遍历集合元素 | 列表 / 树结构遍历 |
策略模式:主要解决的是在有多种算法相似的情况下,使用 if...else
带来的复杂和难以维护问题。它通过将算法封装成一个个独立的策略类,使得在运行时可以自由切换算法。例如在电商支付中,用户可以在信用卡支付、PayPal 支付等多种支付方式(策略)中动态选择。
访问者模式:重点在于将数据结构和对数据的操作分离,使得在不改变数据结构的前提下,可以方便地定义新的操作。比如电商商品管理中,对商品的计价、库存统计等操作可以由不同访问者实现,商品结构不变。
迭代器模式:专注于提供一种顺序访问集合对象内部元素的方法,而不暴露集合的内部表示。它解耦了集合和遍历算法,使得遍历逻辑独立于集合本身。例如在遍历商品列表时,使用迭代器可以按顺序逐个访问商品,而无需关心商品列表是如何存储的。
八、总结
访问者模式是应对复杂数据操作场景的利器,适用于以下情况:
需要对多种类型对象执行不同操作:当对象结构包含多个类型的对象,且需要对这些对象实施依赖其具体类型的操作时,如电商平台中对不同商品类型进行不同的统计操作。
操作频繁变化而数据结构稳定:在数据结构相对稳定,但操作经常变化的场景下,使用访问者模式可以方便地添加新的操作,而无需修改数据结构相关的代码,比如多格式输出场景。
集中管理同类操作:将同类操作集中在访问者类中,使代码结构更清晰,维护更方便,例如编译器设计中对语法树节点的不同操作由不同访问者管理。
避免修改已有数据类:如果不想在已有数据类中添加新的操作方法,以免污染数据类,访问者模式提供了一种将操作与数据结构分离的方式。
在实际开发中,使用访问者模式时可以参考以下建议:
简单场景优化:对于简单的场景,可以省略对象结构,直接调用访问者对元素进行操作,减少不必要的代码复杂度。
结合其他模式:在复杂业务中,可以结合工厂模式来管理访问者的创建,提高代码的可维护性和扩展性。比如根据不同的业务需求,通过工厂模式创建相应的访问者实例。
控制访问者数量:注意控制访问者的数量,避免因访问者过多导致代码难以维护和理解,造成过度设计。
你在哪些项目中用过访问者模式?评论区聊聊你的 “数据操作” 经验吧!👇
💡小知识:Java 的javax.lang.model.element.Element
和javax.lang.model.element.ElementVisitor
就是访问者模式的典型应用,用于在 Java 注解处理中遍历和处理抽象语法树节点。