作为最近真正理解装饰器模式并且现在无处不见其用途的人,我希望能够直观地理解这个看似方便的模式。
我对访问者模式不是很熟悉,请看我是否理解正确。假设您有一组动物的层次结构
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); }
最后,我们实现了实际操作,而不修改猫或狗:
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);
}
letsDo(Operation *v)
需要一个指针。 - AquilaRapax1) 我最喜欢的例子是Scott Meyers,他是“Effective C ++”的著名作者,他将这个称为他有史以来最重要的C ++ aha!时刻之一。
switch
相比:switch
在客户端硬编码决策(代码重复),并且不提供静态类型检查(检查情况的完整性和差异等)。访问者模式由类型检查器验证,并且通常使客户端代码更简单。 - Konrad Rudolphvirtual
这样的特性在现代编程语言中非常有用 - 它们是可扩展程序的基本构建块 - 在我看来,如果代码不需要可扩展性,那么c方式(嵌套开关或模式匹配等,具体取决于您选择的语言)要干净得多,我很高兴地看到像prover 9这样复杂软件中出现了这种风格。更重要的是,任何想要提供可扩展性的语言都应该容纳比递归单分派(即访问者)更好的调度模式。 - user3125280大家的回答都是正确的,但我觉得他们没有解决“何时”的问题。首先,来自《设计模式》的定义:
访问者模式可以在不改变操作元素类的前提下定义新的操作。
现在,我们来想一个简单的类层次结构。我有1、2、3和4类以及A、B、C和D方法。将它们像在电子表格里一样排列:类是行,方法是列。
现在,面向对象设计假设你更可能增加新的类而不是新的方法,所以添加更多行比较容易。你只需要添加一个新类,指定该类中不同的部分,并继承其余部分。
然而,有时候类相对静态,但你需要经常添加更多的方法——添加列。在面向对象设计中,标准的做法是将这些方法添加到所有类中,这可能代价很高。访问者模式使这个过程变得容易。
顺便说一下,这也是Scala模式匹配旨在解决的问题。
访问者设计模式非常适用于像目录树、XML结构或文档大纲这样的“递归”结构。
访问者对象访问递归结构中的每个节点:每个目录、每个XML标记,以此类推。访问者对象不会遍历整个结构。相反,访问者方法应用于结构的每个节点。
下面是一个典型的递归节点结构。它可以是目录或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
方法会将一个访问者对象应用于结构中的每个节点。在这种情况下,它是一个自顶向下的访问者。您可以更改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
。它创建了一个访问者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实践失败的情况下非常有用。)
双重分派(Double dispatch)只是使用该模式的一个原因,而不是唯一原因。但请注意,在使用单调派范式的语言中,它是实现双或多重分派的唯一方式。
以下是使用该模式的原因:
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() {
...
}
}
所有棋子的子类也是一样。以下是一个类图,展示了这种设计:
这种方法存在三个主要缺点:
1. performMove()
或computeIfKingCheck()
等行为很可能会使用共同的逻辑,例如,不论具体的棋子是什么,performMove()
最终都会把当前棋子移动到特定位置,并有可能拿下对手的棋子。将相关的行为拆分成多个类而不是集中在一起,不完全符合单一职责模式,使得可维护性变差。
checkMoveValidity()
检验结果。这个检测超越了人类或计算机可以决策的范围,在玩家每次请求移动操作时执行,以确保被请求的棋子移动是有效的。因此,我们甚至不想在Piece
接口中提供它。Piece
接口、子类、Board、共同的行为等),并让开发者丰富其 bot 策略。为了能够达到这个目标,我们必须提出一个数据和行为不紧密耦合在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
接收器的调用。
在编译时,该方法绑定到 Piece 接口的 accept()
方法上,在运行时,绑定的方法将在运行时的 Piece
类上调用。
而正是 accept()
方法的实现执行了第二次分派。
事实上,每个想要被 PieceMovingVisitor
对象访问的 Piece
子类都通过将自身作为参数传递来调用 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.accept()
中执行后处理。这将是一个非常糟糕的想法,因为它会在Visitor实现和Piece子类之间创建高耦合,并且可能需要使用getClass()
、instanceof
或标记来识别Visitor实现等技巧。
2) 更改模型的要求
与其他一些行为设计模式(如Decorator)相反,访问者模式是有侵入性的。
我们确实需要修改最初的接收方类以提供accept()
方法以接受访问。
对于内置或第三方类,情况并不那么容易。
我们需要包装或继承(如果可以的话)它们以添加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)
使用访问者模式至少有三个很好的理由:
减少当数据结构变化时,代码仅略微不同的扩散。
在不更改实现计算的代码的情况下,将相同的计算应用于多个数据结构。
向遗留库添加信息而不更改遗留代码。
请查看我写的一篇文章了解更多信息。
void SwitchOnBlueTooth(IMobileDevice mobileDevice, IBlueToothRadio blueToothRadio)
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
包含药丸对,因此您需要将药丸对的数量乘以 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;
}
}
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
}
PillCountVisitor
的类中(我们删除了 switch case 语句)。这意味着,每当你需要添加新类型的药品容器时,只需更改 PillCountVisitor
类。还请注意,IVisitor
接口是通用的,可用于其他场景。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
中有药丸数量的值。public class HourlyEmployee extends Employee {
public String reportQtdHoursAndPay() {
//generate the line for this hourly employee
}
}
reportQtdHoursAndPay
方法是用于报告和表示的,这违反了单一职责原则。因此,最好使用访问者模式来解决这个问题。
访问者模式的快速描述:需要修改的类必须全部实现'accept'方法。客户端调用此accept方法来对该类族执行一些新操作,从而扩展其功能。通过为每个特定操作传递不同的访问者类,客户可以使用这一个accept方法执行各种范围的新操作。访问者类包含多个重写的visit方法,定义如何为家族中的每个类执行相同的特定操作。这些visit方法会传递一个实例进行操作。
何时考虑使用它