我一直在博客中看到访客模式的参考,但我不得不承认,我就是不明白。我读了维基百科上关于这个模式的文章,我理解了它的机制,但我仍然不知道什么时候使用它。
作为一个最近才真正了解装饰器模式的人,现在看到它在任何地方都有使用,我希望能够真正直观地理解这个看似方便的模式。
我一直在博客中看到访客模式的参考,但我不得不承认,我就是不明白。我读了维基百科上关于这个模式的文章,我理解了它的机制,但我仍然不知道什么时候使用它。
作为一个最近才真正了解装饰器模式的人,现在看到它在任何地方都有使用,我希望能够真正直观地理解这个看似方便的模式。
当前回答
访问者模式作为方面对象编程的地下实现。
例如,如果您定义一个新操作,而不改变其操作的元素的类
其他回答
使用访问者模式至少有三个很好的理由:
减少代码的增殖,当数据结构发生变化时,代码只会略有不同。 将相同的计算应用于多个数据结构,而不改变实现计算的代码。 在不更改遗留代码的情况下向遗留库添加信息。
请看看我写的一篇关于这方面的文章。
一种看待它的方法是,访问者模式是一种让客户端向特定类层次结构中的所有类添加额外方法的方式。
当您有一个相当稳定的类层次结构,但您对需要对该层次结构做什么有不断变化的需求时,它是有用的。
经典的例子是编译器之类的。抽象语法树(AST)可以准确地定义编程语言的结构,但是您可能希望在AST上执行的操作将随着项目的进展而变化:代码生成器、漂亮的打印机、调试器、复杂性度量分析。
如果没有访问者模式,每次开发人员想要添加一个新特性时,他们都需要将该方法添加到基类中的每个特性中。当基类出现在单独的库中或由单独的团队生成时,这尤其困难。
(我听说访问者模式与良好的OO实践相冲突,因为它将数据的操作从数据中移开了。访问者模式在正常的OO实践失败的情况下非常有用。)
我不理解这种模式,直到我看到bob叔叔的文章和评论。 考虑下面的代码:
public class Employee
{
}
public class SalariedEmployee : Employee
{
}
public class HourlyEmployee : Employee
{
}
public class QtdHoursAndPayReport
{
public void PrintReport()
{
var employees = new List<Employee>
{
new SalariedEmployee(),
new HourlyEmployee()
};
foreach (Employee e in employees)
{
if (e is HourlyEmployee he)
PrintReportLine(he);
if (e is SalariedEmployee se)
PrintReportLine(se);
}
}
public void PrintReportLine(HourlyEmployee he)
{
System.Diagnostics.Debug.WriteLine("hours");
}
public void PrintReportLine(SalariedEmployee se)
{
System.Diagnostics.Debug.WriteLine("fix");
}
}
class Program
{
static void Main(string[] args)
{
new QtdHoursAndPayReport().PrintReport();
}
}
虽然它看起来很好,因为它确认了单一责任,但它违反了开放/封闭原则。每次你有新的员工类型,你将不得不添加如果与类型检查。如果你不知道,在编译时你永远也不会知道。
使用访问者模式,你可以让你的代码更干净,因为它不违反开放/关闭原则,也不违反单一责任。如果你忘记实现visit,它将不会编译:
public abstract class Employee
{
public abstract void Accept(EmployeeVisitor v);
}
public class SalariedEmployee : Employee
{
public override void Accept(EmployeeVisitor v)
{
v.Visit(this);
}
}
public class HourlyEmployee:Employee
{
public override void Accept(EmployeeVisitor v)
{
v.Visit(this);
}
}
public interface EmployeeVisitor
{
void Visit(HourlyEmployee he);
void Visit(SalariedEmployee se);
}
public class QtdHoursAndPayReport : EmployeeVisitor
{
public void Visit(HourlyEmployee he)
{
System.Diagnostics.Debug.WriteLine("hourly");
// generate the line of the report.
}
public void Visit(SalariedEmployee se)
{
System.Diagnostics.Debug.WriteLine("fix");
} // do nothing
public void PrintReport()
{
var employees = new List<Employee>
{
new SalariedEmployee(),
new HourlyEmployee()
};
QtdHoursAndPayReport v = new QtdHoursAndPayReport();
foreach (var emp in employees)
{
emp.Accept(v);
}
}
}
class Program
{
public static void Main(string[] args)
{
new QtdHoursAndPayReport().PrintReport();
}
}
}
神奇的是,虽然v.Visit(this)看起来一样,但实际上是不同的,因为它调用不同的访问者重载。
你困惑的原因可能是来客是一个致命的用词不当。许多(杰出的)程序员都曾遇到过这个问题。它实际做的是用本身不支持它的语言(大多数语言不支持)实现双重调度。
1)我最喜欢的例子是《Effective c++》的作者Scott Meyers,他称这是他最重要的c++啊哈之一!的时刻。
双重分派只是使用此模式的众多原因之一。 但请注意,这是在使用单一分派范式的语言中实现双分派或多分派的唯一方法。
以下是使用该模式的原因:
1)我们希望定义新的操作,而不需要每次都改变模型,因为模型不会经常变化,而操作则经常变化。
2)我们不想把模型和行为结合起来,因为我们想要在多个应用程序中有一个可重用的模型,或者我们想要一个可扩展的模型,允许客户端类用它们自己的类定义它们的行为。
3)我们有依赖于模型的具体类型的通用操作,但我们不想在每个子类中实现逻辑,因为这会在多个类中爆炸通用逻辑,因此在多个地方。
4)我们正在使用领域模型设计,相同层次结构的模型类执行太多不同的事情,而这些事情可能会聚集在其他地方。
5) We need a double dispatch. We have variables declared with interface types and we want to be able to process them according their runtime type … of course without using if (myObj instanceof Foo) {} or any trick. The idea is for example to pass these variables to methods that declares a concrete type of the interface as parameter to apply a specific processing. This way of doing is not possible out of the box with languages relies on a single-dispatch because the chosen invoked at runtime depends only on the runtime type of the receiver. Note that in Java, the method (signature) to call is chosen at compile time and it depends on the declared type of the parameters, not their runtime type.
最后一点是使用访问器的一个原因,也是一个后果,因为当您实现访问器时(当然对于不支持多分派的语言),您必然需要引入双分派实现。
请注意,遍历元素(迭代)以在每个元素上应用访问者并不是使用该模式的原因。 您使用模式是因为您分离了模型和处理。 通过使用该模式,您还可以从迭代器功能中获益。 这种能力非常强大,并且超越了使用特定方法对普通类型进行迭代的能力,因为accept()是一个泛型方法。 这是一个特殊的用例。我先把这个放到一边。
Java示例
我将用一个国际象棋的例子来说明该模式的附加价值,在这个例子中,我们想要定义处理,即玩家要求棋子移动。
如果不使用访问者模式,我们可以直接在pieces子类中定义块移动行为。 例如,我们可以有一个Piece接口,如:
public interface Piece{
boolean checkMoveValidity(Coordinates coord);
void performMove(Coordinates coord);
Piece computeIfKingCheck();
}
每个Piece子类将实现它,如:
public class Pawn implements Piece{
@Override
public boolean checkMoveValidity(Coordinates coord) {
...
}
@Override
public void performMove(Coordinates coord) {
...
}
@Override
public Piece computeIfKingCheck() {
...
}
}
对所有Piece子类也是一样的。 下面是一个说明这种设计的图表类:
这种方法有三个重要的缺点:
-行为如performMove()或computeIfKingCheck()将很可能使用公共逻辑。 例如,无论具体的Piece是什么,performMove()最终都会将当前的Piece设置到特定的位置,并可能获取对手的Piece。 将相关的行为拆分到多个类中,而不是将它们聚集在一起,在某种程度上挫败了单一责任模式。使它们的可维护性更加困难。
处理checkMoveValidity()不应该是Piece子类可以看到或改变的东西。 它是超越人类或计算机行为的检查。该检查在玩家请求的每个动作中执行,以确保所请求的棋子移动是有效的。 我们甚至不想在Piece界面中提供这个。
-在对机器人开发者具有挑战性的国际象棋游戏中,应用程序通常提供标准API(棋子接口、子类、棋盘、常见行为等),并让开发者丰富他们的机器人策略。 为了做到这一点,我们必须提出一个模型,其中数据和行为在Piece实现中不是紧密耦合的。
让我们使用访问者模式!
我们有两种结构:
-接受访问的模型类(碎片)
-拜访他们的访客(移动操作)
下面是说明该模式的类图:
上面的部分是访问者,下面的部分是模型类。
下面是PieceMovingVisitor接口(为每种Piece指定的行为):
public interface PieceMovingVisitor {
void visitPawn(Pawn pawn);
void visitKing(King king);
void visitQueen(Queen queen);
void visitKnight(Knight knight);
void visitRook(Rook rook);
void visitBishop(Bishop bishop);
}
Piece的定义如下:
public interface Piece {
void accept(PieceMovingVisitor pieceVisitor);
Coordinates getCoordinates();
void setCoordinates(Coordinates coordinates);
}
它的关键方法是:
void accept(PieceMovingVisitor pieceVisitor);
它提供了第一个分派:基于Piece接收器的调用。 在编译时,该方法被绑定到Piece接口的accept()方法,在运行时,该绑定方法将在运行时的Piece类上调用。 accept()方法实现将执行第二次分派。
实际上,每个Piece子类都希望被PieceMovingVisitor对象访问,通过传递参数本身来调用PieceMovingVisitor.visit()方法。 通过这种方式,编译器在编译时立即将声明参数的类型与具体类型绑定。 这是第二次调度。 下面是Bishop子类说明了这一点:
public class Bishop implements Piece {
private Coordinates coord;
public Bishop(Coordinates coord) {
super(coord);
}
@Override
public void accept(PieceMovingVisitor pieceVisitor) {
pieceVisitor.visitBishop(this);
}
@Override
public Coordinates getCoordinates() {
return coordinates;
}
@Override
public void setCoordinates(Coordinates coordinates) {
this.coordinates = coordinates;
}
}
下面是一个用法示例:
// 1. Player requests a move for a specific piece
Piece piece = selectPiece();
Coordinates coord = selectCoordinates();
// 2. We check with MoveCheckingVisitor that the request is valid
final MoveCheckingVisitor moveCheckingVisitor = new MoveCheckingVisitor(coord);
piece.accept(moveCheckingVisitor);
// 3. If the move is valid, MovePerformingVisitor performs the move
if (moveCheckingVisitor.isValid()) {
piece.accept(new MovePerformingVisitor(coord));
}
游客的缺点
访问者模式是一种非常强大的模式,但它也有一些重要的限制,在使用它之前应该考虑这些限制。
1)降低/破坏封装的风险
在某些类型的操作中,访问者模式可能会减少或破坏域对象的封装。
例如,MovePerformingVisitor类需要设置实际piece的坐标,piece接口必须提供一种方法来做到这一点:
void setCoordinates(Coordinates coordinates);
The responsibility of Piece coordinates changes is now open to other classes than Piece subclasses. Moving the processing performed by the visitor in the Piece subclasses is not an option either. It will indeed create another issue as the Piece.accept() accepts any visitor implementation. It doesn't know what the visitor performs and so no idea about whether and how to change the Piece state. A way to identify the visitor would be to perform a post processing in Piece.accept() according to the visitor implementation. It would be a very bad idea as it would create a high coupling between Visitor implementations and Piece subclasses and besides it would probably require to use trick as getClass(), instanceof or any marker identifying the Visitor implementation.
2)变更模型的要求
与Decorator等其他行为设计模式相反,访问者模式是侵入式的。 我们确实需要修改初始接收者类,以提供一个accept()方法来接受访问。 对于Piece和它的子类,我们没有任何问题,因为这些是我们的类。 在内置类或第三方类中,事情就不那么容易了。 我们需要包装或继承(如果可以的话)它们来添加accept()方法。
3)间接
该模式创建多个间接点。 双分派意味着两次调用而不是一次调用:
call the visited (piece) -> that calls the visitor (pieceMovingVisitor)
当访问者改变被访问对象的状态时,我们可以有额外的间接指示。 它可能看起来像一个循环:
call the visited (piece) -> that calls the visitor (pieceMovingVisitor) -> that calls the visited (piece)