对访问者设计模式感到困惑

Confused about the Visitor Design Pattern

所以,我刚刚阅读了关于访问者模式的内容,我发现访问者和元素之间的来回非常奇怪!

基本上我们调用元素,将其传递给访问者,然后元素将自身传递给访问者。然后访问者操作该元素。什么?为什么?感觉太没必要了。我称之为“来回疯狂”。

因此,访问者的目的是在需要跨所有元素实施相同操作时将元素与其操作分离。这样做是为了防止我们需要使用新操作扩展我们的元素,我们不想进入所有这些 类 并修改已经稳定的代码。所以我们在这里遵循 Open/Closed 原则。

为什么要来来回回?如果没有这个,我们会失去什么?

例如,我编写这段代码时牢记这一目的,但跳过了访问者模式的疯狂交互。基本上我有会跳会吃的动物。我想将这些操作与对象分离,因此我将操作移至访问者。进食和跳跃增加动物健康(我知道,这是一个非常愚蠢的例子......)

public interface AnimalAction { // Abstract Visitor
    public void visit(Dog dog);
    public void visit(Cat cat);
}

public class EatVisitor implements AnimalAction { // ConcreteVisitor
    @Override
    public void visit(Dog dog) {
        // Eating increases the dog health by 100
        dog.increaseHealth(100);
    }

    @Override
    public void visit(Cat cat) {
        // Eating increases the cat health by 50
        cat.increaseHealth(50);
    }
}

public class JumpVisitor implements AnimalAction { // ConcreteVisitor
    public void visit(Dog dog) {
        // Jumping increases the dog health by 10
        dog.increaseHealth(10);
    }

    public void visit(Cat cat) {
        // Jumping increases the cat health by 20
        cat.increaseHealth(20);
    }
}

public class Cat { // ConcreteElement
    private int health;

    public Cat() {
        this.health = 50;
    }

    public void increaseHealth(int healthIncrement) {
        this.health += healthIncrement;
    }

    public int getHealth() {
        return health;
    }
}

public class Dog { // ConcreteElement

    private int health;

    public Dog() {
        this.health = 10;
    }

    public void increaseHealth(int healthIncrement) {
        this.health += healthIncrement;
    }

    public int getHealth() {
        return health;
    }
}

public class Main {

    public static void main(String[] args) {
        AnimalAction jumpAction = new JumpVisitor();
        AnimalAction eatAction = new EatVisitor();

        Dog dog = new Dog();
        Cat cat = new Cat();

        jumpAction.visit(dog); // NOTE HERE. NOT DOING THE BACK AND FORTH MADNESS.
        eatAction.visit(dog);
        System.out.println(dog.getHealth());

        jumpAction.visit(cat);
        eatAction.visit(cat);
        System.out.println(cat.getHealth());
    }
}

OP 中的代码类似于众所周知的访问者设计模式变体,称为 内部访问者 (参见 Extensibility for the Masses。实用Extensibility with Object Algebras,Bruno C. d. S. Oliveira 和 William R. Cook)。然而,该变体使用泛型和 return 值(而不是 void)来解决访问者模式解决的一些问题。

这是什么问题,为什么 OP 变化可能不足?

访问者模式解决的主要问题是,当您有 异构对象 时,您需要对其进行相同的处理。正如 四人帮Design Patterns 的作者)所述,您在

时使用模式

"an object structure contains many classes of objects with differing interfaces, and you want to perform operations on these objects that depend on their concrete classes."

这句话中缺少的是,虽然您希望“对这些依赖于它们的具体 classes 的对象执行操作”,但您希望将这些具体 classes 视为尽管它们只有一个多态类型。

一个时期的例子

使用 animal 域很少说明(我稍后会讲到),所以这是另一个更现实的例子。例子在 C# 中 - 我希望它们对你仍然有用。

假设您正在开发一个在线餐厅预订系统。作为该系统的一部分,您需要能够向用户显示日历。此日历可以显示给定日期的剩余座位数,或列出当天的所有预订。

有时,您希望显示一天,但有时,您希望将整个月显示为单个日历对象。投入一整年的时间来衡量。这意味着您有三个 周期:年。每个都有不同的接口:

public Year(int year)

public Month(int year, int month)

public Day(int year, int month, int day)

为简洁起见,这些只是三个独立 classes 的构造函数。许多人可能只是将其建模为具有可为空字段的单个 class,但这会迫使您处理空字段、枚举或其他类型的问题。

以上三个 class 结构不同,因为它们包含不同的数据,但您想将它们视为一个概念 - 句点

为此,定义一个 IPeriod 接口:

internal interface IPeriod
{
    T Accept<T>(IPeriodVisitor<T> visitor);
}

并使每个 class 实现接口。这里是 Month:

internal sealed class Month : IPeriod
{
    private readonly int year;
    private readonly int month;

    public Month(int year, int month)
    {
        this.year = year;
        this.month = month;
    }

    public T Accept<T>(IPeriodVisitor<T> visitor)
    {
        return visitor.VisitMonth(year, month);
    }
}

这使您能够将三个异构 classes 视为单一类型,并在该单一类型上定义操作而无需更改接口。

例如,这里是计算期的实现:

private class PreviousPeriodVisitor : IPeriodVisitor<IPeriod>
{
    public IPeriod VisitYear(int year)
    {
        var date = new DateTime(year, 1, 1);
        var previous = date.AddYears(-1);
        return Period.Year(previous.Year);
    }

    public IPeriod VisitMonth(int year, int month)
    {
        var date = new DateTime(year, month, 1);
        var previous = date.AddMonths(-1);
        return Period.Month(previous.Year, previous.Month);
    }

    public IPeriod VisitDay(int year, int month, int day)
    {
        var date = new DateTime(year, month, day);
        var previous = date.AddDays(-1);
        return Period.Day(previous.Year, previous.Month, previous.Day);
    }
}

如果你有Day,你会得到前一个Day,但如果你有Month,你会得到前一个Month,等等。

您可以在 this article 中看到 PreviousPeriodVisitor class 和其他正在使用的访问者,但这里是使用它们的几行代码:

var previous = period.Accept(new PreviousPeriodVisitor());
var next = period.Accept(new NextPeriodVisitor());

dto.Links = new[]
{
    url.LinkToPeriod(previous, "previous"),
    url.LinkToPeriod(next, "next")
};

这里,period是一个IPeriod对象,但是代码不知道它是Day,还是Month,还是Year.

为了清楚起见,上面的示例使用了内部访客变体,即 isomorphic to a Church encoding

动物

用动物来理解面向对象编程很少有启发性。我认为学校应该停止使用该示例,因为它更可能造成混淆而不是帮助。

OP 代码示例没有遇到访问者模式解决的问题,因此在这种情况下,如果您看不到好处也就不足为奇了。

CatDog class 是 非异构的。 它们具有相同的 class 字段和相同的行为。唯一的区别在于构造函数。您可以轻松地将这两个 class 重构为一个 Animal class:

public class Animal {
    private int health;

    public Animal(int health) {
        this.health = health;
    }

    public void increaseHealth(int healthIncrement) {
        this.health += healthIncrement;
    }

    public int getHealth() {
        return health;
    }
}

然后使用两个不同的 health 值定义猫和狗的两种创建方法。

由于您现在只有一个 class,因此不需要访问者。

BACK-AND-FORTH 是这个意思吗?

public class Dog implements Animal {

    //...

    @Override
    public void accept(AnimalAction action) {
        action.visit(this);
    }
}

此代码的目的是您可以在不知道具体类型的情况下分派该类型,如下所示:

public class Main {

    public static void main(String[] args) {
        AnimalAction jumpAction = new JumpVisitor();
        AnimalAction eatAction = new EatVisitor();


        Animal animal = aFunctionThatCouldReturnAnyAnimal();
        animal.accept(jumpAction);
        animal.accept(eatAction);
    }

    private static Animal aFunctionThatCouldReturnAnyAnimal() {
        return new Dog();
    }
}

所以你得到的是:你可以在只知道动物是动物的情况下对动物调用正确的个体动作。

如果您遍历复合模式,这将特别有用,其中叶节点是 Animal,内部节点是 Animals 的聚合(例如 List)。 List<Animal> 您的设计无法处理。

Visitor中的来回是模拟一种double dispatch机制,其中你select一个方法实现基于运行时间类型两个个对象。

如果您的动物 和 访客的类型都是抽象的(或多态的),这将很有用。在这种情况下,您可能有 2 x 2 = 4 种方法实现可供选择,具体取决于 a) 您想要执行哪种操作(访问),以及 b) 您希望将此操作应用于哪种类型的动物。

如果您使用的是具体类型和非多态类型,那么这种来回的部分操作确实是多余的。

访问者模式解决了将函数应用于图结构元素的问题。

更具体地说,它解决了在某个对象 V 的上下文中访问某个图结构中的每个节点 N 以及针对每个 N 调用某个通用函数 F(V, N) 的问题。 F的方法实现是根据V和N的类型来选择的。

在具有多重分派的编程语言中,访问者模式几乎消失了。它简化为图形对象的遍历(例如递归树下降),这对每个 N 节点进行简单的 F(V, N) 调用。完成!

例如在 Common Lisp 中。为了简洁起见,我们甚至不定义 classes:integersstrings 是 classes,所以让我们使用它们。

首先,让我们编写泛型函数的四个方法,用于访问整数或字符串的整数或字符串的每个组合。这些方法只产生输出。我们没有用 defgeneric 定义泛型函数; Lisp 推断并隐式地为我们做:

(defmethod visit ((visitor integer) (node string))
  (format t "integer ~s visits string ~s!~%" visitor node))

(defmethod visit ((visitor integer) (node integer))
  (format t "integer ~s visits integer ~s!~%" visitor node))

(defmethod visit ((visitor string) (node string))
  (format t "string ~s visits string ~s!~%" visitor node))

(defmethod visit ((visitor string) (node integer))
  (format t "string ~s visits integer ~s!~%" visitor node))

现在让我们使用一个列表作为访问者迭代的结构,并为此编写一个包装函数:

(defun visitor-pattern (visitor list)
  ;; map over the list, doing the visitation
  (mapc (lambda (item) (visit visitor item)) list)
  ;; return  nothing
  (values))

交互式测试:

(visitor-pattern 42 '(1 "abc"))
integer 42 visits integer 1!
integer 42 visits string "abc"!

(visitor-pattern "foo" '(1 "abc"))
string "foo" visits integer 1!
string "foo" visits string "abc"!

好的,这就是访问者模式:遍历结构中的每个元素,并使用访问上下文对象对方法进行双重分派。

“来回疯狂”与在只有单一调度的 OOP 系统中模拟双重调度的样板代码有关,其中方法属于 classes 而不是通用函数的特化。

因为在主流的 single-dispatch OOP 系统中,方法被封装在 classes 中,我们面临的第一个问题是 visit 方法在哪里?是在访客上还是在节点上?

答案原来是两者兼而有之。我们需要在这两种类型上发送一些内容。

接下来的问题是在OOP实践中,我们需要好的命名。我们不能在 visitorvisited 对象上同时使用 visit 方法。当访问一个被访问的对象时,“访问”动词不用于描述该对象正在做什么。它“接受”来访者。所以我们必须调用那一半的动作 accept.

我们创建了一个结构,每个要访问的节点都有一个 accept 方法。此方法根据节点类型进行调度,并采用 Visitor 参数。事实上,该节点有多个 accept 方法,这些方法 静态地 专门针对不同类型的访问者:IntegerVisitorStringVisitorFooVisitor.注意我们不能只使用String,即使我们在语言中有这样一个class,因为它没有实现Visitor接口和visit方法.

那么我们遍历结构,获取每个节点 N,然后调用 V.visit(N) 让访问者访问它。我们不知道 V 的确切类型;这是一个基本参考。每个 Visitor 实现必须将 visit 作为样板实现(使用不是 Java 或 C++ 的伪语言):

StringVisitor::visit(Visited obj)
{
  obj.Accept(self)
}

IntegerVisitor::visit(Visited obj)
{
  obj.Accept(self)
}

原因是 self 必须为 Accept 调用静态类型化,因为 Visited 对象针对编译时选择的不同类型有多个 Accept 实现时间:

IntegerNode::visit(StringVisitor v)
{
   print(`integer @{self.value} visits string @{v.value}`)
}

IntegerNode::visit(IntegerVisitor v)
{
   print(`integer @{self.value} visits string @{v.value}`)
}

所有这些 classes 和方法都必须在某处声明:

class VisitorBase {
  virtual void Visit(VisitedBase);
}

class IntegerVisitor;
class StringVisitor;

class VisitedBase {
  virtual void Accept(IntegerVisitor);
  virtual void Accept(StringVisitor);
}

class IntegerVisitor : inherit VisitorBase {
  Integer value;
  void Visit(VisitedBase);
}

class StringVisitor: inherit VisitorBase {
  String value;
  void Visit(VisitedBase);
}

class IntegerNode : inherit VisitedBase {
  Integer value;
  void Accept(IntegerVisitor);
  void Accept(StringVisitor);
}

class StringNode : inherit VisitedBase {
  String value;
  void Accept(IntegerVisitor);
  void Accept(StringVisitor);
}

这就是静态重载的单一调度访问者模式:有一堆样板,加上 classes 之一的限制,无论是访问者还是被访问者,都必须知道所有其他受支持的静态类型,因此它可以在其上静态分派,并且对于每个静态类型,也会有一个虚拟方法。