访问者模式的目的及示例

96

我对访问者模式及其使用感到困惑。我似乎无法想象使用此模式的好处或其目的。如果有人能用例子解释一下,那就太好了。

我真的很困惑访问者模式及其作用。我很难想象使用该模式的好处或目的。如果可能的话,请给出示例解释。

6
你有查过该主题在维基百科上的介绍吗?http://en.wikipedia.org/wiki/Visitor_pattern 哪些部分不太清楚?或者你是在寻找真实世界的例子? - BalusC
1
简而言之:访问者模式类似于具有元素类型特定行为的高阶map(),称为“访问者”。或者说,访问者模式模拟了可靠函数或双重分派。可靠函数基本上是一组函数重载,但作为单个函数,不同的输入类型(甚至值)被映射到不同的输出类型。(它们的真正用途在于那些静态代码分析可以验证其类型正确性以进行运行时变量输入的语言中。)在双重分派的情况下,行为由访问对象和访问者动态分派。 - ChrisoLosoph
顺便说一下,我最近看到的一个合理的用例是从中间表示树生成代码。每种输出语言都有一个访问者类,定义了如何将某些具有不同类型的树节点翻译成目标语言。核心只需使用访问者遍历树来为每个节点生成文本输出。 - ChrisoLosoph
6个回答

216

你可能已经读了数不清的关于访问者模式的解释,但你可能仍然会说:“但是何时使用它!”

传统上,访问者用于在不牺牲类型安全性的情况下实现类型测试,只要你的类型在前面被明确定义并提前知道。假设我们有几个类如下:

abstract class Fruit { }
class Orange : Fruit { }
class Apple : Fruit { }
class Banana : Fruit { }

假设我们创建了一个 Fruit[]

var fruits = new Fruit[]
    { new Orange(), new Apple(), new Banana(),
      new Banana(), new Banana(), new Orange() };

我想将一个列表分成三个列表,分别包含橙子、苹果或香蕉。你会怎么做呢?嗯,简单的解决方案是使用类型测试:

List<Orange> oranges = new List<Orange>();
List<Apple> apples = new List<Apple>();
List<Banana> bananas = new List<Banana>();
foreach (Fruit fruit in fruits)
{
    if (fruit is Orange)
        oranges.Add((Orange)fruit);
    else if (fruit is Apple)
        apples.Add((Apple)fruit);
    else if (fruit is Banana)
        bananas.Add((Banana)fruit);
}

这段代码能够工作,但存在很多问题:

  • 首先,它很难看。
  • 它不是类型安全的,我们只有在运行时才能捕获类型错误。
  • 它不易维护。如果我们添加了一个新的Fruit类的派生实例,我们需要全局搜索每个执行水果类型测试的地方,否则我们可能会错过一些类型。

访问者模式可以优雅地解决这个问题。首先修改我们的基本Fruit类:

interface IFruitVisitor
{
    void Visit(Orange fruit);
    void Visit(Apple fruit);
    void Visit(Banana fruit);
}

abstract class Fruit { public abstract void Accept(IFruitVisitor visitor); }
class Orange : Fruit { public override void Accept(IFruitVisitor visitor) { visitor.Visit(this); } }
class Apple : Fruit { public override void Accept(IFruitVisitor visitor) { visitor.Visit(this); } }
class Banana : Fruit { public override void Accept(IFruitVisitor visitor) { visitor.Visit(this); } }

看起来我们在复制粘贴代码,但请注意派生类都调用了不同的重载函数(Apple 调用 Visit(Apple)Banana 调用 Visit(Banana),等等)。

实现访问者模式:

class FruitPartitioner : IFruitVisitor
{
    public List<Orange> Oranges { get; private set; }
    public List<Apple> Apples { get; private set; }
    public List<Banana> Bananas { get; private set; }

    public FruitPartitioner()
    {
        Oranges = new List<Orange>();
        Apples = new List<Apple>();
        Bananas = new List<Banana>();
    }

    public void Visit(Orange fruit) { Oranges.Add(fruit); }
    public void Visit(Apple fruit) { Apples.Add(fruit); }
    public void Visit(Banana fruit) { Bananas.Add(fruit); }
}

现在您可以进行水果的分区而无需进行类型测试:

FruitPartitioner partitioner = new FruitPartitioner();
foreach (Fruit fruit in fruits)
{
    fruit.Accept(partitioner);
}
Console.WriteLine("Oranges.Count: {0}", partitioner.Oranges.Count);
Console.WriteLine("Apples.Count: {0}", partitioner.Apples.Count);
Console.WriteLine("Bananas.Count: {0}", partitioner.Bananas.Count);

这样做的好处是:

  • 代码相对简洁易读。
  • 具有类型安全性,类型错误在编译时被捕获。
  • 可维护性。如果我添加或删除一个具体的 Fruit 类,我可以修改 IFruitVisitor 接口以相应地处理该类型,并且编译器将立即找到实现接口的所有位置,以便我们进行适当的修改。

话虽如此,访问者通常是过度设计,它们往往会极大地复杂化 API,并且为每种新行为定义一个新的访问者可能非常麻烦。

通常情况下,应使用更简单的模式(例如继承)代替访问者。例如,原则上可以编写一个类似于:

class FruitPricer : IFruitVisitor
{
    public double Price { get; private set; }
    public void Visit(Orange fruit) { Price = 0.69; }
    public void Visit(Apple fruit) { Price = 0.89; }
    public void Visit(Banana fruit) { Price = 1.11; }
}

这个方法可以使用,但相比于这个微小的修改,有什么优势呢:

abstract class Fruit
{
    public abstract void Accept(IFruitVisitor visitor);
    public abstract double Price { get; }
}

当以下条件成立时,您应该使用访问者模式:

  • 您有一个定义明确的已知类集将被访问。

  • 对这些类的操作没有被提前定义或者是未知的。例如,如果有人使用您的API,并且您希望为消费者提供一种向对象添加新的特定功能的方式。它们也是一种方便的方式来通过特定功能扩展已封闭的类。

  • 您执行一个对象类的操作并想要避免运行时类型测试。这通常发生在遍历具有不同属性的不同对象层次结构时。

不要在以下情况下使用访问者模式:

  • 您支持对其派生类型未知的对象类进行操作。

  • 对象的操作事先被定义得很好,尤其是如果可以从基类继承或在接口中定义。

  • 使用继承更容易为客户端向类添加新功能。

  • 您正在遍历具有相同属性或接口的对象层次结构。

  • 您需要一个相对简单的API。


6
访问者模式遵循开放封闭原则,访问者本身可以被修改,但不影响其他类的稳定性。如果要增加新的水果类型,则需要新增一个新方法。最好的做法是让访问者只有一个Visit(Fruit fruit)方法,并具有将每种水果映射到特定方法的具体实现。(这样其他访问者类可以扩展该具体基类) - jgauffin
9
访问者模式的一个正式后果是,每当您添加一个可以被访问的新对象时,就会创建一个新方法,因此这种模式的后果涉及到了开放/封闭原则的违反。在Visit()方法中执行类型解析会带来很多缺点,包括不允许IDE、编译器、运行时环境等验证实现。 - Fleep
请您将代码重构为Java。此外,问题有Java标签,因此“我们”更希望在Java中提供示例。我无法理解这段代码,它看起来像伪代码。 - GilbertS
请您将代码重构为Java。此外,问题有Java标签,因此“我们”更希望在Java中提供示例。我无法理解这段代码,它看起来像伪代码。 - GilbertS

79

从前有一个故事...

class MusicLibrary {
    private Set<Music> collection ...
    public Set<Music> getPopMusic() { ... }
    public Set<Music> getRockMusic() { ... }
    public Set<Music> getElectronicaMusic() { ... }
}

然后你意识到你希望能够通过其他类型来过滤库的集合。你可以不断添加新的getter方法。或者你可以使用访问者模式。

interface Visitor<T> {
    visit(Set<T> items);
}

interface MusicVisitor extends Visitor<Music>;

class MusicLibrary {
    private Set<Music> collection ...
    public void accept(MusicVisitor visitor) {
       visitor.visit( this.collection );
    }
}

class RockMusicVisitor implements MusicVisitor {
    private final Set<Music> picks = ...
    public visit(Set<Music> items) { ... }
    public Set<Music> getRockMusic() { return this.picks; }
}
class AmbientMusicVisitor implements MusicVisitor {
    private final Set<Music> picks = ...
    public visit(Set<Music> items) { ... }
    public Set<Music> getAmbientMusic() { return this.picks; }
}

您将数据与算法分离。您将算法卸载到访问器实现中。您可以通过创建更多的访问者来添加功能,而不是不断修改(和膨胀)保存数据的类。


44
抱歉,这个例子并不适合展示访问者模式,它过于简单。访问者模式的主要机制之一,即通过访问元素的类型(双重分派)来选择功能的机制没有被展示。-1 - Harald Scheirich
3
访问者可以选择按类型选择功能,也可以不选。即使没有按类型选择,我发现访问者仍然非常有用。 - DJClayworth
3
这不是策略设计模式吗? - HakunaMatata
3
赞同@HaraldScheirich的观点——《设计模式-可重用面向对象软件的基础》(1995年)中对访问者的正式表示表明,每个访问者应该代表一个操作,并且该访问者应该有一个方法来处理每种可操作的类型。 - Fleep
1
这个例子似乎添加了与访问者模式旨在解决的相同问题,即在同一个 MusicVisitor 接口上具有不同命名的方法。更好的解决方案是在所有接口上都有一个共同的 getMusic() 方法。 - user3714134
显示剩余2条评论

5
它提供了另一层抽象。减少了对象的复杂性,使其更加模块化。有点像使用接口(实现完全独立,没有人关心它是如何完成的,只要完成了就可以)。虽然我从未使用过它,但它对于实现需要在不同子类中完成的特定函数非常有用,因为每个子类需要以不同的方式实现它,另一个类将实现所有函数。有点像一个模块,但仅适用于一组类。维基百科有一个相当好的解释:http://en.wikipedia.org/wiki/Visitor_pattern 他们的例子有助于解释我的意思。希望这能帮助你更好地理解它。编辑**很抱歉我为你的答案链接到了维基百科,但他们确实有一个不错的例子 :) 我不想成为那个让你自己找的人。

这个解释有点像策略模式。 - Waclock

4

访问者模式示例。书籍、水果和蔬菜是类型"Visitable"的基本元素,有两个"Visitors"BillingVisitor & OfferVisitor,每个访问者都有自己的目的。计算账单的算法和计算这些元素优惠的算法封装在各自的访问者中,而可访问的元素保持不变。

import java.util.ArrayList;
import java.util.List;


public class VisitorPattern {

    public static void main(String[] args) {
        List<Visitable> visitableElements = new ArrayList<Visitable>();
        visitableElements.add(new Book("I123",10,2.0));
        visitableElements.add(new Fruit(5,7.0));
        visitableElements.add(new Vegetable(25,8.0));
        BillingVisitor billingVisitor = new BillingVisitor();
        for(Visitable visitableElement : visitableElements){
            visitableElement.accept(billingVisitor);
        }

        OfferVisitor offerVisitor = new OfferVisitor();
        for(Visitable visitableElement : visitableElements){
            visitableElement.accept(offerVisitor);
        }
        System.out.println("Total bill " + billingVisitor.totalPrice);
        System.out.println("Offer  " + offerVisitor.offer);

    }

    interface Visitor {
        void visit(Book book);
        void visit(Vegetable vegetable);
        void visit(Fruit fruit);
    }

    //Element
    interface Visitable{
        public void accept(Visitor visitor);
    }


    static class OfferVisitor implements Visitor{
        StringBuilder offer = new StringBuilder();

        @Override
        public void visit(Book book) {
            offer.append("Book " +  book.isbn +  " discount 10 %" + " \n");
        }

        @Override
        public void visit(Vegetable vegetable) {
            offer.append("Vegetable  No discount \n");
        }

        @Override
        public void visit(Fruit fruit) {
            offer.append("Fruits  No discount \n");
        }

    }

    static class BillingVisitor implements Visitor{
        double totalPrice = 0.0;

        @Override
        public void visit(Book book) {
            totalPrice += (book.quantity * book.price);
        }

        @Override
        public void visit(Vegetable vegetable) {
            totalPrice += (vegetable.weight * vegetable.price);
        }

        @Override
        public void visit(Fruit fruit) {
            totalPrice += (fruit.quantity * fruit.price);
        }

    }

    static class Book implements Visitable{
        private String isbn;
        private double quantity;
        private double price;

        public Book(String isbn, double quantity, double price) {
            this.isbn = isbn;
            this.quantity = quantity;
            this.price = price;
        }

        @Override
        public void accept(Visitor visitor) {
            visitor.visit(this);
        }
    }

    static class Fruit implements Visitable{
        private double quantity;
        private double price;

        public Fruit(double quantity, double price) {
            this.quantity = quantity;
            this.price = price;
        }

        @Override
        public void accept(Visitor visitor) {
            visitor.visit(this);
        }
    }

    static class Vegetable implements Visitable{
        private double weight;
        private double price;

        public Vegetable(double weight, double price) {
            this.weight = weight;
            this.price = price;
        }


        @Override
        public void accept(Visitor visitor) {
            visitor.visit(this);            
        }
    }


}

我在考虑在游戏项目中使用访问者模式来解决具有灵活状态修改器的问题。但是这有点复杂...“修改器”将具有一些数据,例如名称、描述、值和 modType(它是加法、乘法等类型,以及它是否与其他修改器堆叠),并且可能具有最小/最大输出范围。Stat 对象类型类似于 Stat<T> 类,其中 T: struct,关系有点令人生畏哈哈。 - Aaron Carter

4
我认为访问者模式的主要目的是具有高度的可扩展性。 直觉告诉我们,你已经购买了一个机器人。 该机器人已经完全实现了基本功能,如前进、左转、右转、后退、拾取物品、说话等。
有一天,你希望你的机器人可以替你去邮局。虽然它可以做所有这些基本功能,但你需要把机器人带到商店并“更新”机器人。商店销售员不需要修改机器人,只需简单地给你的机器人加上一个新的更新芯片,它就能做你想要的事情。
另一天,你想让你的机器人去超市。同样的过程,你必须将机器人带到商店并更新这个“高级”功能。无需修改机器人本身。
因此,访问者模式的思想是,在给定所有实现的基本功能的情况下,您可以使用访问者模式添加无限数量的复杂功能。在这个例子中,机器人是你的工作类,而“更新芯片”是访问者。每次需要新的功能“更新”时,你不需要修改你的工作类,而是添加一个访问者。

2

这是将数据操作与实际数据分开的过程。作为一个额外的好处,您可以重复使用相同的访问者类来处理整个类层次结构,这样就不必携带与实际对象无关的数据操作算法。


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