行为模式-访问者模式
行为模式-访问者模式
亦称: Visitor
意图
访问者模式是一种行为设计模式, 它能将算法与其所作用的对象隔离开来。
问题
假如你的团队开发了一款能够使用巨型图像中地理信息的应用程序。 图像中的每个节点既能代表复杂实体 (例如一座城市), 也能代表更精细的对象 (例如工业区和旅游景点等)。 如果节点代表的真实对象之间存在公路, 那么这些节点就会相互连接。 在程序内部, 每个节点的类型都由其所属的类来表示, 每个特定的节点则是一个对象。
一段时间后, 你接到了实现将图像导出到 XML 文件中的任务。 这些工作最初看上去非常简单。 你计划为每个节点类添加导出函数, 然后递归执行图像中每个节点的导出函数。 解决方案简单且优雅: 使用多态机制可以让导出方法的调用代码不会和具体的节点类相耦合。
但你不太走运, 系统架构师拒绝批准对已有节点类进行修改。 他认为这些代码已经是产品了, 不想冒险对其进行修改, 因为修改可能会引入潜在的缺陷。
此外, 他还质疑在节点类中包含导出 XML 文件的代码是否有意义。 这些类的主要工作是处理地理数据。 导出 XML 文件的代码放在这里并不合适。
还有另一个原因, 那就是在此项任务完成后, 营销部门很有可能会要求程序提供导出其他类型文件的功能, 或者提出其他奇怪的要求。 这样你很可能会被迫再次修改这些重要但脆弱的类。
解决方案
访问者模式建议将新行为放入一个名为访问者的独立类中, 而不是试图将其整合到已有类中。 现在, 需要执行操作的原始对象将作为参数被传递给访问者中的方法, 让方法能访问对象所包含的一切必要数据。
如果现在该操作能在不同类的对象上执行会怎么样呢? 比如在我们的示例中, 各节点类导出 XML 文件的实际实现很可能会稍有不同。 因此, 访问者类可以定义一组 (而不是一个) 方法, 且每个方法可接收不同类型的参数, 如下所示:
1 | class ExportVisitor implements Visitor is |
但我们究竟应该如何调用这些方法 (尤其是在处理整个图像方面) 呢? 这些方法的签名各不相同, 因此我们不能使用多态机制。 为了可以挑选出能够处理特定对象的访问者方法, 我们需要对它的类进行检查。 这是不是听上去像个噩梦呢?
1 | foreach (Node node in graph) |
你可能会问, 我们为什么不使用方法重载呢? 就是使用相同的方法名称, 但它们的参数不同。 不幸的是, 即使我们的编程语言 (例如 Java 和 C#) 支持重载也不行。 由于我们无法提前知晓节点对象所属的类, 所以重载机制无法执行正确的方法。 方法会将 节点
基类作为输入参数的默认类型。
但是, 访问者模式可以解决这个问题。 它使用了一种名为双分派的技巧, 不使用累赘的条件语句也可下执行正确的方法。 与其让客户端来选择调用正确版本的方法, 不如将选择权委派给作为参数传递给访问者的对象。 由于该对象知晓其自身的类, 因此能更自然地在访问者中选出正确的方法。 它们会 “接收” 一个访问者并告诉其应执行的访问者方法。
1 | // 客户端代码 |
我承认最终还是修改了节点类, 但毕竟改动很小, 且使得我们能够在后续进一步添加行为时无需再次修改代码。
现在, 如果我们抽取出所有访问者的通用接口, 所有已有的节点都能与我们在程序中引入的任何访问者交互。 如果需要引入与节点相关的某个行为, 你只需要实现一个新的访问者类即可。
真实世界类比
假如有这样一位非常希望赢得新客户的资深保险代理人。 他可以拜访街区中的每栋楼, 尝试向每个路人推销保险。 所以, 根据大楼内组织类型的不同, 他可以提供专门的保单:
- 如果建筑是居民楼, 他会推销医疗保险。
- 如果建筑是银行, 他会推销失窃保险。
- 如果建筑是咖啡厅, 他会推销火灾和洪水保险。
访问者模式结构
- 访问者 (Visitor) 接口声明了一系列以对象结构的具体元素为参数的访问者方法。 如果编程语言支持重载, 这些方法的名称可以是相同的, 但是其参数一定是不同的。
- 具体访问者 (Concrete Visitor) 会为不同的具体元素类实现相同行为的几个不同版本。
- 元素 (Element) 接口声明了一个方法来 “接收” 访问者。 该方法必须有一个参数被声明为访问者接口类型。
- 具体元素 (Concrete Element) 必须实现接收方法。 该方法的目的是根据当前元素类将其调用重定向到相应访问者的方法。 请注意, 即使元素基类实现了该方法, 所有子类都必须对其进行重写并调用访问者对象中的合适方法。
- 客户端 (Client) 通常会作为集合或其他复杂对象 (例如一个组合树) 的代表。 客户端通常不知晓所有的具体元素类, 因为它们会通过抽象接口与集合中的对象进行交互。
伪代码
在本例中, 访问者模式为几何图像层次结构添加了对于 XML 文件导出功能的支持。
1 | // 元素接口声明了一个`accept(接收)`方法,它会将访问者基础接口作为一个参 |
如果你并不十分理解为何本例中需要使用 accept
接收方法, 我的一篇文章访问者和双分派详细解释了这个问题。
访问者模式适合应用场景
如果你需要对一个复杂对象结构 (例如对象树) 中的所有元素执行某些操作, 可使用访问者模式。
访问者模式通过在访问者对象中为多个目标类提供相同操作的变体, 让你能在属于不同类的一组对象上执行同一操作。
可使用访问者模式来清理辅助行为的业务逻辑。
该模式会将所有非主要的行为抽取到一组访问者类中, 使得程序的主要类能更专注于主要的工作。
当某个行为仅在类层次结构中的一些类中有意义, 而在其他类中没有意义时, 可使用该模式。
你可将该行为抽取到单独的访问者类中, 只需实现接收相关类的对象作为参数的访问者方法并将其他方法留空即可。
实现方式
在访问者接口中声明一组 “访问” 方法, 分别对应程序中的每个具体元素类。
声明元素接口。 如果程序中已有元素类层次接口, 可在层次结构基类中添加抽象的 “接收” 方法。 该方法必须接受访问者对象作为参数。
在所有具体元素类中实现接收方法。 这些方法必须将调用重定向到当前元素对应的访问者对象中的访问者方法上。
元素类只能通过访问者接口与访问者进行交互。 不过访问者必须知晓所有的具体元素类, 因为这些类在访问者方法中都被作为参数类型引用。
为每个无法在元素层次结构中实现的行为创建一个具体访问者类并实现所有的访问者方法。
你可能会遇到访问者需要访问元素类的部分私有成员变量的情况。 在这种情况下, 你要么将这些变量或方法设为公有, 这将破坏元素的封装; 要么将访问者类嵌入到元素类中。 后一种方式只有在支持嵌套类的编程语言中才可能实现。
客户端必须创建访问者对象并通过 “接收” 方法将其传递给元素。
访问者模式优缺点
优点
- 开闭原则。 你可以引入在不同类对象上执行的新行为, 且无需对这些类做出修改。
- 单一职责原则。 可将同一行为的不同版本移到同一个类中。
- 访问者对象可以在与各种对象交互时收集一些有用的信息。 当你想要遍历一些复杂的对象结构 (例如对象树), 并在结构中的每个对象上应用访问者时, 这些信息可能会有所帮助。
缺点
- 部分客户端可能会受到算法框架的限制。
- 通过子类抑制默认步骤实现可能会导致违反_里氏替换原则_。
- 模板方法中的步骤越多, 其维护工作就可能会越困难。
与其他模式的关系
- 你可以将访问者模式视为命令模式的加强版本, 其对象可对不同类的多种对象执行操作。
- 你可以使用访问者对整个组合模式树执行操作。
- 可以同时使用访问者和迭代器模式来遍历复杂数据结构, 并对其中的元素执行所需操作, 即使这些元素所属的类完全不同。
代码示例
访问者是一种行为设计模式, 允许你在不修改已有代码的情况下向已有类层次结构中增加新的行为。
在 C# 中使用模式
复杂度: ★★★
流行度: ★☆☆
使用示例: 访问者不是常用的设计模式, 因为它不仅复杂, 应用范围也比较狭窄。
概念示例
本例说明了访问者设计模式的结构并重点回答了下面的问题:
- 它由哪些类组成?
- 这些类扮演了哪些角色?
- 模式中的各个元素会以何种方式相互关联?
Program.cs: 概念示例
1 | using System; |
Output.txt: 执行结果
1 | The client code works with all visitors via the base Visitor interface: |
在 C++ 中使用模式
复杂度: ★★★
流行度: ★☆☆
使用示例: 访问者不是常用的设计模式, 因为它不仅复杂, 应用范围也比较狭窄。
概念示例
本例说明了访问者设计模式的结构并重点回答了下面的问题:
- 它由哪些类组成?
- 这些类扮演了哪些角色?
- 模式中的各个元素会以何种方式相互关联?
main.cc: 概念示例
1 | /** |
Output.txt: 执行结果
1 | The client code works with all visitors via the base Visitor interface: |
在 Java 中使用模式
复杂度: ★★★
流行度: ★☆☆
使用示例: 访问者不是常用的设计模式, 因为它不仅复杂, 应用范围也比较狭窄。
这里是 Java 程序库代码中该模式的一些示例:
javax.lang.model.element.AnnotationValue
和AnnotationValueVisitor
javax.lang.model.element.Element
和ElementVisitor
javax.lang.model.type.TypeMirror
和TypeVisitor
java.nio.file.FileVisitor
和SimpleFileVisitor
javax.faces.component.visit.VisitContext
和VisitCallback
将形状导出为 XML 文件
在本例中, 我们希望将一系列几何形状导出为 XML 文件。 重点在于我们不希望直接修改形状代码, 或者至少能确保最小程度的修改。
最终, 访问者模式建立了一个框架, 允许我们在不修改已有类的情况下向形状层次结构中添加新的行为。
shapes
shapes/Shape.java: 通用形状接口
1 | package refactoring_guru.visitor.example.shapes; |
shapes/Dot.java: 点
1 | package refactoring_guru.visitor.example.shapes; |
shapes/Circle.java: 圆形
1 | package refactoring_guru.visitor.example.shapes; |
shapes/Rectangle.java: 矩形
1 | package refactoring_guru.visitor.example.shapes; |
shapes/CompoundShape.java: 组合形状
1 | package refactoring_guru.visitor.example.shapes; |
visitor
visitor/Visitor.java: 通用访问者接口
1 | package refactoring_guru.visitor.example.visitor; |
visitor/XMLExportVisitor.java: 具体访问者, 将所有形状导出为 XML 文件
1 | package refactoring_guru.visitor.example.visitor; |
Demo.java: 客户端代码
1 | package refactoring_guru.visitor.example; |
OutputDemo.png: 执行结果
1 | <?xml version="1.0" encoding="utf-8"?> |
在 PHP 中使用模式
复杂度: ★★★
流行度: ★☆☆
使用示例: 访问者模式在 PHP 代码中不太常用, 因为它不仅复杂, 应用范围也比较狭窄。
概念示例
本例说明了访问者设计模式的结构并重点回答了下面的问题:
- 它由哪些类组成?
- 这些类扮演了哪些角色?
- 模式中的各个元素会以何种方式相互关联?
了解该模式的结构后, 你可以更容易地理解下面基于真实世界的 PHP 应用案例。
index.php: 概念示例
1 |
|
Output.txt: 执行结果
1 | The client code works with all visitors via the base Visitor interface: |
真实世界示例
在本例中, 访问者模式在已有类层次结构 ( “公司 > 部门 > 雇员”) 中添加了报表功能
在将访问者构架添加到程序中后, 你就能够在无需修改已有类的前提下将其他类似行为轻松添加到程序中了。
index.php: 真实世界示例
1 | <?php |
Output.txt: 执行结果
1 | Client: I can print a report for a whole company: |
在 Python 中使用模式
复杂度: ★★★
流行度: ★☆☆
使用示例: 访问者不是常用的设计模式, 因为它不仅复杂, 应用范围也比较狭窄。
概念示例
本例说明了访问者设计模式的结构并重点回答了下面的问题:
- 它由哪些类组成?
- 这些类扮演了哪些角色?
- 模式中的各个元素会以何种方式相互关联?
main.py: 概念示例
1 | from __future__ import annotations |
Output.txt: 执行结果
1 | The client code works with all visitors via the base Visitor interface: |
在 Ruby 中使用模式
复杂度: ★★★
流行度: ★☆☆
使用示例: 访问者不是常用的设计模式, 因为它不仅复杂, 应用范围也比较狭窄。
概念示例
本例说明了访问者设计模式的结构并重点回答了下面的问题:
- 它由哪些类组成?
- 这些类扮演了哪些角色?
- 模式中的各个元素会以何种方式相互关联?
main.rb: 概念示例
1 | # The Component interface declares an `accept` method that should take the base |
output.txt: 执行结果
1 | The client code works with all visitors via the base Visitor interface: |
在 Swift 中使用模式
复杂度: ★★★
流行度: ★☆☆
使用示例: 访问者不是常用的设计模式, 因为它不仅复杂, 应用范围也比较狭窄。
概念示例
本例说明了访问者设计模式的结构并重点回答了下面的问题:
- 它由哪些类组成?
- 这些类扮演了哪些角色?
- 模式中的各个元素会以何种方式相互关联?
了解该模式的结构后, 你可以更容易地理解下面基于真实世界的 Swift 应用案例。
Example.swift: 概念示例
1 | import XCTest |
Output.txt: 执行结果
1 | The client code works with all visitors via the base Visitor interface: |
真实世界示例
Example.swift: 真实世界示例
1 | import Foundation |
Output.txt: 执行结果
1 | Client: Using Default Policy Visitor and Black List Visitor |
在 TypeScript 中使用模式
复杂度: ★★★
流行度: ★☆☆
使用示例: 访问者不是常用的设计模式, 因为它不仅复杂, 应用范围也比较狭窄。
概念示例
本例说明了访问者设计模式的结构并重点回答了下面的问题:
- 它由哪些类组成?
- 这些类扮演了哪些角色?
- 模式中的各个元素会以何种方式相互关联?
index.ts: 概念示例
1 | /** |
Output.txt: 执行结果
1 | The client code works with all visitors via the base Visitor interface: |
概念示例
访问者模式允许你在结构体中添加行为, 而又不会对结构体造成实际变更。 假设你是一个代码库的维护者, 代码库中包含不同的形状结构体, 如:
- 方形
- 圆形
- 三角形
上述每个形状结构体都实现了通用形状接口。
在公司员工开始使用你维护的代码库时, 你就会被各种功能请求给淹没。 让我们来看看其中比较简单的请求: 有个团队请求你在形状结构体中添加 getArea
获取面积行为。
解决这一问题的办法有很多。
第一个选项便是将 getArea
方法直接添加至形状接口, 然后在各个形状结构体中进行实现。 这似乎是比较好的解决方案, 但其代价也比较高。 作为代码库的管理员, 相信你也不想在每次有人要求添加另外一种行为时就去冒着风险改动自己的宝贝代码。 不过, 你也一定想让其他团队的人还是用一用自己的代码库。
第二个选项是请求功能的团队自行实现行为。 然而这并不总是可行, 因为行为可能会依赖于私有代码。
第三个方法就是使用访问者模式来解决上述问题。 首先定义一个如下访问者接口:
1 | type visitor interface { |
我们可以使用 visitForSquare(square)
、 visitForCircle(circle)
以及 visitForTriangle(triangle)
函数来为方形、 圆形以及三角形添加相应的功能。
你可能在想, 为什么我们不再访问者接口里面使用单一的 visit(shape)
方法呢? 这是因为 Go 语言不支持方法重载, 所以你无法以相同名称、 不同参数的方式来使用方法。
好了, 第二项重要的工作是将 accept
接受方法添加至形状接口中。
1 | func accept(v visitor) |
所有形状结构体都需要定义此方法, 类似于:
1 | func (obj *square) accept(v visitor){ |
等等, 我刚才是不是提到过, 我们并不想修改现有的形状结构体? 很不幸, 在使用访问者模式时, 我们必须要修改形状结构体。 但这样的修改只需要进行一次。
如果添加任何其他行为, 比如 getNumSides
获取边数和 getMiddleCoordinates
获取中点坐标 , 我们将使用相同的 accept(v visitor)
函数, 而无需对形状结构体进行进一步的修改。
最后, 形状结构体只需要修改一次, 并且所有未来针对不同行为的请求都可以使用相同的 accept 函数来进行处理。 如果团队成员请求 getArea
行为, 我们只需简单地定义访问者接口的具体实现, 并在其中编写面积的计算逻辑即可。
shape.go: 元件
1 | package main |
square.go: 具体元件
1 | package main |
circle.go: 具体元件
1 | package main |
rectangle.go: 具体元件
1 | package main |
visitor.go: 访问者
1 | package main |
areaCalculator.go: 具体访问者
1 | package main |
middleCoordinates.go: 具体访问者
1 | package main |
main.go: 客户端代码
1 | package main |
output.txt: 执行结果
1 | Calculating area for square |