访问者设计模式让您感到困惑?

41

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

基本上我们调用元素,将它传递给访问者,然后元素将自己传递给访问者。然后访问者操作元素。为什么?这感觉很不必要。我称之为“来回疯狂”。

因此,访问者的意图是在需要在所有元素中实现相同操作时将元素与其操作解耦。这样做是因为如果我们需要使用新操作扩展元素,我们不希望进入所有那些类并修改已经稳定的代码。所以我们在这里遵循开放/封闭原则。

为什么会有这种来回操作,如果没有这个会失去什么?

例如,我编写了这个代码以考虑到这个目的,但跳过了访问者模式的交互困扰。基本上,我有可以跳跃和吃东西的动物。我想将这些动作从对象中解耦,所以我将这些操作移到访问者中。吃和跳跃会增加动物的健康值(我知道,这是一个非常愚蠢的例子……)

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());
    }
}


2
在我看来,当与访问者进行推理时,我通常使用树形模式,其中您具有不同类型的"节点"(例如,考虑AST,其中一些节点表示运算符,而其他操作数等,则将1+1=2表示为Equals(Plus(Literal(1), Literal(1)), Literal(2)))。显然,Equals/ Plus等是通用的,因为表达式不必是简单文字,现在考虑如何编写一个访问者来计算此表达式......然后编写一些代码来解析表达式的文本并对其进行评估,现在您在编译时没有任何具体已知类。 - GACy20
1
@GACy20 你说得对。这段代码之所以能够工作,是因为我已经知道狗是Dog,猫是Cat。感谢你提供的例子,它让访问者更容易理解。 - AFP_555
3
如果你把与“dog”一起工作的代码和与“cat”一起工作的代码放入一个列表中并使用循环,那会发生什么呢?尽管它们看起来相同。 - Ben Voigt
我认为Shvets很好地解释了访问者模式;当您需要扩展现有类以实现某些常见行为时,您可以使用它 - 由于这一点,您需要使用“双重分派”- https://sourcemaking.com/design_patterns/visitor当您没有处理该约束时,通常有更简单的方法来实现相同的目标。 - Den-Jason
1
请参考我在这里的回答(https://softwareengineering.stackexchange.com/a/412392/275536),它可能会给您带来更深入的见解。 - Filip Milovanović
显示剩余2条评论
5个回答

39

这个问题中的OP代码类似于Visitor设计模式的一种广为人知的变化形式,称为内部访问者(例如参见Bruno C. d. S. Oliveira和William R. Cook的面向大众的可扩展性。使用对象代数实现实用的可扩展性)。然而,该变化使用泛型和返回值(而不是void)来解决Visitor模式所解决的一些问题。

那个问题是什么,为什么OP版可能不足够?

Visitor模式解决的主要问题是当你有异构对象需要以相同方式处理时。正如设计模式的GoF(设计模式的作者)所述,您在以下情况下使用该模式:

"一个对象结构包含许多具有不同接口的对象类,并且您希望对这些对象执行依赖于它们的具体类的操作."

这句话缺少的是,尽管您希望"对这些对象执行依赖于它们的具体类的操作",但您希望将这些具体类视为具有单一的多态类型。

一个例子

使用动物领域很少有说明性(稍后我会回到这个问题),因此这里是另一个更实际的例子。示例是使用C#编写的,希望对您仍然有用。

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

有时,您想要显示单独的一天,但在其他时候,您想要将整个月作为单个日历对象显示。再加上整整一年。这意味着您有三个时间段:年。每个时间段都具有不同的接口:

public Year(int year)

public Month(int year, int month)

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

为简洁起见,这只是三个单独类的构造函数。许多人可能会将其建模为具有可空字段的单个类,但这会迫使您处理空字段、枚举或其他类型的不良代码。
上述三个类具有不同的结构,因为它们包含不同的数据,但您希望将它们视为单个概念——一个“期间”。
为此,请定义一个IPeriod接口:
internal interface IPeriod
{
    T Accept<T>(IPeriodVisitor<T> visitor);
}

并使每个类实现该接口。这是 {{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);
    }
}

这使得你能够将三个不同的类视为单一类型并定义操作,而无需更改接口。
以下是一个计算前一周期的实现示例:
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类和其他访问者的使用情况,但这里是它们被使用的几行代码。
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对象,但代码不知道它是一个DayMonth还是一个Year
需要明确的是,上面的例子使用了内部访问者变体,它与Church编码等同
动物
用动物来理解面向对象编程很少有启发性。我认为学校应该停止使用这个例子,因为它更容易混淆而不是帮助。
OP代码示例没有遭受访问者模式解决的问题,因此在这种情况下,如果你看不到好处,也就不足为奇了。 CatDog类不是异构的。它们具有相同的类字段和行为。唯一的区别在于构造函数。你可以将这两个类轻松地重构成一个单一的Animal类:
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值。
由于现在只有一个类,不需要使用访问者模式。

我有一个问题。这整个来回的过程是必要的,因为Java没有双重分派。如果Java有双重分派,我能否使用我已经拥有的相同代码(无需来回)并将我的示例中的Dog和Cat视为Animal而不是它们的具体类? - AFP_555
1
@AFP_555 这只是一种推测:如果你的编程语言拥有一个解决设计模式所涉及问题的语言特性,那么你是否还需要这个设计模式呢? 从逻辑上讲,不需要。这是针对设计模式的典型(也是完全合理的)批评之一(尤其是来自FP社区),即它解决了“更好”的编程语言没有的问题。 - Mark Seemann
@MarkSeemann,你在这里谈论异构对象,这让我开始思考我在策略模式中遇到的一个概念问题。我不确定请求这是否“违规”,但你能否也检查一下这个问题?https://stackoverflow.com/questions/67833661/strategy-pattern-on-heterogeneous-objects - AFP_555
@AFP_555 这完全不是非法的。我认为,你可能误解了我对“异构”的使用。这很可能是我的错。我所说的异构是指类型完全不相似的对象。你在那个问题中列出的例子并不是这样的。至少我认为它们都可以迭代(但是,惊喜!我其实不是Java程序员,所以我不确定)。 - Mark Seemann

23
访问者模式中的来回操作是为了模拟一种双重派发机制,它可以根据两个对象的运行时类型选择方法实现。
如果您的动物和访问者的类型都是抽象的(或多态的),则这将非常有用。在这种情况下,您有4种可能的方法实现可供选择,即2 x 2 = 4,取决于a)您想要执行哪种操作(访问),以及b)您希望此操作应用于哪种类型的动物。

enter image description here enter image description here

如果您正在使用具体和非多态类型,则这种来回传递的部分确实是多余的。


10
好的,我会尽力进行翻译。这里的关键概念是“双重分派”,将常规方法名“accept”更改为类似于“revealYourClassToThisGuy”的名称,强调了它对访问者模式的重要性。 - John Bollinger
@JohnBollinger 您的评论让我明白了重点。在创建动物对象时,我尝试使用Animal接口而不是具体的动物(狗,猫),当然,代码无法编译。所以... accept方法的重点是让具体的动物对访问者说“嘿,我是一只猫,因为我正在调用visitCat方法。现在你可以像对待猫一样对待我。” 不过,我仍然不理解双重分派的概念。 - AFP_555
2
@AFP_555,看一下什么是方法分派?可能会有所帮助。在Java中,这是一个既有编译时部分又有运行时部分的活动。双重分派只是意味着你链接两个方法分派来实现你想要的结果,而不是只有一个。这正是你在问题中批评的“来回疯狂”,也是使访问者模式工作的机制。 - John Bollinger

8
你的意思是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();
    }
}

因此,您所获得的是:您只需知道它是动物,就可以调用动物上的正确个体操作。
这在遍历复合模式时特别有用,其中叶节点是动物,而内部节点是聚合(例如,List)的Animals。使用您的设计无法处理List<Animal>

4
这里展示的原则是 OP 中缺失的,即“针对接口编程,而不是针对实现编程”。OP 直接编程到 DogCat,因此没有多态性,因此 OO 设计模式并不是很有用。 - jaco0646
2
没错,我不能把Dog dog当作Animal dog来处理,代码不会以那种方式编译。这是Java中非常特殊的一件事情。现在我明白了来回转换是完全必要的,即使它非常笨拙。谢谢。 - AFP_555
1
@jaco0646 等等……我正在读《设计模式》这本书,他们说:“迭代器不能在具有不同类型元素的对象结构之间工作。(...) 访问者没有这个限制。它可以访问那些没有共同父类的对象。你可以将任何类型的对象添加到访问者接口中。MyType 和 YourType 根本不需要通过继承进行关联”。所以我们真的不需要 Animal 的父类,这就是访问者的好处,能够遍历不属于同一层次结构的无关对象。那么我为什么要有 Animal 类呢? - AFP_555
不需要父类,但接口必须提供一个 accept(Visitor visitor) 方法,并且每个遍历的对象都必须实现它。 - CoronA
在短语“_visit objects that don't have a common parent class_”中,关键词是_common_(而不是parent)。与通过Animal将所有元素相关联不同,另一个例子可能是Canine dogFeline cat之间没有任何关系。在面向对象编程中,您总是针对接口进行编程。访问者模式表示您不必始终针对相同的接口进行编程。如果您根本不针对任何接口进行编程,则首先您不是在进行面向对象编程,其次,如OP所示,访问者模式是无意义的。 - jaco0646
显示剩余2条评论

3

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

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

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

例如,在Common Lisp中。为简洁起见,我们甚至不定义类:整数字符串都是类,因此让我们使用这些类。

首先,让我们为整数或字符串访问整数或字符串的每种组合编写通用函数的四种方法。这些方法只产生输出。我们不使用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"!

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

所谓“前后疯狂”,与在仅具有单一派发而方法属于类而不是通用函数的面向对象系统中模拟双重派发的样板代码有关。

因为在主流的单一派发面向对象系统中,方法被封装在类中,我们首先遇到的问题是visit方法在哪里?它位于访问者还是节点上?

答案是必须都有。我们需要在两种类型上都分派一些东西。

接下来出现的问题是,在面向对象编程实践中,我们需要良好的命名。我们不能在visitorvisited对象上都有一个visit方法。当访问一个已访问对象时,“访问”动词不用来描述该对象正在做什么。它“接受”一个访问者。因此,我们必须称该行为的一半为accept

我们创建了一种结构,使得要访问的每个节点都有一个accept方法。此方法根据节点类型分派,并接受一个Visitor参数。实际上,该节点具有多个accept方法,这些方法在不同类型的访问者上静态地进行了特化:IntegerVisitorStringVisitorFooVisitor。请注意,即使我们在语言中有这样的类,我们也不能只使用String,因为它没有用visit方法实现Visitor接口。

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

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

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

原因是selfAccept调用时必须是静态类型的,因为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}`)
}

所有的类和方法都需要在某个地方声明:

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);
}

那么这就是使用静态重载的单分派访问者模式:存在大量样板,以及一个类(访问者或被访问者)必须知道所有支持的其他类的静态类型,以便可以在其上进行静态分派,对于每个静态类型,也会有一个虚拟方法。

0
  • 我的理解

Visitor模式是一种针对对象结构中的元素执行操作的设计模式。使用Visitor模式可以定义新的操作,而无需修改已有元素的类。

  • 通用示例

Visitor是一个根接口。

public interface Visitor<R> {

    R visit(Object o);

    static <T, R> X<T, R> forType(Class<T> type) {
        return () -> type;
    }

    static <R> Visitor<R> of(VisitorConsumer<VisitorBuilder<R>> visitorConsumer) {
        Map<Class<?>, Function<Object, R>> registry = new HashMap<>();
        VisitorBuilder<R> visitorBuilder = new VisitorBuilder<R>() {
            @Override
            public <T> void register(Class<T> type, Function<T, R> function) {
                registry.put(type, function.compose(type::cast));
            }
        };
        visitorConsumer.accept(visitorBuilder);
        return o -> (R) registry.get(o.getClass()).apply(o);
    }

    interface X<T, R> {
        default Y<R> execute(Function<T, R> function) {
            return visitorBuilder -> visitorBuilder.register(type(), function);
        }

        Class<T> type();
    }

    interface Y<R> extends VisitorConsumer<VisitorBuilder<R>> {
        default <T> W<T, R> forType(Class<T> type) {
            return index -> index == 0 ? this : type;
        }

        default Y<R> andThen(Y<R> after) {
            return t -> {
                this.accept(t);
                after.accept(t);
            };
        }
    }

    interface W<T, R> {

        Object get(int index);

        default Class<T> type() {
            return (Class<T>) get(1);
        }

        default Y<R> previousConsumer() {
            return (Y<R>) get(0);
        }

        default Y<R> execute(Function<T, R> function) {
            return previousConsumer().andThen(visitorBuilder -> visitorBuilder.register(type(), function));
        }
    }
}

访问者消费者接口

public interface VisitorConsumer<T> {

    void accept(T t);

    default VisitorConsumer<T> chainConsumer(VisitorConsumer<T> other) {
        Objects.requireNonNull(other);
        return (T t) -> {
            this.accept(t);
            other.accept(t);
        };
    }

    default <U> Z<U> construct(Class<?> type) {
        return () -> type;
    }

    interface Z<U> {
        default U build(VisitorConsumer<U> consumer, Supplier<U> supplier) {

            BiFunction<VisitorConsumer<U>, Supplier<U>, U> extractor = (o, s) -> {
                U u = s.get();
                o.accept(u);
                return u;
            };
            return extractor.apply(consumer, supplier);
        }

        Class<?> type();
    }
    
}

访客构建器界面

public interface VisitorBuilder<R> {

    <T> void register(Class<T> type, Function<T, R> function);

}

还有一个实现

public class Main {
    public static void main(String[] args) {
        VisitorConsumer<VisitorBuilder<String>> consumer = Visitor.<Beta, String>forType(Beta.class)
                .execute(o -> "The Great : " + o.getName())
                .forType(Alpha.class).execute(Alpha::getName);

        Visitor<String> visitor = Visitor.of(consumer);
        System.out.println(visitor.visit(new Beta("Wolf")));
        System.out.println(visitor.visit(new Alpha("Wolf")));
    }
}

其中Alpha和Beta只是具有接收字符串的任意不同类的构造函数。请注意,我们针对每个特定的类类型专门添加了运算符,并在它们的实例上应用了访问者。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接