我一直在博客中看到对访客模式的引用,但我必须承认,我只是不明白.我阅读了维基百科文章的模式,我理解它的机制,但我仍然对我何时使用它感到困惑.
作为最近刚刚获得装饰模式的人,现在看到它在任何地方的用途我都希望能够直观地理解这个看似方便的模式.
我对访客模式不太熟悉.让我们看看我是否做对了.假设你有一个动物的层次结构
class Animal { };
class Dog: public Animal { };
class Cat: public Animal { };
(假设它是一个复杂的层次结构,具有完善的接口.)
现在我们想要在层次结构中添加一个新操作,即我们希望每个动物发出声音.对于层次结构这么简单,您可以使用直接多态:
class Animal
{ public: virtual void makeSound() = 0; };
class Dog : public Animal
{ public: void makeSound(); };
void Dog::makeSound()
{ std::cout << "woof!\n"; }
class Cat : public Animal
{ public: void makeSound(); };
void Cat::makeSound()
{ std::cout << "meow!\n"; }
但是以这种方式继续,每次要添加操作时,必须将接口修改为层次结构的每个类.现在,假设您对原始界面感到满意,并且您希望对其进行尽可能少的修改.
访问者模式允许您在适当的类中移动每个新操作,并且您只需要扩展层次结构的接口一次.我们开始做吧.首先,我们定义一个抽象操作(GoF中的"Visitor"类),它为层次结构中的每个类都有一个方法:
class Operation
{
public:
virtual void hereIsADog(Dog *d) = 0;
virtual void hereIsACat(Cat *c) = 0;
};
然后,我们修改层次结构以接受新操作:
class Animal
{ public: virtual void letsDo(Operation *v) = 0; };
class Dog : public Animal
{ public: void letsDo(Operation *v); };
void Dog::letsDo(Operation *v)
{ v->hereIsADog(this); }
class Cat : public Animal
{ public: void letsDo(Operation *v); };
void Cat::letsDo(Operation *v)
{ v->hereIsACat(this); }
最后,我们实施实际操作,既不修改Cat也不修改Dog:
class Sound : public Operation
{
public:
void hereIsADog(Dog *d);
void hereIsACat(Cat *c);
};
void Sound::hereIsADog(Dog *d)
{ std::cout << "woof!\n"; }
void Sound::hereIsACat(Cat *c)
{ std::cout << "meow!\n"; }
现在,您可以在不修改层次结构的情况下添加操作.下面是它的工作原理:
int main()
{
Cat c;
Sound theSound;
c.letsDo(&theSound);
}
你混淆的原因可能是访客是一个致命的误称.许多(突出的1!)程序员偶然发现了这个问题.它实际上做的是在本地不支持它的语言中实现双重调度(大多数不支持).
1)我最喜欢的例子是着名的"Effective C++"的作者Scott Meyers,他称这是他最重要的C++之一!时刻永远.
这里的每个人都是正确的,但我认为它没有解决"何时".首先,来自设计模式:
访问者允许您定义新操作,而无需更改其操作的元素的类.
现在,让我们想一个简单的类层次结构.我有类1,2,3和4以及方法A,B,C和D.将它们放在电子表格中:类是行,方法是列.
现在,面向对象的设计假设你比新方法更有可能增长新的类,所以可以说,添加更多的行更容易.您只需添加一个新类,指定该类中的不同内容,并继承其余类.
但有时候,这些类是相对静态的,但您需要经常添加更多方法 - 添加列.OO设计中的标准方法是将这些方法添加到所有类中,这可能是昂贵的.访客模式使这很容易.
顺便说一句,这是Scala的模式匹配打算解决的问题.
该访问者设计模式可以很好地表现就像目录树,XML结构或文件概述"递归"结构.
Visitor对象访问递归结构中的每个节点:每个目录,每个XML标记,等等.Visitor对象不会遍历结构.而是将Visitor方法应用于结构的每个节点.
这是典型的递归节点结构.可以是目录或XML标记.[如果你是一个Java人,想象一下构建和维护子列表的许多额外方法.]
class TreeNode( object ): def __init__( self, name, *children ): self.name= name self.children= children def visit( self, someVisitor ): someVisitor.arrivedAt( self ) someVisitor.down() for c in self.children: c.visit( someVisitor ) someVisitor.up()
该visit
方法将Visitor对象应用于结构中的每个节点.在这种情况下,它是一个自上而下的访客.您可以更改visit
方法的结构以进行自下而上或其他一些排序.
这是访客的超类.它被visit
方法使用.它"到达"结构中的每个节点.由于该visit
方法调用up
和down
,游客可以跟踪深度.
class Visitor( object ): def __init__( self ): self.depth= 0 def down( self ): self.depth += 1 def up( self ): self.depth -= 1 def arrivedAt( self, aTreeNode ): print self.depth, aTreeNode.name
子类可以执行诸如计算每个级别的节点并累积节点列表之类的事情,从而生成一个很好的路径分层节数.
这是一个应用程序.它构建了一个树形结构someTree
.它创造了一个Visitor
,dumpNodes
.
然后它将应用于dumpNodes
树.该dumpNode
对象将"访问"树中的每个节点.
someTree= TreeNode( "Top", TreeNode("c1"), TreeNode("c2"), TreeNode("c3") ) dumpNodes= Visitor() someTree.visit( dumpNodes )
TreeNode visit
算法将确保每个TreeNode都用作Visitor的arrivedAt
方法的参数.
查看它的一种方法是访问者模式是一种让客户向特定类层次结构中的所有类添加其他方法的方法.
当您具有相当稳定的类层次结构时,它很有用,但您需要更改需要对该层次结构执行的操作的要求.
经典的例子是编译器之类的.抽象语法树(AST)可以准确地定义编程语言的结构,但是您可能希望对AST执行的操作将随着项目的进展而改变:代码生成器,漂亮打印机,调试器,复杂度指标分析.
如果没有访问者模式,每次开发人员想要添加新功能时,他们都需要将该方法添加到基类中的每个功能.当基类出现在单独的库中或由单独的团队生成时,这尤其困难.
(我听说它认为访问者模式与良好的OO实践相冲突,因为它会使数据的操作远离数据.访问者模式在正常OO实践失败的情况下非常有用.)
使用访客模式至少有三个非常好的理由:
减少代码的扩散,这在数据结构发生变化时略有不同.
将相同的计算应用于多个数据结构,而不更改实现计算的代码.
在不更改旧代码的情况下将信息添加到旧库.
请看一篇我写的关于此的文章.
正如Konrad Rudolph已经指出的那样,它适用于我们需要双重调度的情况
这是一个示例,显示我们需要双重调度的情况以及访问者如何帮助我们这样做.
示例:
可以说我有3种类型的移动设备 - iPhone,Android,Windows Mobile.
所有这三个设备都安装了蓝牙无线电.
让我们假设蓝牙收音机可以来自两个独立的原始设备制造商 - 英特尔和Broadcom.
为了使示例与我们的讨论相关,我们还假设英特尔无线电公开的API与Broadcom无线电公开的API不同.
这就是我的课程外观 -
现在,我想介绍一项操作 - 在移动设备上打开蓝牙.
它的功能签名应该像这样 -
void SwitchOnBlueTooth(IMobileDevice mobileDevice, IBlueToothRadio blueToothRadio)
所以,这取决于权类型的设备,并根据不同类型正确蓝牙无线电的,它可以通过接通调用适当的步骤或算法.
原则上,它变成一个3 x 2矩阵,其中我试图根据所涉及的对象的正确类型来引导正确的操作.
多态行为取决于两个参数的类型.
现在,访客模式可以应用于此问题.灵感来自维基百科页面,指出 - "实质上,访问者允许一个人在不修改类本身的情况下向一个类族添加新的虚函数; 相反,我们创建了一个访问者类,它实现了虚函数的所有适当的特化.访问者将实例引用作为输入,并通过双重调度实现目标."
由于3x2矩阵,双重调度是必要的
以下是设置的外观 -
我写了这个例子来回答另一个问题,这里提到了代码及其解释.
我发现以下链接更容易:
在
http://www.remondo.net/visitor-pattern-example-csharp/中,我找到了一个示例,其中显示了一个模拟示例,显示了访问者模式的好处.这里有不同的容器类Pill
:
namespace DesignPatterns { public class BlisterPack { // Pairs so x2 public int TabletPairs { get; set; } } public class Bottle { // Unsigned public uint Items { get; set; } } public class Jar { // Signed public int Pieces { get; set; } } }
正如您在上面看到的那样,您需要BilsterPack
包含一对Pills',因此您需要将对的数量乘以2.此外,您可能会注意到Bottle
使用unit
不同的数据类型并需要进行转换.
因此,在主要方法中,您可以使用以下代码计算药丸计数:
foreach (var item in packageList) { if (item.GetType() == typeof (BlisterPack)) { pillCount += ((BlisterPack) item).TabletPairs * 2; } else if (item.GetType() == typeof (Bottle)) { pillCount += (int) ((Bottle) item).Items; } else if (item.GetType() == typeof (Jar)) { pillCount += ((Jar) item).Pieces; } }
请注意,上面的代码违反了Single Responsibility Principle
.这意味着如果添加新类型的容器,则必须更改主方法代码.使开关更长是不好的做法.
因此,通过引入以下代码:
public class PillCountVisitor : IVisitor { public int Count { get; private set; } #region IVisitor Members public void Visit(BlisterPack blisterPack) { Count += blisterPack.TabletPairs * 2; } public void Visit(Bottle bottle) { Count += (int)bottle.Items; } public void Visit(Jar jar) { Count += jar.Pieces; } #endregion }
你把计算Pill
s 数量的责任转移到了所谓的类PillCountVisitor
(并且我们删除了switch case语句).这意味着每当你需要添加新型药丸容器时,你应该只改变PillCountVisitor
课程.另请注意,IVisitor
界面通常用于其他场景.
通过将Accept方法添加到药丸容器类:
public class BlisterPack : IAcceptor { public int TabletPairs { get; set; } #region IAcceptor Members public void Accept(IVisitor visitor) { visitor.Visit(this); } #endregion }
我们允许访客参观药丸容器类.
最后,我们使用以下代码计算药丸计数:
var visitor = new PillCountVisitor(); foreach (IAcceptor item in packageList) { item.Accept(visitor); }
这意味着:每个药丸容器都允许PillCountVisitor
访客看到他们的药丸数量.他知道如何计算你的药丸.
在visitor.Count
具有药丸的价值.
在 http://butunclebob.com/ArticleS.UncleBob.IuseVisitor中,您会看到不能使用多态(答案)遵循单一责任原则的真实场景.事实上在:
public class HourlyEmployee extends Employee { public String reportQtdHoursAndPay() { //generate the line for this hourly employee } }
该reportQtdHoursAndPay
方法用于报告和表示,这违反了单一责任原则.所以最好使用访问者模式来克服这个问题.
双重调度只是使用此模式的原因之一。
但是请注意,这是使用单一调度范式在语言中实现双重或更多调度的唯一方法。
这是使用此模式的原因:
1)我们希望在不每次更改模型的情况下定义新操作,因为模型不会经常更改,而操作会频繁更改。
2)我们不想将模型和行为耦合在一起,因为我们希望在多个应用程序中具有可重用的模型,或者希望拥有一个可扩展的模型,该模型允许客户端类使用自己的类来定义其行为。
3)我们有依赖于模型具体类型的通用操作,但是我们不想在每个子类中实现逻辑,因为这会在多个类中以及因此在多个地方爆炸通用逻辑。
4)我们正在使用领域模型设计,并且相同层次结构的模型类执行太多不同的事情,这些事情可能在其他地方收集。
5)我们需要双重派遣。
我们有使用接口类型声明的变量,我们希望能够根据其运行时类型来处理它们……当然,无需使用if (myObj instanceof Foo) {}
任何技巧。
例如,想法是将这些变量传递给方法,这些方法将接口的具体类型声明为要应用特定处理的参数。这种处理方式不可能开箱即用,因为语言依赖于单调度,因为在运行时调用的所选内容仅取决于接收器的运行时类型。
请注意,在Java中,要调用的方法(签名)是在编译时选择的,它取决于参数的声明类型,而不是参数的运行时类型。
最后一个使用访问者的原因也是一个后果,因为在实现访问者时(当然,对于不支持多调度的语言),您必须引入双重调度实现。
请注意,遍历要在每个对象上应用访问者的元素(迭代)并不是使用模式的原因。
使用模式是因为您拆分了模型和处理。
通过使用该模式,您还可以从迭代器功能中受益。
这种能力非常强大,并且超越accept()
了普通方法的通用类型的迭代。
这是一个特殊的用例。因此,我将其放在一边。
Java范例
我将通过一个国际象棋示例来说明该模式的附加值,在该示例中,我们希望根据玩家要求移动棋子来定义处理。
如果没有使用访客模式,我们可以直接在件子类中定义件移动行为。
例如,我们可以有一个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()
最终都会将当前棋子设置到特定位置,并有可能拿走对手棋子。
将相关行为拆分为多个类,而不是收集它们,以某种方式破坏了单一责任模式。使其难以维护。
–处理子类可能checkMoveValidity()
不会Piece
看到或更改的东西。
这是超越人为或计算机行为的检查。在玩家要求的每个动作中执行此检查,以确保所要求的棋子移动有效。
所以我们甚至不想在Piece
界面中提供它。
–在对机器人开发人员具有挑战性的国际象棋游戏中,通常该应用程序提供标准的API(Piece
接口,子类,开发板,常见行为等),并使开发人员丰富其机器人策略。
为了做到这一点,我们必须提出一个模型,其中数据和行为在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); }
现在定义了作品:
public interface Piece { void accept(PieceMovingVisitor pieceVisitor); Coordinates getCoordinates(); void setCoordinates(Coordinates coordinates); }
其关键方法是:
void accept(PieceMovingVisitor pieceVisitor);
它提供了第一个调度:基于Piece
接收方的调用。
在编译时,该方法绑定到accept()
Piece接口的方法,并且在运行时,将在运行时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
接口必须提供一种方法:
void setCoordinates(Coordinates coordinates);
Piece
坐标更改的责任现在对子类以外的其他类开放Piece
。
移动访问者在Piece
子类中执行的处理也不是一种选择。
的确,由于Piece.accept()
接受任何访客实现,这确实会带来另一个问题。它不知道访问者执行什么操作,因此不知道是否以及如何更改Piece状态。
识别访问者的一种方法是Piece.accept()
根据访问者的实现方式执行后处理。这将是一个非常糟糕的主意,因为它会在Visitor实现和Piece子类之间建立高度的耦合,此外,可能还需要使用rick作为getClass()
,instanceof
或使用任何标识Visitor实现的标记。
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)
在我看来,添加新操作的工作量或多或少与使用Visitor Pattern
或直接修改每个元素结构相同.另外,如果我要添加新的元素类,比如说Cow
,Operation接口会受到影响,并且会传播到所有现有的元素类,因此需要重新编译所有元素类.那有什么意义呢?
访问者模式与Aspect对象编程的相同地下实现相同。
例如,如果您定义一个新操作而不更改其所操作元素的类别
Cay Horstmann有一个很好的例子,可以在他的OO设计和模式书中应用Visitor.他总结了这个问题:
复合对象通常具有复杂的结构,由单个元素组成.一些元素可能再次具有子元素....对元素的操作访问其子元素,将操作应用于它们,并组合结果....但是,在这样的设计中添加新操作并不容易.
之所以不容易,是因为操作是在结构类本身中添加的.例如,假设您有一个文件系统:
以下是我们可能希望使用此结构实现的一些操作(功能):
显示节点元素的名称(文件列表)
显示计算出的节点元素的大小(目录的大小包括其所有子元素的大小)
等等
您可以在FileSystem中为每个类添加函数来实现操作(过去人们已经完成了这个操作,因为它非常明显如何操作).问题是无论何时添加新功能(上面的"等"行),您可能需要向结构类添加越来越多的方法.在某些时候,在您添加到软件中的一些操作之后,这些类中的方法在类的功能内聚方面不再有意义.例如,您有一个FileNode
方法calculateFileColorForFunctionABC()
,以便在文件系统上实现最新的可视化功能.
访客模式(就像许多设计模式一样)诞生于开发人员的痛苦和苦难,他们知道有更好的方法可以让代码改变而不需要在任何地方进行大量更改,并且还尊重良好的设计原则(高内聚,低耦合) ).我认为,在你感受到疼痛之前,很难理解很多模式的用处.解释疼痛(就像我们尝试在上面添加"等"功能一样)会在解释中占用空间并且会分散注意力.由于这个原因,理解模式很难.
访问者允许我们将数据结构(例如FileSystemNodes
)的功能与数据结构本身分离.该模式允许设计尊重内聚 - 数据结构类更简单(它们具有更少的方法),并且功能被封装到Visitor
实现中.这是通过双调度(这是模式的复杂部分)完成的:使用Visitor(功能)类accept()
中的结构类和visitX()
方法中的方法:
这种结构允许我们添加在结构上起作用的新功能作为具体的访问者(不改变结构类).
例如,一个PrintNameVisitor
实现目录列表功能,一个PrintSizeVisitor
实现具有大小的版本.我们可以想象有一天会有一个以XML格式生成数据的"ExportXMLVisitor",或者用JSON生成它的另一个访问者等等.我们甚至可以让一个访问者使用DOT等图形语言显示我的目录树,以便可视化与另一个程序.
作为最后一点:访问者的双重调度的复杂性意味着它更难理解,编码和调试.简而言之,它具有极高的极客因素,并且再次成为KISS原则.在研究人员进行的一项调查中,访客被证明是一种有争议的模式(对其有用性没有达成共识).一些实验甚至表明它没有使代码更容易维护.
访客模式的快速描述。需要修改的类必须全部实现'accept'方法。客户调用此accept方法对该类系列执行一些新操作,从而扩展其功能。通过为每个特定操作传递不同的访问者类,客户可以使用这一接受方法来执行各种新操作。访客类包含多个重写的访问方法,这些方法定义如何为家庭中的每个类实现相同的特定操作。这些访问方法传递了一个可以在其上工作的实例。
当您考虑使用它时
当您拥有一类班级时,您知道自己将不得不添加许多新动作,但是由于某些原因,您将来将无法更改或重新编译这一系列班级。
当您想添加一个新动作并在一个访问者类中完全定义该新动作时,而不是分散在多个类中。
当你的老板说,你必须生产出一系列必须做点什么类现在!...但没有人真正知道的东西到底是什么呢。