Java 23 种设计模式:从踩坑到精通 | 访问者模式 —— 数据结构稳定但操作多变?试试访问者
Java 23 种设计模式:从踩坑到精通 | 访问者模式 —— 数据结构稳定但操作多变?试试访问者
摘要:当数据结构相对稳定,但需要在其上定义多种互不相关的操作时,将操作分散到各个数据类中会导致职责混乱、扩展困难。访问者模式将“操作”从“数据结构”中分离出来,封装为独立的访问者对象,使得新增操作无需修改数据结构本身。本文从电商订单的报表计算场景出发,完整讲解访问者模式的原理、UML、代码实现、双分派机制,与迭代器模式进行对比,并结合 ASM 字节码操作、Spring IoC 的
BeanDefinitionVisitor、代码格式化工具等应用,帮你掌握“操作与结构分离”的设计精髓。
🗺️本文阅读地图(3 分钟速览)
- 为什么每新增一种报表就要改所有商品类?
- 双分派:访问者模式的核心机制
- 手写电商报表系统(销售报表 + 库存报表)
- ASM 字节码框架如何体现代访问者模式
- 面试必问:“访问者和迭代器有什么区别?什么是双分派?”
📖《Java 23 种设计模式:从踩坑到精通》
开篇:系列介绍与目录 | 上一篇:模版方法模式 |当前:访问者模式| 下一篇:系列总结
🔗 返回系列总目录
文章目录
- Java 23 种设计模式:从踩坑到精通 | 访问者模式 —— 数据结构稳定但操作多变?试试访问者
- 1. 从一个“报表计算”的纠结说起
- 1.1 你的场景该不该用访问者?
- 2. 模式定义与 UML 结构
- 图文解析(配合上述 UML 图)
- 3. 核心机制:双分派详解
- 4. 代码实现:电商商品报表系统
- 4.1 抽象元素
- 4.2 具体元素
- 4.3 抽象访问者
- 4.4 具体访问者:销售报表
- 4.5 具体访问者:库存报表
- 4.6 对象结构:订单
- 4.7 客户端
- 5. 优缺点一览
- 6. 访问者模式 vs 迭代器模式
- 7. 框架与实践中的应用
- 7.1 ASM 字节码操作框架
- 7.2 Spring IoC:`BeanDefinitionVisitor`
- 7.3 代码格式化工具(Checkstyle、PMD)
- 8. 面试必问 + 面试官追问连环炮
- 9. 六大设计原则在访问者模式中的体现
- 🧭 《Java 23 种设计模式:从踩坑到精通》快速导航
1. 从一个“报表计算”的纠结说起
假设你开发了一个电商系统,订单中包含多种商品:电子产品、服装、食品。每种商品的计费规则、税费计算、包装方式都不同。现在需要生成多种报表:销售报表、库存报表、物流报表……
如果在每种商品类中写死这些报表逻辑:
classElectronicProduct{voidgenerateSalesReport(){/* 电子产品的销售报表逻辑 */}voidgenerateInventoryReport(){/* 电子产品的库存报表逻辑 */}voidgenerateLogisticsReport(){/* 电子产品的物流报表逻辑 */}}classClothingProduct{voidgenerateSalesReport(){/* 服装的销售报表逻辑 */}voidgenerateInventoryReport(){/* 服装的库存报表逻辑 */}voidgenerateLogisticsReport(){/* 服装的物流报表逻辑 */}}classFoodProduct{voidgenerateSalesReport(){/* 食品的销售报表逻辑 */}voidgenerateInventoryReport(){/* 食品的库存报表逻辑 */}voidgenerateLogisticsReport(){/* 食品的物流报表逻辑 */}}这种写法的致命问题:
- 商品类膨胀:每种商品类塞满了各种报表方法,职责混乱;
- 违反开闭原则:新增一种报表(如“损耗报表”)就要修改所有商品类;
- 报表逻辑分散:同一种报表的逻辑散落在不同商品类中,难以统一管理和优化;
- 测试困难:测试销售报表需要实例化所有商品类型。
访问者模式(Visitor Pattern)正是为此而生:它将“操作”从“数据结构”中分离出来,封装为独立的访问者对象。新增操作只需新增一个访问者类,无需修改任何数据结构。
1.1 你的场景该不该用访问者?
| 判断标准 | 是 → 用访问者 | 否 → 用其他方式 |
|---|---|---|
| 数据结构稳定(元素类型很少变化) | ✅ | ❌ |
| 需要频繁新增对数据结构的操作 | ✅ | ❌ |
| 操作之间互不相关,需要独立管理 | ✅ | ❌ |
| 数据结构频繁变化(经常新增元素类型) | ❌ | 直接在元素类中加方法 |
| 操作很少变化 | ❌ | 直接在元素类中写死即可 |
2. 模式定义与 UML 结构
访问者模式表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。它属于行为型设计模式。
图文解析(配合上述 UML 图)
访问者模式的核心角色:
- 抽象访问者(
Visitor):为每种具体元素声明一个visit()方法,方法名相同但参数类型不同(方法重载)。这是实现双分派的关键。 - 具体访问者(
ConcreteVisitor):实现每个visit()方法,定义对每种元素的具体操作。例如SalesReportVisitor实现销售报表逻辑。 - 抽象元素(
Element):定义一个accept(Visitor)方法,接受一个访问者对象。这是双分派的第一次分派——客户端将访问者传给元素。 - 具体元素(
ElementA/ElementB):实现accept()方法,在方法内部调用visitor.visit(this)。这是双分派的第二次分派——元素将自己传给访问者的对应重载方法。 - 对象结构(
ObjectStructure):持有元素集合,提供遍历所有元素的方法,通常包含accept(Visitor)批量接受访问者。
3. 核心机制:双分派详解
访问者模式的核心是双分派(Double Dispatch),它解决了“如何根据两个对象的类型来决定调用哪个方法”的问题。
Java 是单分派语言:方法调用由接收者类型和参数编译时类型决定。访问者模式通过两次方法调用实现了伪双分派:
// 客户端:第一次分派——选择哪个访问者element.accept(visitor);// 元素内部:第二次分派——根据元素类型选择 visit 的重载版本publicvoidaccept(Visitorvisitor){visitor.visit(this);// this 的类型是确定的:ElectronicProduct}| 调用步骤 | 分派内容 | 决定因素 |
|---|---|---|
product.accept(visitor) | 选择哪个访问者 | 客户端传入的具体访问者类型 |
visitor.visit(this) | 选择visit的哪个重载版本 | this的具体元素类型 |
💬白话:第一次分派是“你要做什么报表”(销售报表 or 库存报表),第二次分派是“你要对什么商品做报表”(电子产品 or 服装 or 食品)。
4. 代码实现:电商商品报表系统
4.1 抽象元素
publicinterfaceProduct{voidaccept(Visitorvisitor);}💬白话:所有商品都必须能“接待”访问者——你来了,我让你处理我。
4.2 具体元素
publicclassElectronicProductimplementsProduct{privateStringname;privatedoubleprice;publicElectronicProduct(Stringname,doubleprice){this.name=name;this.price=price;}publicStringgetName(){returnname;}publicdoublegetPrice(){returnprice;}@Overridepublicvoidaccept(Visitorvisitor){visitor.visit(this);// 第二次分派:把自己传给访问者}}publicclassClothingProductimplementsProduct{privateStringname;privatedoubleprice;privateStringsize;publicClothingProduct(Stringname,doubleprice,Stringsize){this.name=name;this.price=price;this.size=size;}publicStringgetName(){returnname;}publicdoublegetPrice(){returnprice;}publicStringgetSize(){returnsize;}@Overridepublicvoidaccept(Visitorvisitor){visitor.visit(this);}}publicclassFoodProductimplementsProduct{privateStringname;privatedoubleprice;privateLocalDateexpiryDate;publicFoodProduct(Stringname,doubleprice,LocalDateexpiryDate){this.name=name;this.price=price;this.expiryDate=expiryDate;}publicStringgetName(){returnname;}publicdoublegetPrice(){returnprice;}publicLocalDategetExpiryDate(){returnexpiryDate;}@Overridepublicvoidaccept(Visitorvisitor){visitor.visit(this);}}💬白话:每个商品类的
accept()都只有一行——调用visitor.visit(this)。商品类不关心访问者做什么,只管把自己交出去。
4.3 抽象访问者
publicinterfaceVisitor{voidvisit(ElectronicProductproduct);voidvisit(ClothingProductproduct);voidvisit(FoodProductproduct);}💬白话:访问者必须为每种商品类型准备一个
visit()方法。三个方法名相同但参数类型不同——这是 Java 的方法重载。
4.4 具体访问者:销售报表
publicclassSalesReportVisitorimplementsVisitor{privatedoubletotalSales=0;@Overridepublicvoidvisit(ElectronicProductproduct){doubletax=product.getPrice()*0.13;System.out.println("电子产品:"+product.getName()+",售价:"+product.getPrice()+",税费:"+tax);totalSales+=product.getPrice()+tax;}@Overridepublicvoidvisit(ClothingProductproduct){doubletax=product.getPrice()*0.08;System.out.println("服装:"+product.getName()+"("+product.getSize()+"),售价:"+product.getPrice()+",税费:"+tax);totalSales+=product.getPrice()+tax;}@Overridepublicvoidvisit(FoodProductproduct){doubletax=product.getPrice()*0.05;System.out.println("食品:"+product.getName()+",售价:"+product.getPrice()+",税费:"+tax+",保质期至:"+product.getExpiryDate());totalSales+=product.getPrice()+tax;}publicdoublegetTotalSales(){returntotalSales;}}💬白话:销售报表知道每种商品怎么算税费——电子产品 13%、服装 8%、食品 5%。这些逻辑全部集中在访问者中,商品类完全不知道。
4.5 具体访问者:库存报表
publicclassInventoryReportVisitorimplementsVisitor{@Overridepublicvoidvisit(ElectronicProductproduct){System.out.println("【库存-电子】"+product.getName()+",建议库存:50 件");}@Overridepublicvoidvisit(ClothingProductproduct){Stringsuggestion=product.getSize().equals("XL")?"建议库存:20 件(大码少备货)":"建议库存:50 件";System.out.println("【库存-服装】"+product.getName()+"("+product.getSize()+"),"+suggestion);}@Overridepublicvoidvisit(FoodProductproduct){booleannearExpiry=product.getExpiryDate().isBefore(LocalDate.now().plusDays(30));Stringsuggestion=nearExpiry?"警告:临近保质期,建议库存:10 件":"建议库存:100 件";System.out.println("【库存-食品】"+product.getName()+","+suggestion);}}💬白话:库存报表完全不关心税费,它关心的是“该备多少货”。这就是访问者模式的核心价值——每种操作独立成类,互不干扰。
4.6 对象结构:订单
publicclassOrder{privateList<Product>products=newArrayList<>();publicvoidaddProduct(Productproduct){products.add(product);}publicvoidaccept(Visitorvisitor){for(Productproduct:products){product.accept(visitor);// 遍历每个商品,让访问者处理}}}💬白话:订单就是一个“商品集合”,它负责遍历所有商品,让访问者逐个处理。
4.7 客户端
Orderorder=newOrder();order.addProduct(newElectronicProduct("机械键盘",599));order.addProduct(newClothingProduct("羽绒服",899,"XL"));order.addProduct(newFoodProduct("有机牛奶",15,LocalDate.now().plusDays(20)));System.out.println("===== 销售报表 =====");SalesReportVisitorsalesVisitor=newSalesReportVisitor();order.accept(salesVisitor);System.out.println("销售总额:"+salesVisitor.getTotalSales());System.out.println("\n===== 库存报表 =====");InventoryReportVisitorinventoryVisitor=newInventoryReportVisitor();order.accept(inventoryVisitor);输出:
===== 销售报表 ===== 电子产品:机械键盘,售价:599.0,税费:77.87 服装:羽绒服(XL),售价:899.0,税费:71.92 食品:有机牛奶,售价:15.0,税费:0.75,保质期至:2026-06-16 销售总额:1663.54 ===== 库存报表 ===== 【库存-电子】机械键盘,建议库存:50 件 【库存-服装】羽绒服(XL),建议库存:20 件(大码少备货) 【库存-食品】有机牛奶,警告:临近保质期,建议库存:10 件新增一种报表(如物流报表)只需新增一个LogisticsReportVisitor类,所有商品类零修改。这就是访问者模式的核心价值。
5. 优缺点一览
| 优点 | 缺点 |
|---|---|
| 操作与结构分离:新增操作只需新增访问者,符合开闭原则 | 元素类必须稳定:新增元素类型需要修改所有访问者 |
| 职责集中:相同操作集中在访问者中,便于维护 | 破坏封装:访问者通常需要访问元素的内部细节 |
| 双分派机制:巧妙解决根据两个类型选择方法的问题 | 双分派理解成本高:初学时不易理解调用过程 |
| 访问者可以积累状态:如销售报表统计总额 | 不符合开闭原则(元素维度):新增元素类型违反开闭原则 |
6. 访问者模式 vs 迭代器模式
| 对比维度 | 访问者模式 | 迭代器模式 |
|---|---|---|
| 目的 | 在数据结构上执行操作 | 顺序访问数据结构中的元素 |
| 操作与遍历 | 访问者定义操作,遍历由对象结构控制 | 迭代器负责遍历,客户端执行操作 |
| 扩展方向 | 操作维度(新增访问者) | 遍历方式(新增迭代器) |
| 典型应用 | ASM 字节码分析、报表生成 | 集合遍历 |
💡 两者可以组合使用:用迭代器遍历集合,在遍历中对每个元素应用访问者。访问者模式关注“对元素做什么”,迭代器模式关注“如何遍历元素”。
7. 框架与实践中的应用
7.1 ASM 字节码操作框架
ASM 是访问者模式最经典的实现。ClassVisitor是抽象访问者,提供visitField()、visitMethod()等方法。开发者通过继承ClassVisitor实现对字节码的修改和分析,无需了解字节码的内部结构。
// ASM 使用访问者模式解析字节码ClassReaderreader=newClassReader("com.example.MyClass");ClassWriterwriter=newClassWriter(reader,0);ClassVisitorvisitor=newMyClassVisitor(writer);// 自定义访问者reader.accept(visitor,0);// 遍历字节码,访问者处理每个部分7.2 Spring IoC:BeanDefinitionVisitor
Spring 的BeanDefinitionVisitor使用访问者模式来遍历和修改BeanDefinition的属性值,常用于占位符替换、属性覆盖等场景。
7.3 代码格式化工具(Checkstyle、PMD)
这些静态分析工具通过访问者模式遍历 AST(抽象语法树),对不同类型的节点执行不同的检查规则。每种检查规则都是一个独立的访问者。
8. 面试必问 + 面试官追问连环炮
基础必问
- 访问者模式的核心角色?→ Visitor、ConcreteVisitor、Element、ConcreteElement、ObjectStructure。
- 什么是双分派?→ 第一次分派选择访问者(
element.accept(visitor)),第二次分派根据元素类型选择对应方法(visitor.visit(this))。 - 访问者模式为什么要求元素类稳定?→ 新增元素需要修改抽象访问者及所有具体访问者,违反开闭原则。
面试官追问
- “访问者模式破坏了封装性吗?”
👉 确实需要元素暴露部分内部状态给访问者。如果封装性要求极高,可以用迭代器模式代替,或让元素自己提供操作接口。 - “ASM 用了什么设计模式?”
👉 访问者模式。ClassVisitor是抽象访问者,开发者通过继承它实现对字节码的修改和分析。 - “访问者模式和迭代器模式如何配合使用?”
👉 迭代器负责遍历集合,访问者负责执行操作。例如用迭代器遍历订单中的商品,每个商品再接受访问者处理。 - “为什么说访问者模式在元素维度上违反开闭原则?”
👉 新增一种元素类型(如新增SportProduct),需要修改Visitor接口及所有具体访问者类。这是访问者模式最大的局限性。
🎉恭喜:如果你能立刻画出访问者模式的 UML 图,并解释双分派的两次方法调用过程,你已经掌握了 GoF 中最复杂的行为型模式之一。面试中回答“访问者模式 = 双分派 + 操作与结构分离”,会让面试官眼前一亮。
9. 六大设计原则在访问者模式中的体现
| 设计原则 | 在访问者模式中的体现 |
|---|---|
| 单一职责原则(SRP) | 元素类负责数据,访问者负责操作,职责分离 |
| 开闭原则(OCP) | 新增操作只需新增访问者类,无需修改元素类;但新增元素类型需修改访问者 |
| 里氏替换原则(LSP) | 所有元素实现Element接口,所有访问者实现Visitor接口,可替换 |
| 依赖倒置原则(DIP) | 元素依赖抽象Visitor,访问者依赖抽象Element |
| 接口隔离原则(ISP) | Visitor为每种元素定义独立的visit()方法 |
| 迪米特法则(LoD) | 对象结构封装遍历逻辑,客户端只与访问者交互 |
🧭 《Java 23 种设计模式:从踩坑到精通》快速导航
- 开篇:系列介绍与目录
- 上一篇:模版方法模式 —— 定义算法骨架,交给子类填充细节
- 当前:访问者模式 —— 数据结构稳定但操作多变?试试访问者(你在这里)
- 下一篇:系列总结:25 篇文章,我们学到了什么?
- 创建型模式汇总:单例、工厂、建造者、原型
- 结构型模式汇总:适配器、装饰器、代理……
- 行为型模式汇总:观察者、策略、模板方法……
🔔 关注《Java 23 种设计模式:从踩坑到精通》,用 25 篇文章彻底吃透设计模式。
📦福利预告:全系列代码及 UML 源码将在完结时统一打包开放,点击「关注」「收藏」第一时间获取。
🚀下一篇:系列总结:25 篇文章,我们学到了什么?
📌 除了设计模式,我也在深挖智能物流实战(WMS、托盘调度、机器学习落地)。欢迎点击头像,看看专栏 《出版社物流WMS智能调度实战》。技术相通,思路可鉴。
