作为一名开发者,我们经常遇到这样一个棘手的问题:面对一个已经上线且运行稳定的系统,产品经理突然走过来要求我们为一组现有的核心对象添加全新的业务逻辑。如果这些对象已经被封装在库中,或者修改它们会破坏现有的稳定性,我们该怎么办?
这就引出了今天我们要深入探讨的主题——访问者设计模式。这是一种行为型设计模式,它就像是一把“万能钥匙”,允许我们在不修改这些类结构的情况下,定义作用于这些对象的新操作。在本文中,我们将一起探索这个模式的强大之处、它的内部结构,并通过丰富的代码实例掌握它的实际用法。
目录
目录
- 什么是访问者设计模式?
- 为什么我们需要它?(解决问题)
- 真实世界的类比
- UML 类图与核心组成
- 深入理解:它是如何工作的?
- 代码实战:几何图形面积计算
- 进阶实战:购物车与折扣系统
- 何时使用与何时不使用
- 优点与缺点分析
- 总结与最佳实践
什么是访问者设计模式?
简单来说,访问者设计模式是一种行为型模式,它允许我们在不改变现有对象结构的前提下,定义作用于这些对象的新操作。
你可能会想:“为什么不直接在对象类里加一个方法呢?” 这是一个好问题。通常情况下,直接加方法是可以的。但是,当我们面对一个庞大的系统,其中涉及的类非常稳定(即不常改变),但我们需要频繁地对这些类执行各种不同的操作时,每次都去修改源码不仅麻烦,还很容易引入 Bug。访问者模式通过将数据结构与作用于结构上的操作分离,彻底解决了这个两难问题。
真实世界的类比
为了让你更好地理解,让我们把目光从代码移开,看一个生活中的例子。
想象一下,你正在经营一家大型超市,货架上摆满了各种各样的商品:书、电子产品、服装等等。现在,到了“黑五”促销季,你需要对不同的商品应用不同的折扣策略。
- 如果没有访问者模式:你可能需要跑到每一个商品的货架前,修改它们的价格标签(修改商品类)。如果明天又要搞“会员日”,你得再改一次。
- 如果有访问者模式:你可以雇佣一位“促销员”(访问者)。这位促销员手里拿着一份清单,他走到书面前,根据清单给书打 8 折;走到手机面前,给手机打 9 折。商品本身不需要知道怎么打折,它只需要“接受”这位促销员的访问即可。
在这个例子中,促销员就是“访问者”,商品就是“元素”。通过更换不同的促销员(比如“税务员”或“打包员”),我们可以在不改变商品本身的情况下,执行各种完全不同的操作。
UML 类图与核心组成
在开始编码之前,我们需要先了解这个模式的“骨架”。访问者模式由几个关键组件协同工作:
1. 访问者接口
这是操作声明的核心。它为对象结构中的每一种类型的元素都声明一个 INLINECODEe565f5ce 方法。这意味着,如果你的结构里有 Circle 和 Square,你的 Visitor 接口就会有 INLINECODE950216f1 和 visit(Square s)。
2. 具体访问者
这是实际的干活儿的人。它实现了 Visitor 接口,并实现了所有的 visit 方法。具体的业务逻辑(比如计算面积、生成 XML 报告)都写在这里。
3. 元素接口
这是被访问对象的接口。它通常只包含一个方法:accept(Visitor v)。这个方法是连接对象和访问者的桥梁。
4. 具体元素
这些是具体的类(如 Book, Fruit)。它们实现了 Element 接口。在 INLINECODE1de99db8 方法中,它们通常会调用 INLINECODE45dec1d3,这就是著名的双分派技术的体现。
5. 对象结构
这是一个容器,用于存放所有的具体元素,并能遍历它们。比如一个 List 或者一个购物车对象。
深入理解:它是如何工作的?
访问者模式的核心魔力在于双分派。这是什么意思呢?
- 单分派:在普通的面向对象编程中,比如 INLINECODE445f17f7,具体调用哪个 INLINECODE3b11bc91 方法取决于
shape的运行时类型(多态)。
- 双分派:在访问者模式中,调用过程是这样的:INLINECODE5f54578f -> INLINECODE94f8f4cc。
1. 第一次分派决定了执行哪个元素的 accept 方法。
2. 第二次分派发生在 INLINECODEe45344d0 方法内部,它调用了 visitor 的重载方法 INLINECODE29cfc53f。
通过这种方式,调用哪个具体的 visit 方法,同时取决于元素的类型和访问者的类型。这使得我们可以在不修改元素类的情况下,增加新的 Visitor 类来扩展功能。
代码实战:几何图形面积计算
让我们通过一个经典的例子——计算不同形状的面积——来实现这个模式。假设我们的系统中已经定义好了 INLINECODE93f89259 和 INLINECODEbd8c8045,现在我们需要添加计算面积的功能。
步骤 1:定义 Visitor 接口
首先,我们需要定义访问者接口,明确它能处理哪些类型的元素。
// 访问者接口:为每种具体的图形声明一个访问方法
public interface ShapeVisitor {
// 访问圆形
void visit(Circle circle);
// 访问正方形
void visit(Square square);
// 如果未来有三角形,扩展起来只需在这里加一行
// void visit(Triangle triangle);
}
步骤 2:定义 Element 接口
接着,定义所有图形都要实现的接口。这是允许访问者进来的“大门”。
// 元素接口:定义接受访问者的方法
public interface Shape {
void accept(ShapeVisitor visitor);
}
步骤 3:实现具体的元素类
现在,我们实现具体的图形类。注意看 accept 方法的实现,这是模式的关键点。
// 具体元素:圆形
public class Circle implements Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
public double getRadius() {
return radius;
}
@Override
public void accept(ShapeVisitor visitor) {
// 关键点:调用访问者的 visit 方法,并将自己 作为参数传递
// 这使得访问者可以访问到圆的私有数据(如果需要的话)
visitor.visit(this);
}
}
// 具体元素:正方形
public class Square implements Shape {
private double sideLength;
public Square(double sideLength) {
this.sideLength = sideLength;
}
public double getSideLength() {
return sideLength;
}
@Override
public void accept(ShapeVisitor visitor) {
// 同样地,将自身交给访问者
visitor.visit(this);
}
}
步骤 4:实现具体的访问者
现在,我们来编写具体的业务逻辑:计算面积。请注意,我们并没有修改 Circle 或 Square 的代码!
// 具体访问者:面积计算器
public class AreaCalculator implements ShapeVisitor {
private double totalArea = 0;
@Override
public void visit(Circle circle) {
// 针对圆形的面积计算逻辑
double area = Math.PI * circle.getRadius() * circle.getRadius();
System.out.println("正在计算圆形的面积: " + area);
totalArea += area;
}
@Override
public void visit(Square square) {
// 针对正方形的面积计算逻辑
double area = square.getSideLength() * square.getSideLength();
System.out.println("正在计算正方形的面积: " + area);
totalArea += area;
}
public double getTotalArea() {
return totalArea;
}
}
步骤 5:构建对象结构并运行
最后,我们在客户端代码中组合这些组件。
import java.util.ArrayList;
import java.util.List;
public class Main {
public static void main(String[] args) {
// 1. 创建对象结构(比如一个图形列表)
List shapes = new ArrayList();
shapes.add(new Circle(5));
shapes.add(new Square(4));
shapes.add(new Circle(2));
// 2. 创建具体的访问者
AreaCalculator calculator = new AreaCalculator();
// 3. 遍历结构,让每个图形接受访问者
for (Shape shape : shapes) {
shape.accept(calculator);
}
System.out.println("所有图形的总面积是: " + calculator.getTotalArea());
}
}
运行结果:
正在计算圆形的面积: 78.53981633974483
正在计算正方形的面积: 16.0
正在计算圆形的面积: 12.566370614359172
所有图形的总面积是: 107.10618695410401
进阶实战:电子商务中的折扣系统
为了展示这个模式的灵活性,让我们换个完全不同的场景:电商购物车。这次,我们将添加第二个 Visitor 来展示扩展性。
假设我们有 INLINECODEe3b41585 和 INLINECODE0241ec62 两个类。
// 访问者接口
public interface ItemVisitor {
void visit(Book book);
void visit(Fruit fruit);
}
// 元素接口
interface Item {
void accept(ItemVisitor visitor);
}
// 具体元素:书
public class Book implements Item {
private int isbn;
private double price;
public Book(int isbn, double price) {
this.isbn = isbn;
this.price = price;
}
public double getPrice() { return price; }
@Override
public void accept(ItemVisitor visitor) {
visitor.visit(this);
}
}
// 具体元素:水果
public class Fruit implements Item {
private String name;
private double pricePerKg;
private double weight;
public Fruit(String name, double pricePerKg, double weight) {
this.name = name;
this.pricePerKg = pricePerKg;
this.weight = weight;
}
public double getPricePerKg() { return pricePerKg; }
public double getWeight() { return weight; }
@Override
public void accept(ItemVisitor visitor) {
visitor.visit(this);
}
}
现在,我们实现两个不同的 Visitor。一个用于计算总价,一个用于生成购物清单 XML。这展示了同一个数据结构如何支持多种不相关的操作。
// 访问者1:购物车结算
public class ShoppingCartVisitor implements ItemVisitor {
@Override
public void visit(Book book) {
double cost = book.getPrice();
// 假设书不打折
System.out.println("书的价格: " + cost);
}
@Override
public void visit(Fruit fruit) {
double cost = fruit.getPricePerKg() * fruit.getWeight();
System.out.println("水果价格: " + cost);
}
}
// 访问者2:XML 报告生成器(完全不同的逻辑,复用同一个对象结构)
public class XMLExportVisitor implements ItemVisitor {
@Override
public void visit(Book book) {
System.out.println("" + book.getPrice() + "");
}
@Override
public void visit(Fruit fruit) {
System.out.println("" + (fruit.getPricePerKg() * fruit.getWeight()) + "");
}
}
客户端代码:
public class Main {
public static void main(String[] args) {
List items = new ArrayList();
items.add(new Book(1234, 20.0));
items.add(new Fruit("苹果", 5.0, 2.0));
System.out.println("--- 结算模式 ---");
ItemVisitor costVisitor = new ShoppingCartVisitor();
for (Item item : items) {
item.accept(costVisitor);
}
System.out.println("
--- 导出XML模式 ---");
ItemVisitor xmlVisitor = new XMLExportVisitor();
for (Item item : items) {
item.accept(xmlVisitor);
}
}
}
这个例子非常有力地证明了:当你的需求变化时(比如从结算变成导出数据),你只需要新增一个 Visitor 类,而不需要去触碰 Book 或 Fruit 的代码。这完美符合开闭原则。
何时使用访问者模式?
既然我们已经掌握了它的用法,那么在什么情况下我们应该考虑使用它呢?
- 对象结构稳定,操作多变:如果你确定你的类结构(如 Document 的子类)基本不会变,但你需要不断地对其执行新操作(如拼写检查、语法高亮、导出 PDF、导出 Word),那么这个模式是完美的。
- 跨多个类执行复杂操作:如果一个操作涉及到多个不同类的对象,比如遍历一个包含文件和文件夹的目录树来计算总大小,使用 Visitor 可以避免将这些逻辑分散到各个类中。
- 定义类库的扩展点:如果你正在构建一个框架或库,希望用户能够在不修改核心类的情况下扩展功能,Visitor 模式是一个非常标准的机制。
优缺点分析
就像任何工具一样,访问者模式也有它的两面性。
优点
- 符合开闭原则:你可以在不修改现有对象结构的情况下,添加新的操作(新的 Visitor)。
- 相关行为被集中:每个具体的 Visitor 类都包含了一组特定的相关行为。这意味着,你可以轻松地添加一个新的 Visitor 来聚合所有这些操作,而不需要将这些代码分散在几十个不同的类中。
- 双分派带来的灵活性:它允许我们在运行时根据元素类型和访问者类型来决定执行哪个方法。
缺点
- 增加新的具体元素很困难:这是最大的痛点。如果你需要在系统中增加一个新的
Triangle类,你不仅需要创建这个类,还需要修改 Visitor 接口 以及 所有 已实现的 Visitor 类。这违反了开闭原则的另一面。 - 依赖倒置的破坏:Visitor 接口通常依赖于具体的 Element 类型(如
visit(Circle c)),而不是抽象接口。这增加了元素类与访问者之间的耦合度。 - 封装性被打破:Visitor 模式通常要求 Visitor 能够访问元素的内部状态(如
getRadius())。这意味着我们必须将一些本来应该私有的数据公开,或者提供访问器方法。
总结与最佳实践
在这篇文章中,我们深入探讨了访问者设计模式。我们看到了它是如何通过将操作从对象结构中分离出来,从而赋予我们在不修改现有代码的情况下扩展功能的能力。
主要收获:
- 使用访问者模式当你拥有稳定的类结构但多变的操作需求。
- 记住核心机制:INLINECODEda34790d -> INLINECODE546de6f2。
- 要小心元素类型的变更,因为这会对现有的 Visitor 造成破坏性影响。
最佳建议:
如果你的系统中,对象结构的变动非常频繁(经常加新类),请谨慎使用访问者模式。但在编译器设计、文档处理模型或复杂的报表系统中,它依然是一个不可多得的利器。
希望这篇文章能帮助你更好地理解并运用这个模式。下次当你觉得“我想在这个类里加个方法,但我不想动这个类”的时候,不妨想想我们的老朋友——访问者模式。