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

发布于 2025-01-01 17:49:56 字数 1254 浏览 0 评论 0原文

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

他们像这样使用访问者,

abstract class Expr {
  public <T> T accept(Visitor<T> visitor) { return 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);
  }
}

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

PS1 请用你自己的话解释或者指出具体的解释。因为我收到的两个回复都提到了一些普遍且不确定的事情。

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

PS3 Scala 的模式匹配 = 类固醇上的访客模式 显示了没有accept 方法的Visitor 模式是多么简单。 维基百科添加了此声明:通过链接显示“that accept( ) 当反射可用时,方法是不必要的;为该技术引入了术语“Walkabout”。”

There is a lot of talk on decoupling the algorithms from the classes. But, one thing stays aside not explained.

They use visitor like this

abstract class Expr {
  public <T> T accept(Visitor<T> visitor) { return 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);
  }
}

Instead of calling visit(element) directly, Visitor asks the element to call its visit method. It contradicts the declared idea of class unawareness about visitors.

PS1 Please explain with your own words or point to exact explanation. Because two responses I got refer to something general and uncertain.

PS2 My guess: Since getLeft() returns the basic Expression, calling visit(getLeft()) would result in visit(Expression), whereas getLeft() calling visit(this) will result in another, more appropriate, visit invocation. So, accept() performs the type conversion (aka casting).

PS3 Scala's Pattern Matching = Visitor Pattern on Steroid shows how much simpler the Visitor pattern is without the accept method. Wikipedia adds to this statement: by linking a paper showing "that accept() methods are unnecessary when reflection is available; introduces term 'Walkabout' for the technique."

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(5

_畞蕅 2025-01-08 17:49:56

由于类 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 类型的变量都是如此。这是为什么呢?因为 Java 和其他类 C 语言在决定调用哪个重载时仅考虑参数的静态类型,或者变量声明的类型。 Java 不会在运行时为每个方法调用采取额外的步骤来询问“好吧,root 的动态类型是什么?哦,我明白了。它是一个 TrainNodeMyVisitor 中是否有任何方法接受 TrainNode 类型的参数...”。编译器在编译时确定将调用哪个方法。 (如果 Java 确实检查了参数的动态类型,那么性能将非常糟糕。)

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

访问者模式的目标是实现双重调度——不仅仅是访问者的类型考虑了调用目标(MyVisitor,通过虚拟方法),还考虑了参数的类型(我们正在查看什么类型的Node)?访问者模式允许我们通过访问/接受组合来做到这一点。

通过将我们的行更改为:

root.accept(new MyVisitor());

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

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

TrainNode范围内,编译器此时知道什么接受? 它知道this的静态类型是TrainNode。这是编译器在调用者范围内不知道的重要附加信息:在那里,它所知道的有关 root 的信息就是它是一个 Node。现在编译器知道 this (root) 不仅仅是一个 Node,它实际上是一个 TrainNode。因此,在 accept() 中找到的一行:v.visit(this) 完全意味着另一回事。编译器现在将查找采用 TrainNodevisit() 重载。如果找不到,则会将调用编译为采用 Node 的重载。如果两者都不存在,您将收到编译错误(除非您有一个采用 object 的重载)。因此,执行将进入我们一直想要的内容:MyVisitorvisit(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;
  }
}

转换不会让我们走得太远,因为我们不知道 leftright 的类型visit() 方法。我们的解析器很可能也只返回一个 Node 类型的对象,它也指向层次结构的根,所以我们也不能安全地转换它。因此,我们的简单解释器可以如下所示:

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

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

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

The visitor pattern's visit/accept constructs are a necessary evil due to C-like languages' (C#, Java, etc.) semantics. The goal of the visitor pattern is to use double-dispatch to route your call as you'd expect from reading the code.

Normally when the visitor pattern is used, an object hierarchy is involved where all the nodes are derived from a base Node type, referred to henceforth as Node. Instinctively, we'd write it like this:

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

Herein lies the problem. If our MyVisitor class was defined like the following:

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

If, at runtime, regardless of the actual type that root is, our call would go into the overload visit(Node node). This would be true for all variables declared of type Node. Why is this? Because Java and other C-like languages only consider the static type, or the type that the variable is declared as, of the parameter when deciding which overload to call. Java doesn't take the extra step to ask, for every method call, at runtime, "Okay, what is the dynamic type of root? Oh, I see. It's a TrainNode. Let's see if there's any method in MyVisitor which accepts a parameter of type TrainNode...". The compiler, at compile-time, determines which is the method that will be called. (If Java indeed did inspect the arguments' dynamic types, performance would be pretty terrible.)

Java does give us one tool for taking into account the runtime (i.e. dynamic) type of an object when a method is called -- virtual method dispatch. When we call a virtual method, the call actually goes to a table in memory that consists of function pointers. Each type has a table. If a particular method is overridden by a class, that class' function table entry will contain the address of the overridden function. If the class doesn't override a method, it will contain a pointer to the base class' implementation. This still incurs a performance overhead (each method call will basically be dereferencing two pointers: one pointing to the type's function table, and another of function itself), but it's still faster than having to inspect parameter types.

The goal of the visitor pattern is to accomplish double-dispatch -- not only is the type of the call target considered (MyVisitor, via virtual methods), but also the type of the parameter (what type of Node are we looking at)? The Visitor pattern allows us to do this by the visit/accept combination.

By changing our line to this:

root.accept(new MyVisitor());

We can get what we want: via virtual method dispatch, we enter the correct accept() call as implemented by the subclass -- in our example with TrainElement, we'll enter TrainElement's implementation of accept():

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

What does the compiler know at this point, inside the scope of TrainNode's accept? It knows that the static type of this is a TrainNode. This is an important additional shred of information that the compiler was not aware of in our caller's scope: there, all it knew about root was that it was a Node. Now the compiler knows that this (root) is not just a Node, but it's actually a TrainNode. In consequence, the one line found inside accept(): v.visit(this), means something else entirely. The compiler will now look for an overload of visit() that takes a TrainNode. If it can't find one, it'll then compile the call to an overload that takes a Node. If neither exist, you'll get a compilation error (unless you have an overload that takes object). Execution will thus enter what we had intended all along: MyVisitor's implementation of visit(TrainNode e). No casts were needed, and, most importantly, no reflection was needed. Thus, the overhead of this mechanism is rather low: it only consists of pointer references and nothing else.

You're right in your question -- we can use a cast and get the correct behavior. However, often, we don't even know what type Node is. Take the case of the following hierarchy:

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

And we were writing a simple compiler which parses a source file and produces a object hierarchy that conforms to the specification above. If we were writing an interpreter for the hierarchy implemented as a Visitor:

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

Casting wouldn't get us very far, since we don't know the types of left or right in the visit() methods. Our parser would most likely also just return an object of type Node which pointed at the root of the hierarchy as well, so we can't cast that safely either. So our simple interpreter can look like:

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

The visitor pattern allows us to do something very powerful: given an object hierarchy, it allows us to create modular operations that operate over the hierarchy without needing requiring to put the code in the hierarchy's class itself. The visitor pattern is used widely, for example, in compiler construction. Given the syntax tree of a particular program, many visitors are written that operate on that tree: type checking, optimizations, machine code emission are all usually implemented as different visitors. In the case of the optimization visitor, it can even output a new syntax tree given the input tree.

It has its drawbacks, of course: if we add a new type into the hierarchy, we need to also add a visit() method for that new type into the IVisitor interface, and create stub (or full) implementations in all of our visitors. We also need to add the accept() method too, for the reasons described above. If performance doesn't mean that much to you, there are solutions for writing visitors without needing the accept(), but they normally involve reflection and thus can incur quite a large overhead.

逆蝶 2025-01-08 17:49:56

当然,如果这是实现 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 方法包含有关如何导航到相邻节点的逻辑。 双重调度的情况是一种特殊情况,其中根本没有可导航到的相邻节点。

Of course that would be silly if that was the only way that Accept is implemented.

But it is not.

For example, visitors are really really useful when dealing with hierarchies in which case the implementation of a non-terminal node might be something like this

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

You see? What you describe as stupid is the solution for traversing hierarchies.

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

Edit:
To clarify: The visitor's Visit method contains logic to be applied to a node. The node's Accept method contains logic on how to navigate to adjacent nodes. The case where you only double dispatch is a special case where there are simply no adjacent nodes to navigate to.

梦一生花开无言 2025-01-08 17:49:56

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

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

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

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

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

The purpose of the Visitor pattern is to ensure that objects know when the visitor is finished with them and have departed, so the classes can perform any necessary cleanup afterward. It also allows classes to expose their internals "temporarily" as 'ref' parameters, and know that the internals will no longer be exposed once the visitor is gone. In cases where no cleanup is necessary, the visitor pattern isn't terribly useful. Classes which do neither of these things may not benefit from the visitor pattern, but code which is written to use the visitor pattern will be usable with future classes that may require cleanup after access.

For example, suppose one has a data structure holding many strings that should be updated atomically, but the class holding the data structure doesn't know precisely what types of atomic updates should be performed (e.g. if one thread wants to replace all occurrences of "X", while another thread wants to replace any sequence of digits with a sequence that is numerically one higher, both threads' operations should succeed; if each thread simply read out a string, performed its updates, and wrote it back, the second thread to write back its string would overwrite the first). One way to accomplish this would be to have each thread acquire a lock, perform its operation, and release the lock. Unfortunately, if locks are exposed in that way, the data structure would have no way of preventing someone from acquiring a lock and never releasing it.

The Visitor pattern offers (at least) three approaches to avoid that problem:

  1. It can lock a record, call the supplied function, and then unlock the record; the record could be locked forever if the supplied function falls into an endless loop, but if the supplied function returns or throws an exception, the record will be unlocked (it may be reasonable to mark the record invalid if the function throws an exception; leaving it locked is probably not a good idea). Note that it's important that if the called function attempts to acquire other locks, deadlock could result.
  2. On some platforms, it can pass a storage location holding the string as a 'ref' parameter. That function could then copy the string, compute a new string based upon the copied string, attempt to CompareExchange the old string to the new one, and repeat the whole process if the CompareExchange fails.
  3. It can make a copy of the string, call the supplied function on the string, then use CompareExchange itself to attempt to update the original, and repeat the whole process if the CompareExchange fails.

Without the visitor pattern, performing atomic updates would require exposing locks and risking failure if calling software fails to follow a strict locking/unlocking protocol. With the Visitor pattern, atomic updates can be done relatively safely.

影子的影子 2025-01-08 17:49:56

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

如果您经常向稳定的类系列添加、更改或删除功能,则访问者非常有用,因为每个功能项都是在每个访问者类中单独定义的,并且类本身不需要更改。如果类族不稳定,那么访问者模式可能用处不大,因为每次添加或删除类时,许多访问者都需要更改。

The classes that require modification must all implement the 'accept' method. Clients call this accept method to perform some new action on that family of classes thereby extending their functionality. Clients are able to use this one accept method to perform a wide range of new actions by passing in a different visitor class for each specific action. A visitor class contains multiple overridden visit methods defining how to achieve that same specific action for every class within the family. These visit methods get passed an instance on which to work.

Visitors are useful if you are frequently adding, altering or removing functionality to a stable family of classes because each item of functionality is defined seperately in each visitor class and the classes themselves do not need changing. If the family of classes is not stable then the visitor pattern may be of less use, because many visitors need changing each time a class is added or removed.

老街孤人 2025-01-08 17:49:56

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

interface CompilingVisitor {
   build(SourceFile source);
}

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

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

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

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

A good example is in source code compilation:

interface CompilingVisitor {
   build(SourceFile source);
}

Clients can implement a JavaBuilder, RubyBuilder, XMLValidator, etc. and the implementation for collecting and visiting all the source files in a project does not need to change.

This would be a bad pattern if you have separate classes for each source file type:

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

It comes down to context and what parts of the system you want to be extensible.

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文