看图识模式

比如说有一个农场(这是一个结构体),里面包括了木头、牛羊、空闲的土地(结构体里的元素)

需求

  • 需求一: 我要在这里生活,所以要建房子、生火做饭
  • 需求二: 我要在这里开工厂,所以要建厂房、生成火腿肠

这些需求都可以通过农场里的材料进行不同的加工来完成。

分析

我们如何设计这个类? 想一下~

其实很简单,给这个类添加2个方法。一个方法建房子上火做饭,另一个方法建厂房生产火腿。
如果这时候有军队提出需求,需要建堡垒、生产肉罐头。我们可以再加一个新方法来满足它。但是这样有一些问题:
首先,它不符合开放封闭原则,且这个类过于复杂,不利于维护。随着功能的增加,这个类会越来越臃肿。

猜想

通过上面的例子,我们知道这个农场是稳定的(里面就是木材、牛羊和土地),但是大家的需求不一样,导致对这个农场进行的操作也不一样。
那我们可不可以把农场和对农场的操作分离出来。不同人的来访问这个农场就会进行不同的操作。

clipboard.png

农民来访问它,建房子生火做饭
商人来访问它,建厂房生产火腿
军人访问它,建堡垒生产肉罐头

好处: 操作集合从数据结构中分离出来了,可以相对独立自由的演化。

模式定义

表示一个作用于某对象结构中的各元素的操作。它让我们可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

先来看第一句话,说是一个作用于某对象结构中的各元素的操作,这里提到了三个事物,一个是对象结构,一个是各元素,一个是操作。那么我们可以这么理解,有这么一个操作,它是作用于一些元素之上的,而这些元素属于某一个对象结构。

好了,最关键的第二句来了,它说使用了访问者模式之后,可以让我们在不改变各元素类的前提下定义作用于这些元素的新操作。这里面的关键点在于前半句,即不改变各元素类的前提下,在这个前提下定义新操作是访问者模式精髓中的精髓。

结构图

clipboard.png

访问者模式涉及到6类角色: 抽象访问者角色、具体访问者角色、抽象节点角色、具体节点角色、结构对象角色以及客户端角色。

  • Visitor接口:它定义了对每一个元素(Element)访问的行为,它的参数就是可以访问的元素,它的方法个数理论上来讲与元素个数(Element的实现类个数)是一样的,从这点不难看出,访问者模式要求元素类的个数不能改变(不能改变的意思是说,如果元素类的个数经常改变,则说明不适合使用访问者模式)。
  • ConcreteVisitor:具体的访问者,它需要给出对每一个元素类访问时所产生的具体行为。
  • Element接口:元素接口,它定义了一个接受访问者(accept)的方法,其意义是指,每一个元素都要可以被访问者访问。
  • ConcreteElement:具体的元素类,它提供接受访问方法的具体实现,而这个具体的实现,通常情况下是使用访问者提供的访问该元素类的方法。
  • ObjectStructure:这个便是定义当中所提到的对象结构,对象结构是一个抽象表述,具体点可以理解为一个具有容器性质或者复合对象特性的类,它会含有一组元素(Element),并且可以迭代这些元素,供访问者访问。

使用场景

  • 需要对一个组合结构中的对象进行很多不相关的操作,但是不想让这些操作“污染”这这些对象的类。可以将相关的操作集中起来,定义在一个访问者类中,并在访问者定义的操作中使用它。
  • 数据结构稳定,作用于数据结构的操作经常变化的时候。
  • 当一个数据结构中,一些元素类需要负责与其不相关的操作的时候,为了将这些操作分离出去,以减少这些元素类的职责时,可以使用访问者模式。
  • 有时在对数据结构上的元素进行操作的时候,需要区分具体的类型,这时使用访问者模式可以针对不同的类型,在访问者类中定义不同的操作,从而去除掉类型判断。

有意思的是,在很多情况下不使用设计模式反而会得到一个较好的设计。换言之,每一个设计模式都有其不应当使用的情况。访问者模式也有其不应当使用的情况,让我们
先看一看访问者模式不应当在什么情况下使用。

倾斜的可扩展性

访问者模式仅应当在被访问的类结构非常稳定的情况下使用。换言之,系统很少出现需要加入新节点的情况。如果出现需要加入新节点的情况,那么就必须在每一个访问对象里加入一个对应于这个新节点的访问操作,而这是对一个系统的大规模修改,因而是违背"开一闭"原则的。

访问者模式允许在节点中加入新的方法,相应的仅仅需要在一个新的访问者类中加入此方法,而不需要在每一个访问者类中都加入此方法。

显然,访问者模式提供了倾斜的可扩展性设计:方法集合的可扩展性和类集合的不可扩展性。换言之,如果系统的数据结构是频繁变化的,则不适合使用访问者模式。

"开一闭"原则和对变化的封装

面向对象的设计原则中最重要的便是所谓的"开一闭"原则。一个软件系统的设计应当尽量做到对扩展开放,对修改关闭。达到这个原则的途径就是遵循"对变化的封装"的原则。这个原则讲的是在进行软件系统的设计时,应当设法找出一个软件系统中会变化的部分,将之封装起来。

很多系统可以按照算法和数据结构分开,也就是说一些对象含有算法,而另一些对象含有数据,接受算法的操作。如果这样的系统有比较稳定的数据结构,又有易于变化的算法的话,使用访问者模式就是比较合适的,因为访问者模式使得算法操作的增加变得容易。

反过来,如果这样一个系统的数据结构对象易于变化,经常要有新的数据对象增加进来的话,就不适合使用访问者模式。因为在访问者模式中增加新的节点很困难,要涉及到在抽象访问者和所有的具体访问者中增加新的方法。

应用示例

这里为了更接近实际项目开发而不是单纯的纸上谈兵,我们以涂鸦板为例来演示。

项目介绍

核心功能就是在屏幕上涂鸦,把手指滑动的轨迹绘制出来。至于颜色、粗细之类的我们以后再添加,这里只实现核心功能,主要目的是演示访问者模式在实践中的使用。

注意: 这里以iOS项目为例,基于CocoaTouch框架构建; 如果不熟悉可以先查阅相关API再看一下内容。

设计

绘制前需要把手指在屏幕上划过的点记录下来。理论上我们可以使用所知的任何数据结构来存储线条和点等。但是如果全部都用多维数组(比如说)来保存,使用和解析时就需要进行很多类型检查。而且,数据结构并不一致和可靠,需要大量的调试。

如果一种数据结构可以保存独立的点,又可以把点保存为子节点的线条,可以使用
把每一个点和线条都组合到树中,而我们又希望能够统一的对待(处理)树上的任意节点,这就可以通过组合模式来实现了。

定义父类型Mark协议。Vertex、Dot和Stroke都是Mark的具体类。
Mark: 不论线条还是点,其实都是在介质上留下的标志(Mark),它为所有具体类定义了属性和方法。
Dot: 点,组件只有一个点,那么它会表现为一个实心圆,在屏幕上代表一个点。
Vertex: 顶点,连接起来的一串顶点,被绘制成连接起来的线条。
Stroke: 线条,一个线条实体,包含了若干的Vertex子节点
这样当客户端基于接口来操作具体类的时候,可以统一对待每个具体类,而不必在客户端作类型检查。Mark对象又有add方法,可以把其它Mark对象加为自己的子节点,形成组合体。

数据最后的组合结构图是这样的:

clipboard.png

代码

Element

这里Mark也是我们要进行访问的元素Element,它的定义如下

@protocol Mark <NSObject>

@property (nonatomic, assign) CGPoint location;
@property (nonatomic, assign) CGFloat size;
@property (nonatomic, readonly) id <Mark> lastChild;

- (void)addMark:(id <Mark>)mark;
- (void)removeMark:(id <Mark>) mark;

- (void)acceptMarkVisitor:(id <MarkVisitor>)visitor;

@end

具体类型Dot的实现
由于它就是一个圆点,不会真的有添加和移除子节点功能

@interface Dot : NSObject <Mark>

@property (nonatomic, assign) CGPoint location;
@property (nonatomic, assign) CGFloat size;
@property (nonatomic, readonly) id <Mark> lastChild;

@end

@implementation Dot

- (void)addMark:(id <Mark>)mark { }

- (void)removeMark:(id <Mark>) mark { }

- (id <Mark>)lastChild { return nil; }

- (void)acceptMarkVisitor:(id <MarkVisitor>)visitor
{
    [visitor visitDot:self];
}

@end

具体类型Vertex的实现
它只是线条中的一个顶点,也不会有添加和移除子节点功能

@interface Vertex : NSObject <Mark>

@property (nonatomic, assign) CGPoint location;
@property (nonatomic, assign) CGFloat size;
@property (nonatomic, readonly) id <Mark> lastChild;

@end

@implementation Vertex

- (void)addMark:(id <Mark>)mark { }

- (void)removeMark:(id <Mark>) mark { }

- (id <Mark>)lastChild { return nil; }

- (void)acceptMarkVisitor:(id <MarkVisitor>)visitor
{
    [visitor visitVertex:self];
}

@end

具体类型Stroke的实现

@interface Stroke : NSObject <Mark>

@property (nonatomic, assign) CGPoint location;
@property (nonatomic, assign) CGFloat size;
@property (nonatomic, readonly) id <Mark> lastChild;

@end

@implementation Stroke
@dynamic location;

- (instancetype)init
{
    self = [super init];
    if (self) {
        _children = [NSMutableArray array];
    }
    return self;
}

- (void)addMark:(id <Mark>)mark
{
    [self.children addObject:mark];
}

- (void)removeMark:(id <Mark>) mark
{
    [self.children removeObject:mark];
}

- (void)setLocation:(CGPoint)aPoint
{
    // it doesn't set any arbitrary location
}

- (CGPoint)location
{
    // return the location of the first child
    if ([self.children count] > 0)
    {
        id <Mark> child = [self.children objectAtIndex:0];
        return [child location];
    }

    // otherwise returns the origin
    return CGPointZero;
}

- (id <Mark>)lastChild
{
    return [self.children lastObject];
}

- (void)acceptMarkVisitor:(id <MarkVisitor>)visitor
{
    for (id <Mark> dot in self.children) {
        [dot acceptMarkVisitor:visitor];
    }
    
    [visitor visitStroke:self];
}

@end

这里有3中类型的元素Element,它们分别都在自己的acceptMarkVisitor方法里调用visitor的visit方法,并把自己self作为参数传递出去

访问者

先定义抽象的Visitor接口
它定义了对每一个元素(Element)访问的行为

@protocol MarkVisitor <NSObject>

- (void)visitMark:(id <Mark>)mark;
- (void)visitVertex:(Vertex *)vertex;
- (void)visitDot:(Dot *)dot;
- (void)visitStroke:(Stroke *)stroke;

@end

具体的访问者,MarkRenderer绘制访问者,它是对这些点和先进行绘制操作的

@interface MarkRenderer : NSObject <MarkVisitor>

- (instancetype)initWithCGContext:(CGContextRef)context;

@end

@interface MarkRenderer ()

@property (nonatomic, assign) CGContextRef context;
@property (nonatomic, assign) BOOL shouldMoveContextToDot;

@end

@implementation MarkRenderer

- (instancetype)initWithCGContext:(CGContextRef)context
{
    self = [super init];
    if (self) {
        _context = context;
        _shouldMoveContextToDot = YES;
    }
    return self;
}

- (void)visitMark:(id <Mark>)mark
{
    // default behavior
}

- (void)visitVertex:(Vertex *)vertex
{
    CGFloat x = vertex.location.x;
    CGFloat y = vertex.location.y;
    if (self.shouldMoveContextToDot) {
        CGContextMoveToPoint(self.context, x, y);
        self.shouldMoveContextToDot = NO;
    } else {
        CGContextAddLineToPoint(self.context, x, y);
    }
}
- (void)visitDot:(Dot *)dot
{
    CGFloat x = dot.location.x;
    CGFloat y = dot.location.y;
    CGRect frame = CGRectMake(x, y, 2, 2);
    
    CGContextSetFillColorWithColor(self.context, [UIColor blackColor].CGColor);
    CGContextFillEllipseInRect(self.context, frame);
}

- (void)visitStroke:(Stroke *)stroke
{
    CGContextSetStrokeColorWithColor(self.context, [UIColor blueColor].CGColor);
    CGContextSetLineWidth(self.context, 1);
    CGContextSetLineCap(self.context, kCGLineCapRound);
    CGContextStrokePath(self.context);
    self.shouldMoveContextToDot = YES;
}

@end

Client调用

前提是我们已经把数据都搜集好了,放在具体的Mark结构体中,然后在一个具体的view类的drawRect方法中调用就可以了

- (void)drawRect:(CGRect)rect {
    CGContextRef context = UIGraphicsGetCurrentContext();
    MarkRenderer *markRender = [[MarkRenderer alloc] initWithCGContext:context];
    [self.mark acceptMarkVisitor:markRender];
}

运行效果图,具体可以看源码源码下载

clipboard.png

访问者拓展

这个例子,用绘制节点对象的访问者(MarkRenderer)拓展了Mark家族类,这样就可以把它们显示到屏幕上了。还可以在增加一个访问者,比如,访问Mark组合体每个节点,对它实施仿射变换(旋转、缩放、平移等)。在不改变组合结构的前提下,我们拓展了它的功能。

双分派技术

这个模式有一个绕的地方是采用了双分派技术,其实它本身本不难,就是过程有点曲折而已。
第一次分派: 把具体的访问者对象传递给结构对象,比如上面例子中的MarkRenderer传递给了Mark对象,MarkRenderer只是某一个具体的visitor,客户端也可以传递其它的visitor过来,做不一样的操作。
第二次分派: 当Mark接到具体的visitor对象过来后,具体的Mark实例会根据自己的类型调用visitor对应的方法,并把自己(self)作为参赛传递过去,这就完成了第二次分派。

通过上面的分析发现它并不难,单这样的目的是什么呢? 其实这就意味着最后得到的操作是由两个具体对象的类型(具体元素和具体访问者)来决定的。

总结

  • 访问者模式适用于数据结构相对稳定的系统

    它把数据结构和作用于数据结构上的操作之间的耦合解脱开,使得操作集合可以相对自由的演化
  • 访问者模式的目的是要把处理从数据结构分离出来。

    很多系统可以安装算法和数据结构分开,如果这样的系统有比较稳定的数据结构,又有易于变化算法的话,使用访问者模式就是比较合适的,因为访问者模式使得算法操作的增加变动容易。
  • 访问者模式的有点就是增加新的操作很容易,因为增加新的操作就意味着增加一个新的访问者。访问者模式将有关的行为集中到一个访问者对象中。
  • 反之,访问者的缺点也就是增加新的数据结构变得困难了。

《设计模式》作者GoF四人中的一个说过:大多数情况下,你并需要使用访问者模式,但是当你一旦需要使用它时,那你就是真的需要它了。


danielmea
28 声望8 粉丝

程序猿的逗比日常-------