编程

设计模式之访问者(Vistor)模式

268 2024-02-19 22:18:00

意图

访问者(Visitor)模式是一种行为模式,允许你将算法与它们的操作对象上分离。

 

 

问题描述

假设你的团队开发了一款应用,它可以处理构造成一个巨大图形的地理信息。图中的每个节点可以代表一个复杂的实体,如城市,也可以代表更精细的东西,如工业、观光区等。如果节点所代表的真实对象之间存在道路,则这些节点与其他节点相连接。在底层中,每个节点类型都由其自己的类表示,而每个特定节点都是一个对象。

Exporting the graph into XML
Exporting the graph into XML.

在某个时刻,你得到了一个任务来实现将图形导出为 XML 格式。起初,这份工作看起来相当简单。你计划向每个节点类添加一个导出方法,然后利用递归遍历图的每个节点,执行导出方法。解决方案简单而优雅:由于多态,你没有将调用导出方法的代码耦合到具体的节点类。

不幸的是,系统架构师拒绝允许你更改现有的节点类。他说,代码已经在生产中了,他不想因为你的更改中存在潜在的错误而冒险破坏它。

此外,他还质疑在节点类中包含 XML 导出代码是否合理。这些类的主要工作是使用地理数据。XML 导出行为在那里看起来很奇怪。

拒绝还有另一个原因。很有可能,在这个功能实现后,市场部的人会要求你提供导出到不同格式的能力,或者要求一些其他奇怪的东西。这将迫使你再次改变那些珍贵而脆弱的类。

方案

访问者模式建议将新行为放在一个名为 Visitor 的单独类中,而不是试图将其集成到现有类中。现在,必须执行行为的原始对象作为参数传递给访问者的一个方法,为该方法提供对对象中包含的所有必要数据的访问权限。

现在,如果该行为可以在不同类的对象上执行呢?例如,在我们使用 XML 导出的情况下,不同节点类之间的实际实现可能有点不同。因此,访问者类可能不是定义一个,而是定义一组方法,每个方法都可以采用不同类型的参数,如下所示:

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

但是,我们应该如何准确地调用这些方法,尤其是在处理整个图时?这些方法有不同的签名,所以我们不能使用多态性。要选择一个能够处理给定对象的适当访问者方法,我们需要检查它的类。这听起来不是一场噩梦吗?

foreach (Node node in graph)
    if (node instanceof City)
        exportVisitor.doForCity((City) node)
    if (node instanceof Industry)
        exportVisitor.doForIndustry((Industry) node)
    // ...
}

你可能会问,为什么我们不使用方法重载?也就是说,即使所有方法支持不同的参数集,也要为它们指定相同的名称。不幸的是,即使假设我们的编程语言完全支持重载(就像 Java 和 C# 一样),它也对我们没有帮助。由于节点对象的具体类事先是未知的,重载机制将无法确定执行的正确方法。它将默认为采用基类 Node 的对象的方法。

但是,访问者模式解决了这个问题。它使用了一种名为双分派(Double Dispatch)的技术,这有助于在对象上执行正确的方法,而不需要繁琐的条件。与其让客户端选择要调用的方法的正确版本,不如将此选择委托给作为参数传递给访问者的对象?由于对象知晓它们自己的类,所以它们能够在访问者身上不那么笨拙地选择一个合适的方法。他们“接受”访问者,并告诉它应该执行什么访问方法。

// Client code
foreach (Node node in graph)
    node.accept(exportVisitor)
// City
class City is
    method accept(Visitor v) is
        v.doForCity(this)
    // ...
// Industry
class Industry is
    method accept(Visitor v) is
        v.doForIndustry(this)
    // ...

我承认。最终我们不得不修改节点类。但毕竟改动很小,它使我们可以在不再次修改代码的情况下添加更多的行为。

现在,如果我们为所有访问者提取一个通用接口,所有现有节点都可以与你引入应用的任何访问者一起工作。如果你发现自己引入了一个与节点相关的新行为,那你所要做的就是实现一个新的访问者类

真实世界类比

Insurance agent
A good insurance agent is always ready to offer different policies to various types of organizations.

假设有一个经验丰富的保险代理人渴望获得新客户。他可以访问附近的每一栋楼,试图向他遇到的每一个人推销保险。根据大楼中的组织类型,他可以提供专门的保单:

  • 如果是住宅楼,则推销医疗保险。
  • 如果是银行,则推销盗窃保险。
  • 如果是咖啡店,则推销火灾险和洪水保险。

结构

访问者(Vistor)接口声明一套访问方法,这些方法可以将对象结构的具体元素作为参数。如果程序是用支持重载的语言编写的,则这些方法可能具有相同的名称,但它们的参数类型必须不同。

每个具体访问者(Concrete Visitor)都实现了相同行为的多个版本,这些版本是为不同的具体元素类量身定制的。

元素(Element)接口声明了一个用于“接受”访问者的方法。这个方法应该有一个用访问者接口的类型声明的参数.

每个具体元素(Concrete Element)必须执行接收方法。此方法的目的是将调用重定向到与当前元素类对应的适当访问者的方法。请注意,即使基本元素类实现了此方法,所有子类仍必须在其自己的类中重写此方法,并在访问者对象上调用适当的方法。

客户端(Client)通常表示一个集合或其他一些复杂的对象(例如,组合(Composite)树)。通常,客户端不知道所有具体的元素类,因为它们通过一些抽象接口处理该集合中的对象。

伪代码

本例中,访问者(Visitor)模式将 XML 支持添加到几何形状(geometric shape)类的层次结构中。

Structure of the Visitor pattern example
Exporting various types of objects into XML format via a visitor object.
// The element interface declares an `accept` method that takes
// the base visitor interface as an argument.
interface Shape is
    method move(x, y)
    method draw()
    method accept(v: Visitor)
// Each concrete element class must implement the `accept`
// method in such a way that it calls the visitor's method that
// corresponds to the element's class.
class Dot implements Shape is
    // ...
    // Note that we're calling `visitDot`, which matches the
    // current class name. This way we let the visitor know the
    // class of the element it works with.
    method accept(v: Visitor) is
        v.visitDot(this)
class Circle implements Shape is
    // ...
    method accept(v: Visitor) is
        v.visitCircle(this)
class Rectangle implements Shape is
    // ...
    method accept(v: Visitor) is
        v.visitRectangle(this)
class CompoundShape implements Shape is
    // ...
    method accept(v: Visitor) is
        v.visitCompoundShape(this)
// The Visitor interface declares a set of visiting methods that
// correspond to element classes. The signature of a visiting
// method lets the visitor identify the exact class of the
// element that it's dealing with.
interface Visitor is
    method visitDot(d: Dot)
    method visitCircle(c: Circle)
    method visitRectangle(r: Rectangle)
    method visitCompoundShape(cs: CompoundShape)
// Concrete visitors implement several versions of the same
// algorithm, which can work with all concrete element classes.
//
// You can experience the biggest benefit of the Visitor pattern
// when using it with a complex object structure such as a
// Composite tree. In this case, it might be helpful to store
// some intermediate state of the algorithm while executing the
// visitor's methods over various objects of the structure.
class XMLExportVisitor implements Visitor is
    method visitDot(d: Dot) is
        // Export the dot's ID and center coordinates.
    method visitCircle(c: Circle) is
        // Export the circle's ID, center coordinates and
        // radius.
    method visitRectangle(r: Rectangle) is
        // Export the rectangle's ID, left-top coordinates,
        // width and height.
    method visitCompoundShape(cs: CompoundShape) is
        // Export the shape's ID as well as the list of its
        // children's IDs.
// The client code can run visitor operations over any set of
// elements without figuring out their concrete classes. The
// accept operation directs a call to the appropriate operation
// in the visitor object.
class Application is
    field allShapes: array of Shapes
    method export() is
        exportVisitor = new XMLExportVisitor()
        foreach (shape in allShapes) do
            shape.accept(exportVisitor)

如果你好奇,本例为什么需要 accept,《访问者及双分派》一文详细解释了这个问题。

适用

当需要对复杂对象结构(例如,对象树)的所有元素执行操作时,请使用访问者模式。

访问者模式允许你通过让一个访问者对象实现同一操作的几个变体来对具有不同类的一组对象执行操作,这些变体对应于所有目标类。

可使用访问者清理辅助行为的业务逻辑。

该模式允许将所有非主要的行为抽取到一套访问者类中, 使得程序的主要类能更专注于主要的工作。

当行为仅在类层次结构的某些类中有意义,而在其他类中没有意义时,请使用该模式。

你可将该行为抽取到单独的访问者类中, 并实现接收相关类的对象作为参数的访问者方法并将其他方法留空即可。

如何实现

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

优缺点

  • ✔️开闭原则。 你可以引入一种新的行为,该行为可以在不更改这些类的情况下处理不同类的对象。
  • ✔️单一职责原则。你可以将同一个行为的多种版本移动到同一个类中。
  • ✔️访问者对象可以在与各种对象交互时收集一些有用的信息。 当你要遍历一些复杂的对象结构(例如对象树),并在结构中的每个对象上应用访问者时,这些信息可能会有所帮助。
  • ❌每次在元素层次结构中添加或移除一个类时,你都要更新所有的访问者。
  • ❌在访问者同某个元素进行交互时,它们可能没有访问元素私有成员变量和方法

与其他模式的关联

可以将访问者模式当作命令模式的增强版本。其对象可以执行多个不同类对象中中的操作。

可以使用访问者模式对整个组合模式树执行操作

可以使用访问者模式迭代模式来遍历复杂的数据结构,并对其元素执行一些操作,即使它们都有不同的类。

index.php: 概念示例

<?php

namespace RefactoringGuru\Visitor\Conceptual;

/**
 * The Component interface declares an `accept` method that should take the base
 * visitor interface as an argument.
 */
interface Component
{
    public function accept(Visitor $visitor): void;
}

/**
 * Each Concrete Component must implement the `accept` method in such a way that
 * it calls the visitor's method corresponding to the component's class.
 */
class ConcreteComponentA implements Component
{
    /**
     * Note that we're calling `visitConcreteComponentA`, which matches the
     * current class name. This way we let the visitor know the class of the
     * component it works with.
     */
    public function accept(Visitor $visitor): void
    {
        $visitor->visitConcreteComponentA($this);
    }

    /**
     * Concrete Components may have special methods that don't exist in their
     * base class or interface. The Visitor is still able to use these methods
     * since it's aware of the component's concrete class.
     */
    public function exclusiveMethodOfConcreteComponentA(): string
    {
        return "A";
    }
}

class ConcreteComponentB implements Component
{
    /**
     * Same here: visitConcreteComponentB => ConcreteComponentB
     */
    public function accept(Visitor $visitor): void
    {
        $visitor->visitConcreteComponentB($this);
    }

    public function specialMethodOfConcreteComponentB(): string
    {
        return "B";
    }
}

/**
 * The Visitor Interface declares a set of visiting methods that correspond to
 * component classes. The signature of a visiting method allows the visitor to
 * identify the exact class of the component that it's dealing with.
 */
interface Visitor
{
    public function visitConcreteComponentA(ConcreteComponentA $element): void;

    public function visitConcreteComponentB(ConcreteComponentB $element): void;
}

/**
 * Concrete Visitors implement several versions of the same algorithm, which can
 * work with all concrete component classes.
 *
 * You can experience the biggest benefit of the Visitor pattern when using it
 * with a complex object structure, such as a Composite tree. In this case, it
 * might be helpful to store some intermediate state of the algorithm while
 * executing visitor's methods over various objects of the structure.
 */
class ConcreteVisitor1 implements Visitor
{
    public function visitConcreteComponentA(ConcreteComponentA $element): void
    {
        echo $element->exclusiveMethodOfConcreteComponentA() . " + ConcreteVisitor1\n";
    }

    public function visitConcreteComponentB(ConcreteComponentB $element): void
    {
        echo $element->specialMethodOfConcreteComponentB() . " + ConcreteVisitor1\n";
    }
}

class ConcreteVisitor2 implements Visitor
{
    public function visitConcreteComponentA(ConcreteComponentA $element): void
    {
        echo $element->exclusiveMethodOfConcreteComponentA() . " + ConcreteVisitor2\n";
    }

    public function visitConcreteComponentB(ConcreteComponentB $element): void
    {
        echo $element->specialMethodOfConcreteComponentB() . " + ConcreteVisitor2\n";
    }
}

/**
 * The client code can run visitor operations over any set of elements without
 * figuring out their concrete classes. The accept operation directs a call to
 * the appropriate operation in the visitor object.
 */
function clientCode(array $components, Visitor $visitor)
{
    // ...
    foreach ($components as $component) {
        $component->accept($visitor);
    }
    // ...
}

$components = [
    new ConcreteComponentA(),
    new ConcreteComponentB(),
];

echo "The client code works with all visitors via the base Visitor interface:\n";
$visitor1 = new ConcreteVisitor1();
clientCode($components, $visitor1);
echo "\n";

echo "It allows the same client code to work with different types of visitors:\n";
$visitor2 = new ConcreteVisitor2();
clientCode($components, $visitor2);

Output.txt: 执行结果

The client code works with all visitors via the base Visitor interface:
A + ConcreteVisitor1
B + ConcreteVisitor1

It allows the same client code to work with different types of visitors:
A + ConcreteVisitor2
B + ConcreteVisitor2