访客模式中的accept()方法有什么意义?


87

关于将算法与类分离的讨论很多。但是,一件事搁置不明。

他们这样使用访客

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(element),而是要求元素调用其visit方法。它与已宣布的关于访客的阶级不了解的思想相矛盾。

PS1请用您自己的文字解释或指向确切的解释。因为我得到的两个回答是关于一般性和不确定性的。

PS2我的猜测:由于getLeft()返回basic Expression,因此调用visit(getLeft())将导致visit(Expression),而getLeft()调用visit(this)将导致另一个更合适的访问调用。因此,accept()执行类型转换(aka转换)。

PS3 Scala的模式匹配=类固醇上的访客模式显示了没有accept方法的访客模式要简单得多。Wikipedia对此声明进行了补充:通过链接显示“accept()有反射的情况下无需使用方法;为该技术引入术语'Walkabout'”的论文。



它说:“当访客呼叫接受时,将根据被呼叫者的类型来调度cal。然后,被呼叫者会回调访客的特定类型的访问方法,并根据访客的实际类型来调度此呼叫。” 换句话说,它说明了令我困惑的事情。因此,请您更具体吗?
2012年

Answers:


154

由于C语言(C#,Java等)的语义,访问者模式的visit/accept构造是必不可少的。访客模式的目标是使用双分派来路由您的呼叫,如您阅读代码所期望的那样。

通常,当使用访问者模式时,将涉及对象层次结构,其中所有节点均从基本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)。对于声明为type的所有变量都是如此Node。为什么是这样?因为Java和其他类似C的语言在确定要调用的重载时仅考虑参数的静态类型或将变量声明为的类型。Java并不需要采取额外的步骤来询问每个方法调用,在运行时,“好吧,动态类型是root什么?哦,我知道了。它是一个TrainNode。让我们看看是否有任何方法MyVisitor接受类型参数TrainNode...”。编译器在编译时确定将调用哪个方法。(如果Java确实检查了参数的动态类型,性能将非常糟糕。)

Java确实为我们提供了一种工具,用于在方法调用时考虑对象的运行时(即动态)类型-虚拟方法调度。当我们调用虚拟方法时,调用实际上转到内存中由函数指针组成的。每种类型都有一个表格。如果某个类覆盖了某个特定方法,则该类的函数表条目将包含该覆盖函数的地址。如果该类未覆盖方法,它将包含一个指向基类实现的指针。这仍然会带来性能开销(每个方法调用将基本上取消引用两个指针:一个指向类型的函数表,另一个指向函数本身),但是它比必须检查参数类型要快。

访客模式的目标是完成两次调度-不仅要考虑调用目标的类型(MyVisitor通过虚拟方法),而且还要考虑参数的类型(Node我们要查看的是哪种类型)?访客模式允许我们通过visit/accept组合执行此操作。

通过将我们的行更改为此:

root.accept(new MyVisitor());

我们可以得到想要的:通过虚拟方法分派,我们输入由子类实现的正确的accept()调用-在我们的示例中TrainElement,我们将输入TrainElement的实现accept()

class TrainNode extends Node implements IVisitable {
  void accept(IVisitor v) {
    v.visit(this);
  }
}

什么编译器知道在这一点上,范围内TrainNodeaccept它知道的静态类型thisTrainNode。这是编译器在调用者范围内不了解的另一重要重要信息:在那里,它所知道的root只是一个Node。现在,编译器知道thisroot)不仅是一个Node,而且实际上是一个TrainNode。因此,在accept():中找到的一行v.visit(this)完全意味着其他内容。编译器现在将寻找过载的visit(),需要一个TrainNode。如果找不到,则会将调用编译为需要一个Node。如果两者都不存在,则将出现编译错误(除非您有接受的重载object)。因此执行将进入我们一直以来的预期目标:MyVisitor的实现visit(TrainNode e)。不需要强制转换,最重要的是,不需要反射。因此,此机制的开销相当低:它仅包含指针引用,而没有其他内容。

您的问题是对的-我们可以使用强制转换并获得正确的行为。但是,通常,我们甚至不知道Node是什么类型。以以下层次结构为例:

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

铸造不会让我们很远,因为我们不知道类型的left或者rightvisit()方法。我们的解析器很可能还会返回类型Node也指向层次结构根的对象,因此我们也无法安全地进行转换。因此,我们的简单解释器可能如下所示:

Node program = parse(args[0]);
int result = program.accept(new Interpreter());
System.out.println("Output: " + result);

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

当然,它有缺点:如果我们在层次结构中添加新类型,我们还需要visit()IVisitor接口中添加针对该新类型的方法,并在所有访问者中创建存根(或完整)实现。accept()由于上述原因,我们也需要添加该方法。如果性能对您没有太大的意义,则可以使用一些解决方案来编写访问者而不需要accept(),但是它们通常涉及反射,因此会产生相当大的开销。


5
有效的Java项目#41包含以下警告:“避免通过添加强制类型转换将相同的参数集传递给不同的重载的情况。accept()当访客中违反此警告时,该方法变得很有必要。
jaco0646

通常,使用访问者模式时,将涉及对象层次结构,其中所有节点均从基本Node类型派生”,这在C ++中绝对不是必需的。见Boost.Variant,Eggs.Variant
吉恩-迈克尔Celerier

在我看来,在Java中我们实际上并不需要用accept方法,因为在Java中我们总是调用最特定的类型方法
Gilad Baruchian

1
哇,这是一个很棒的解释。令人鼓舞的是,该模式的所有阴影都是由于编译器的限制所致,现在对您的感谢深表感谢。
阿方索西川

@GiladBaruchian,编译器会生成对编译器可以确定的最特定类型方法的调用。
mmw

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

你看?您描述为愚蠢的是遍历层次结构解决方案。

这是一篇长而深入的文章,使我了解了访客

编辑: 澄清:访问者的Visit方法包含要应用于节点的逻辑。节点的Accept方法包含有关如何导航到相邻节点的逻辑。您仅进行两次分派的情况是一种特殊情况,其中根本没有相邻节点可导航。


7
您的解释没有解释为什么要由节点负责而不是由访问者的适当的visit()方法来迭代子项?您的意思是说,当我们需要针对不同访问者的相同访问方式时,主要思想是共享层次结构遍历代码?我看不到推荐论文中的任何提示。
2012年

1
说接受对于常规遍历是有道理的,并且对于一般人群来说是值得的。但是,我以某人的“在读完andymaleh.blogspot.com/2008/04/…之前我无法理解访客模式”作为例子。此示例,Wikipedia或其他答案均未提及导航优势。然而,他们都要求这个愚蠢的accept()。那就是为什么问我的问题:为什么?
2012年

1
@Val-你是什么意思?我不确定你在问什么。我不能说其他文章,因为那些人​​对此东西有不同的看法,但我怀疑我们不同意。通常,在计算中,许多问题可以映射到网络,因此用法可能与表面的图形无关,但实际上是一个非常相似的问题。
George Mauer 2012年

1
提供某个方法可能有用的示例并不能回答为什么该方法是强制性的问题。由于并非总是需要导航,所以accept()方法并不总是很适合访问。因此,没有它,我们应该能够实现我们的目标。但是,这是强制性的。这意味着,比“有时有用”更强的理由将accept()引入每个访问者模式。我的问题不清楚什么?如果您不尝试理解为什么Wikipedia寻求摆脱接受的方式,那么您对理解我的问题不感兴趣。
Val

1
@Val他们链接到“访问者模式的本质”的论文指出,导航和操作的抽象与我给出的相同。他们只是在说GOF实现(这是您要问的)有一些局限性和烦恼,可以通过使用反射来消除-因此,他们引入了Walkabout模式。这当然是有用的,并且可以完成访问者可以做的很多事情,但是它是很多相当复杂的代码,并且(粗略地阅读)失去了类型安全性的一些好处。它是该工具箱的一种工具,但比访客的工具重得多
George Mauer

0

访客模式的目的是确保对象知道访客何时完成访问并离开,以便类随后可以执行任何必要的清除。它还允许类将内部信息“临时”公开为“ ref”参数,并知道一旦访问者离开,内部信息将不再公开。在不需要清理的情况下,访问者模式不是很有用。不执行这些操作的类可能无法从访问者模式中受益,但是编写为使用访问者模式的代码将可用于将来需要访问后进行清理的类。

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

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

  1. 它可以锁定记录,调用提供的功能,然后解锁记录;如果提供的函数陷入无限循环,则记录可能永远被锁定,但是如果提供的函数返回或引发异常,则记录将被解锁(如果该函数引发异常,则将记录标记为无效可能是合理的;锁定它可能不是一个好主意)。请注意,如果被调用函数尝试获取其他锁,则可能会导致死锁,这一点很重要。
  2. 在某些平台上,它可以传递将字符串作为'ref'参数的存储位置。然后,该函数可以复制字符串,根据复制的字符串计算新的字符串,尝试将旧字符串比较为新字符串,如果CompareExchange失败,则重复整个过程。
  3. 它可以制作字符串的副本,在字符串上调用提供的函数,然后使用CompareExchange本身尝试更新原始字符串,如果CompareExchange失败,则重复整个过程。

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


2
1. Visiting(访问)意味着您只能访问被访问的公共方法,因此需要使内部锁可访问,以供访问者使用。2 /我之前未看过任何示例都没有暗示应该将Visitor用于更改受访者的状态。3.“使用传统的VisitorPattern,只能确定何时进入节点。我们不知道在进入当前节点之前是否离开了上一个节点。” 您如何仅通过访问而不是visitEnter和visitLeave来解锁?最后,我询问了accpet()而不是Visitor的应用程序。
2012年

也许我并不完全了解模式的术语,但是“访问者模式”似乎类似于我使用的一种方法,其中X向Y传递了一个委托,然后Y可以向其中传递只需要有效的信息即可。只要委托正在运行。也许该模式还有其他名称?
supercat '02

2
这是访客模式在特定问题上的有趣应用,但并未描述模式本身或回答原始问题。“在不需要清理的情况下,访问者模式不是很有用。” 此声明绝对是错误的,仅与您的特定问题有关,与总体模式无关。
Tony O'Hagan

0

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

如果您经常向稳定的类系列添加,更改或删除功能,则访问者很有用,因为在每个访问者类中分别定义了每个功能项,并且类本身不需要更改。如果类的类别不稳定,则访问者模式的使用可能会减少,因为每次添加或删除类时,许多访问者都需要更改。


-1

一个很好的例子是源代码编译:

interface CompilingVisitor {
   build(SourceFile source);
}

客户端可以实现JavaBuilderRubyBuilderXMLValidator等,为项目中的收集和访问所有源文件的实施并不需要改变。

如果每种源文件类型都有单独的类,那么这将是一个糟糕的模式:

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

它取决于上下文以及您希望扩展系统的哪些部分。


具有讽刺意味的是,VisitorPattern为我们提供了使用错误模式的方法。它说,我们必须为要访问的每种节点定义访问方法。其次,不清楚您的例子是好是坏。它们与我的问题有什么关系?
2012年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.