访客模式中accept()方法的重点是什么?

时间:2012-02-03 16:43:59

标签: design-patterns visitor

关于将算法与类解耦,有很多讨论。但是,有一件事没有解释。

他们使用这样的访客

abstract class Expr {
  public <T> T accept(Visitor<T> visitor) {visitor.visit(this);}
}

class ExprVisitor extends Visitor{
  public Integer visit(Num num) {
    return num.value;
  }

  public Integer visit(Sum sum) {
    return sum.getLeft().accept(this) + sum.getRight().accept(this);
  }

  public Integer visit(Prod prod) {
    return prod.getLeft().accept(this) * prod.getRight().accept(this);
  }

访问者要求元素调用其visit方法,而不是直接调用visit(element)。它与所宣称的关于访客的阶级无意识的观念相矛盾。

PS1请用您自己的话解释或指出确切的解释。因为我得到的两个回答是指一般的和不确定的。

PS2我猜:由于getLeft()会返回基本Expression,因此调用visit(getLeft())会产生visit(Expression),而调用getLeft()的{​​{1}}将会visit(this)导致另一个更合适的访问调用。因此,accept()执行类型转换(也称为转换)。

PS3 Scala's Pattern Matching = Visitor Pattern on Steroid显示没有accept方法访问者模式的简单程度。 Wikipedia adds to this statement:通过链接一篇文章,显示“当反射可用时accept()方法是不必要的;为该技术引入术语'Walkabout'。”

5 个答案:

答案 0 :(得分:134)

访问者模式的visit / accept结构是一种必要的邪恶,因为类似C的语言&#39; (C#,Java等)语义。访问者模式的目标是使用双重调度来路由您的呼叫,因为您希望阅读代码。

通常,当使用访问者模式时,涉及对象层次结构,其中所有节点都是从基本Node类型派生的,此后称为Node。本能地,我们这样写:

Node root = GetTreeRoot();
new MyVisitor().visit(root);

这就是问题所在。如果我们的MyVisitor类定义如下:

class MyVisitor implements IVisitor {
  void visit(CarNode node);
  void visit(TrainNode node);
  void visit(PlaneNode node);
  void visit(Node node);
}

如果在运行时,无论root实际类型如何,我们的调用都将进入重载visit(Node node)。对于声明类型为Node的所有变量,情况都是如此。为什么是这样?因为Java和其他类C语言在决定调用哪个重载时只考虑参数的静态类型或变量声明类型。对于每个方法调用,Java在运行时都没有采取额外的步骤来询问,#34;好的,root的动态类型是什么?原来如此。它是TrainNode。让我们看看MyVisitor中是否有任何方法接受TrainNode类型的参数......&#34;。编译器在编译时确定将调用哪个方法。 (如果Java确实检查了参数&#39;动态类型,性能将非常糟糕。)

Java确实为我们提供了一种工具,用于在调用方法时考虑对象的运行时(即动态)类型 - virtual method dispatch。当我们调用虚方法时,调用实际上转到内存中由函数指针组成的table。每种类型都有一张桌子。如果一个特定方法被一个类覆盖,那么该类&#39;函数表条目将包含被覆盖函数的地址。如果该类没有覆盖一个方法,它将包含一个指向基类的指针&#39;实现。这仍然会产生性能开销(每个方法调用基本上都会取消引用两个指针:一个指向类型的函数表,另一个指向函数本身),但它仍然比检查参数类型更快

访问者模式的目标是完成double-dispatch - 不仅通过虚拟方法考虑呼叫目标的类型(MyVisitor),还考虑参数的类型(我们在看Node的类型?访问者模式允许我们通过visit / accept组合执行此操作。

将我们的行更改为:

root.accept(new MyVisitor());

我们可以得到我们想要的东西:通过虚拟方法调度,我们输入由子类实现的正确的accept()调用 - 在我们的TrainElement示例中,我们输入{{1} TrainElement的实施:

accept()

目前,编译器在class TrainNode extends Node implements IVisitable { void accept(IVisitor v) { v.visit(this); } } TrainNode的范围内知道什么? 它知道accept的静态类型是this 。这是编译器在我们的调用者范围内没有注意到的一个重要的额外信息:在那里,它所知道的TrainNode只是root。现在,编译器知道Nodethis)不仅仅是root,而且它实际上是Node。因此,在TrainNodeaccept()中找到的一行,完全意味着其他内容。编译器现在将查找v.visit(this)的重载,该重载需要visit()。如果找不到,那么它会将调用编译为带TrainNode的重载。如果两者都不存在,那么您将收到编译错误(除非您有一个需要Node的重载。因此,执行将进入我们一直以来的目的:object MyVisitor的实施。不需要演员阵容,最重要的是,不需要反思。因此,这种机制的开销相当低:它只包含指针引用而没有别的。

你的问题正确 - 我们可以使用演员表并获得正确的行为。但是,通常,我们甚至不知道Node是什么类型的。以下面的层次结构为例:

visit(TrainNode e)

我们正在编写一个简单的编译器,它解析源文件并生成符合上述规范的对象层次结构。如果我们为实现为访问者的层次结构编写解释器:

abstract class Node { ... }
abstract class BinaryNode extends Node { Node left, right; }
abstract class AdditionNode extends BinaryNode { }
abstract class MultiplicationNode extends BinaryNode { }
abstract class LiteralNode { int value; }

由于我们不知道class Interpreter implements IVisitor<int> { int visit(AdditionNode n) { int left = n.left.accept(this); int right = n.right.accept(this); return left + right; } int visit(MultiplicationNode n) { int left = n.left.accept(this); int right = n.right.accept(this); return left * right; } int visit(LiteralNode n) { return n.value; } } 方法中leftright的类型,因此投射不会让我们走得太远。我们的解析器很可能也只返回一个类型为visit()的对象,它也指向了层次结构的根,所以我们也无法安全地转换它。所以我们简单的解释器看起来像:

Node

访问者模式允许我们做一些非常强大的事情:给定一个对象层次结构,它允许我们创建在层次结构上运行的模块化操作,而不需要将代码放在层次结构的类本身中。访问者模式被广泛使用,例如,在编译器构造中。给定特定程序的语法树,编写了许多访问该树的访问者:类型检查,优化,机器代码发射通常都是作为不同的访问者实现的。在优化访问者的情况下,它甚至可以在给定输入树的情况下输出新的语法树。

它有它的缺点,当然:如果我们在层次结构中添加一个新类型,我们还需要为Node program = parse(args[0]); int result = program.accept(new Interpreter()); System.out.println("Output: " + result); 接口添加一个visit()方法,并创建存根(我们所有访客的实施或全部实施。出于上述原因,我们还需要添加IVisitor方法。如果性能对您来说意义不大,那么有一些解决方案可以在不需要accept()的情况下编写访问者,但它们通常涉及反射,因此会产生相当大的开销。

答案 1 :(得分:15)

当然,如果那是唯一方式实现Accept,那将是愚蠢的。

但事实并非如此。

例如,在处理层次结构时,访问者确实非常有用在这种情况下,非终端节点的实现可能是这样的

interface IAcceptVisitor<T> {
  void Accept(IVisit<T> visitor);
}
class HierarchyNode : IAcceptVisitor<HierarchyNode> {
  public void Accept(IVisit<T> visitor) {
    visitor.visit(this);
    foreach(var n in this.children)
      n.Accept(visitor);
  }

  private IEnumerable<HierarchyNode> children;
  ....
}
你知道吗?您描述为愚蠢的是 遍历层次结构的解决方案。

Here is a much longer and in depth article that made me understand visitor

修改 澄清一下:访问者的Visit方法包含要应用于节点的逻辑。节点的Accept方法包含有关如何导航到相邻节点的逻辑。 双重调度的情况是一种特殊情况,其中没有相邻的节点可以导航到。

答案 2 :(得分:0)

访问者模式的目的是确保对象知道访问者何时完成访问并离开,因此类可以在之后执行任何必要的清理。它还允许类“暂时”将其内部暴露为“ref”参数,并且知道一旦访问者离开,内部将不再被暴露。在不需要清理的情况下,访问者模式并不十分有用。不执行这些操作的类可能无法从访问者模式中受益,但是使用访问者模式编写的代码将可用于将来可能需要在访问后进行清理的类。

例如,假设有一个数据结构包含许多应该原子更新的字符串,但是持有数据结构的类并不确切地知道应该执行什么类型的原子更新(例如,如果一个线程想要替换所有出现“X”,而另一个线程想要用数字上更高的序列替换任何数字序列,两个线程的操作都应该成功;如果每个线程只读出一个字符串,执行其更新,然后将其写回,写回字符串的第二个线程会覆盖第一个线程。实现此目的的一种方法是让每个线程获得锁,执行其操作并释放锁。不幸的是,如果以这种方式暴露锁,数据结构将无法阻止某人获取锁并且永远不会释放它。

访客模式提供(至少)三种方法来避免该问题:

  1. 它可以锁定记录,调用提供的函数,然后解锁记录;如果提供的函数属于无限循环,则记录可以永久锁定,但如果提供的函数返回或抛出异常,则记录将被解锁(如果函数抛出异常,则标记记录无效;离开锁定可能不是一个好主意)。请注意,如果被调用函数尝试获取其他锁,则可能会导致死锁。
  2. 在某些平台上,它可以将保存字符串的存储位置作为“ref”参数传递。然后,该函数可以复制字符串,根据复制的字符串计算新字符串,尝试将旧字符串CompareExchange为新字符串,并在CompareExchange失败时重复整个过程。
  3. 它可以复制字符串,在字符串上调用提供的函数,然后使用CompareExchange本身尝试更新原始函数,并在CompareExchange失败时重复整个过程。

如果没有访问者模式,执行原子更新将需要暴露锁并且如果调用软件无法遵循严格的锁定/解锁协议则存在失败的风险。使用访客模式,原子更新可以相对安全地完成。

答案 3 :(得分:0)

需要修改的类必须都实现'accept'方法。客户端调用此accept方法对该类系列执行一些新操作,从而扩展其功能。通过为每个特定操作传入不同的访问者类,客户端可以使用这一种接受方法来执行各种新操作。访问者类包含多个重写的访问方法,这些方法定义了如何为系列中的每个类实现相同的特定操作。这些访问方法传递给一个可以工作的实例。

如果您经常为稳定的类系列添加,更改或删除功能,则访问者非常有用,因为每个功能都是在每个访问者类别中单独定义的,而且这些类本身不需要更改。如果类的族不稳定,则访问者模式的使用可能较少,因为每次添加或删除类时,许多访问者都需要更改。

答案 4 :(得分:-1)

良好示例在源代码编译中:

interface CompilingVisitor {
   build(SourceFile source);
}

客户端可以实现JavaBuilderRubyBuilderXMLValidator等,并且无需更改用于收集和访问项目中所有源文件的实现。

如果每个源文件类型都有单独的类,那么这将是错误的模式:

interface CompilingVisitor {
   build(JavaSourceFile source);
   build(RubySourceFile source);
   build(XMLSourceFile source);
}

归结为上下文以及您希望可扩展的系统的哪些部分。