访问者模式是一种行为设计模式,它能将算法与其所作用的对象隔离开。

访问者模式建议将新行为放入一个名为访问者的独立类中,而不是试图将其整合到已有类中。

背景

假如团队开发了一款能够使用巨型图像中地理信息的应用程序。

  • 图像中的每个节点既能代表复杂实体(例如一座城市),也能代表更精细的对象(例如工业区和旅游景点等)。
  • 在程序内部,每个节点的类型都由其所属的类来表示,每个特定的节点则是一个对象。

一段时间后,你接到了实现将图像导出到 XML 文件中的任务。

  • 这些工作最初看上去非常简单。你计划为每个节点类添加导出函数,然后递归执行图像中每个节点的导出函数。
  • 解决方案简单且优雅: 使用多态机制可以让导出方法的调用代码不会和具体的节点类相耦合。

但系统架构师拒绝批准对已有节点类进行修改。

  • 他认为这些 老代码已经是产品了,不想冒险对其进行修改,因为修改可能会引入潜在的缺陷
  • 此外, 他还质疑在节点类中包含导出XML文件的代码是否有意义。这些类的主要工作是处理地理数据。导出XML文件的代码放在这里并不合适
  • 还有另一个原因,那就是在此项任务完成后,营销部门很有可能会要求程序提供导出其他类型文件的功能,或者提出其他奇怪的要求。这样你很可能会被迫再次修改这些重要但脆弱的类。

解决方案

访问者模式建议将新行为放入一个名为访问者的独立类中,而不是试图将其整合到已有类中。 把需要执行操作的原始对象作为参数传递给访问者中的方法,让方法能访问对象所包含的一切必要数据。

如果现在该操作能在不同类的对象上执行会怎么样呢?

在我们的示例中,各节点类导出XML文件的实际实现很可能会稍有不同。 因此,访问者类可以定义一组(而不是一个)方法,且每个方法可接收不同类型的参数,如下所示:

1
2
3
4
5
class ExportVisitor implements Visitor is
    method doForCity(City c) { ... }
    method doForIndustry(Industry f) { ... }
    method doForSightSeeing(SightSeeing ss) { ... }
    // ...

但我们究竟应该如何调用这些方法(尤其是在处理整个图像方面)呢?这些方法的签名各不相同,因此我们不能使用多态机制。

为了可以挑选出能够处理特定对象的访问者方法,我们需要对它的类进行检查。

1
2
3
4
5
6
7
foreach (Node node in graph)
    if (node instanceof City)
        exportVisitor.doForCity((City) node)
    if (node instanceof Industry)
        exportVisitor.doForIndustry((Industry) node)
    // ...
}

你可能会问,我们为什么不使用方法重载呢?就是使用相同的方法名称,但它们的参数类型不同,因为不同node的实际类型不同

  • 不幸的是,即使编程语言(例如 Java 和 C#)支持重载也不行。
  • 由于我们无法提前知晓节点对象所属的类,所以重载机制无法执行正确的方法。方法会将节点基类作为输入参数的类型
    • 即,传参时还是不知道当前node的子类具体类型,只知道它的父类/接口类型,无法进行重载的函数调用

访问者模式可以解决这个问题。

它使用了一种名为双分派的技巧,不使用累赘的条件语句也可以执行正确的方法。

  • 与其让客户端来选择调用正确版本的方法,不如将选择权委派给各种对象。
  • 由于对象知晓其自身的类,因此能更自然地从访问者中选出正确的方法。
  • 它们会 “接收” 一个访问者并调用应执行的访问者方法。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 客户端代码
foreach (Node node in graph)
    node.accept(exportVisitor)

// 城市
class City is
    method accept(Visitor v) is
        v.doForCity(this)
    // ...

// 工业区
class Industry is
    method accept(Visitor v) is
        v.doForIndustry(this)
    // ...

最终还是修改了节点类,但毕竟改动很小,且我们能够在后续进一步添加行为时无需再次修改节点类代码。

现在,如果我们抽取出所有访问者的通用接口,所有已有的节点都能与我们在程序中引入的任何访问者交互。

  • 如果需要引入与节点相关的某个行为,你只需要实现一个新的访问者类即可。

访问者模式

应用场景

  • 如果需要对一个复杂对象结构(例如对象树)中的所有元素执行某些操作,可使用访问者模式。
    • 访问者模式通过在访问者对象中为多个目标类提供相同操作的变体,让你能在属于不同类的一组对象上执行同一操作。
  • 可使用访问者模式来清理辅助行为的业务逻辑。
    • 该模式会将所有非主要的行为抽取到一组访问者类中,使得程序的主要类能更专注于主要的工作。
  • 当某个行为仅在类层次结构中的一些类中有意义,而在其他类中没有意义时,可使用该模式。
    • 可将该行为抽取到单独的访问者类中,只需实现接收相关类的对象作为参数的访问者方法并将其他方法留空即可。

实现步骤

  1. 在访问者接口中声明一组 “访问” 方法,分别对应程序中的每个具体元素类。
  2. 声明元素接口。如果程序中已有元素类层次接口,可在层次结构基类中添加抽象的 “接收” 方法。
    • 该方法必须接受访问者对象作为参数。
  3. 在所有具体元素类中实现接收方法。
    • 这些方法必须将调用重定向到当前元素对应的访问者对象中的访问者方法上。
  4. 元素类只能通过访问者接口与访问者进行交互。
    • 不过访问者必须知晓所有的具体元素类,因为这些类在访问者方法中都被作为参数类型引用。
  5. 为每个无法在元素层次结构中实现的行为创建一个具体访问者类并实现所有的访问者方法。
    • 可能会遇到访问者需要访问元素类的部分私有成员变量的情况。
    • 在这种情况下,要么将这些变量或方法设为公有,这将破坏元素的封装;
    • 要么将访问者类嵌入到元素类中。后一种方式只有在支持嵌套类的编程语言中才可能实现。
    • 如果访问方法需要返回值,可以将返回值保存到访问者类的属性中。
  6. 客户端必须创建访问者对象并通过 “接收” 方法将其传递给元素。

优缺点

优点

  • 开闭原则。你可以引入在不同类对象上执行的新行为,且无需对这些类做出修改。
  • 单一职责原则。可将同一行为的不同版本移到同一个类中。
  • 访问者对象可以在与各种对象交互时收集一些有用的信息。当你想要遍历一些复杂的对象结构(例如对象树),并在结构中的每个对象上应用访问者时,这些信息可能会有所帮助。

缺点

  • 每次在元素层次结构中添加或移除一个类时,你都要更新所有的访问者,因为访问者中定义了针对这个类的访问逻辑。
  • 在访问者同某个元素进行交互时,它们可能没有访问元素私有成员变量和方法的必要权限。

与其它模式的关系

  • 可以将访问者模式视为命令模式的加强版本,其对象可对不同类的多种对象执行操作。
  • 可以使用访问者对整个组合模式树执行操作。
  • 可以同时使用访问者和迭代器模式来遍历复杂数据结构,并对其中的元素执行所需操作,即使这些元素所属的类完全不同。

示例

访问者模式允许在结构体中添加行为,而又不会对结构体造成实际变更。 假设你是一个代码库的维护者,代码库中包含不同的形状结构体,如:

  • 方形
  • 圆形
  • 三角形

上述每个形状结构体都实现了通用形状接口。

有个团队请求你在形状结构体中添加getArea获取面积行为。

解决这一问题的办法有很多

  • 将 getArea方法直接添加至形状接口,然后在各个形状结构体中进行实现。这似乎是比较好的解决方案,但其代价也比较高。作为代码库的管理员,相信你也不想在每次有人要求添加另外一种行为时就去冒着风险改动自己的宝贝代码
  • 请求功能的团队自行实现行为。但这并不总是可行,因为行为可能会依赖于私有代码。
  • 使用访问者模式来解决上述问题。

首先定义一个如下访问者接口:

1
2
3
4
5
type visitor interface {
    visitForSquare(square)
    visitForCircle(circle)
    visitForTriangle(triangle)
}

我们可以使用 visitForSquare(square) 、visitForCircle(circle)以及 visitForTriangle(triangle)函数来为方形、圆形以及三角形添加相应的功能。

Go 语言不支持方法重载,所以你无法以相同名称、不同参数的方式来使用方法。

第二项重要的工作是将 accept接受方法添加至形状接口中。

1
func accept(v visitor)

所有形状结构体都需要定义此方法,类似于:

1
2
3
func (obj *square) accept(v visitor){
    v.visitForSquare(obj)
}

在使用访问者模式时,我们必须要修改形状结构体代码。但这样的修改只需要进行一次

如果添加任何其他行为,比如 getNumSides获取边数和 getMiddleCoordinates获取中点坐标 ,我们将使用相同的 accept(v visitor)函数,传入新的访问者对象,而无需对形状结构体进行进一步的修改。

最后,形状结构体只需要修改一次,并且所有未来针对不同行为的请求都可以使用相同的 accept 函数来进行处理。如果团队成员请求 getArea行为,我们只需简单地定义访问者接口的具体实现,并在其中编写面积的计算逻辑即可。

形状类

1
2
3
4
5
6
package main

type shape interface {
	getType() string
	accept(visitor)
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package main

// circle 圆形
type circle struct {
	radius int
}

func (c *circle) accept(v visitor) {
	v.visitForCircle(c)
}

func (c *circle) getType() string {
	return "Circle"
}

// square 方形
type square struct {
	side int
}

func (s *square) accept(v visitor) {
	v.visitForSquare(s)
}

func (s *square) getType() string {
	return "Square"
}

// rectangle 矩形
type rectangle struct {
	l int
	b int
}

func (t *rectangle) accept(v visitor) {
	v.visitForRectangle(t)
}

func (t *rectangle) getType() string {
	return "rectangle"
}

访问者类

1
2
3
4
5
6
7
8
package main

type visitor interface {
	visitForSquare(*square)
	visitForCircle(*circle)
	visitForRectangle(*rectangle)
}

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import "fmt"

// areaCalculator 面积计算
type areaCalculator struct {
	// 将返回值保存到属性中
	area int 
}

func (a *areaCalculator) visitForSquare(s *square) {
	// Calculate area for square.
	// Then assign in to the area instance variable.
	fmt.Println("Calculating area for square")
}

func (a *areaCalculator) visitForCircle(s *circle) {
	fmt.Println("Calculating area for circle")
}
func (a *areaCalculator) visitForRectangle(s *rectangle) {
	fmt.Println("Calculating area for rectangle")
}

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import "fmt"

// middleCoordinates 中点计算
type middleCoordinates struct {
	// 将返回值保存到属性中
	x int
	y int
}

func (a *middleCoordinates) visitForSquare(s *square) {
	// Calculate middle point coordinates for square.
	// Then assign in to the x and y instance variable.
	fmt.Println("Calculating middle point coordinates for square")
}

func (a *middleCoordinates) visitForCircle(c *circle) {
	fmt.Println("Calculating middle point coordinates for circle")
}
func (a *middleCoordinates) visitForRectangle(t *rectangle) {
	fmt.Println("Calculating middle point coordinates for rectangle")
}

客户端

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

func main() {
	// 元素
	square := &square{side: 2}
	circle := &circle{radius: 3}
	rectangle := &rectangle{l: 2, b: 3}

	// 面积计算访问者
	areaCalculator := &areaCalculator{}
	// 应用访问者
	square.accept(areaCalculator)
	circle.accept(areaCalculator)
	rectangle.accept(areaCalculator)

	// 中点计算访问者
	middleCoordinates := &middleCoordinates{}
	// 应用访问者
	square.accept(middleCoordinates)
	circle.accept(middleCoordinates)
	rectangle.accept(middleCoordinates)
}

References

guru