访问者模式分析--设计模式

原创内容,转载请注明出处

Posted by Weakyon Blog on December 18, 2018

23个设计模式中,大部分多写代码都是可以直接领悟到的。

但是访问者模式不是。一个原因是这个模式的实际应用场景在后端开发流程中特别难遇到(一般用于解决固定问题,游戏开发的复杂逻辑应该能经常遇到),

另外一个原因是这个模式的实现有点绕。

这一篇并不包含实践环节,因为我并没有实际遇到这样的需求,因此本篇只能记录下学习过程了。

一 双重多态模式

1.1 What

网上很多博客都写的很晦涩,我认为下面一篇是我看到讲的比较清晰的(但不完全正确)

他先讲了访问者模式的前身,双重多态模式

类型识别

public class Speaker {
    public static void main(String[] args) {
        Animal a = new Dog();
        Cat c = new Cat();
        Animal f = new Fox();
        Speaker s = new Speaker();
        s.speak(a);
        s.speak(c);
        s.speak(f);
    }
    public void speak(Animal a) {
        a.speak(this);
    }
    public void speak(Dog d) {..}
    public void speak(Cat c) {..}
    public void speak(Fox f) {..}
    ...
}
public interface Animal {
    public void speak(Speaker s);
}
class Cat implements Animal {
    @Override
    public void speak(Speaker s) {
        s.speak(this);
    }
}
class Dog implements Animal {...}
class Fox implements Animal {...}

他的例子有一个不太好的地方是Speaker和Animal的方法都叫speak,显得有点绕

我认为Animal接口改为beSpeak更好(事实也是如此,Animal本身没有speak而是由Speaker代劳)

另外尤其重要的点:

我认为这部分的实现只能叫双重多态,或者叫一重分派,并不能叫做双重分派

1.2 How

N重分派的定义,我的理解是N个参数,根据每个参数的不同动态绑定不同的方法。

上文的代码Speaker参数是固定的,而Cat,Dog,Fox是变化的,因此是一重分派

接口这种方式实现的一重分派很容易理解,是利用语言自己的多态性质来动态绑定不同的方法来完成的。

但假如不使用接口,如何来进行一重分派功能呢?

答案就是上文实现的代码,即对Cat,Dog,Fox的一重分派,

先通过让别的类来替“我”执行某个操作(beSpeak),完成将某些方法(speak)外置的过程。

然后这些方法利用语言的重载特性来进行动态绑定(正因此golang无法优雅的进行双重分派),实现了一重分派。

1.3 Why

为什么要用双重多态模式?怎么样才能用到双重多态模式?

当有需求是某些类要将逻辑集中放在一个外部类中时会用到这种模式,显然这些逻辑一般是独立于类核心逻辑之外的。

因此一般用于实现过滤器,日志打印等内容。

例如责任链模式涉及到很多类实现不同的内容,因此特别适合配合责任链模式进行统计记录日志。

二 访问者模式

然后作者进行了演化,将可以复用的逻辑抽象,就完成了访问者模式

访问者模式

但是这里的意义又完全不一样了,访问者模式一般是为了实现双重分派而使用的

C++的访问者模式

这是对C++实现的一个补充,作者提到了虚函数的性能损耗,说C++这一部分比JAVA的效率要低

也许模板的实现可以解决这个问题,我粗略想了一下还是比较困难的,留着我以后来试试实现了。

2.1 What

public interface Animal {
    public void accept(Visitor v);
}
class Cat implements Animal {
    @Override
    public void accept(Visitor v) {
        v.visit(this);
    }
}
class Dog implements Animal {...}
class Fox implements Animal {...}
public interface Visitor {
    void visit(Animal a);
    void visit(Dog d);
    void visit(Cat c);
    void visit(Fox f);
}
public class Speaker implements Visitor{
    public void visit(Animal a) {
        a.accept(this);
    }
    public void visit(Dog d) {...}
    public void visit(Cat c) {...}
    public void visit(Fox f) {...}
}
public class Counter implements Visitor{...}
public static void main(String[] args) {
    Animal[] animals = {new Dog(), new Cat(), new Fox(), new Cat(), new Dog(), new Dog()};
    Speaker s = new Speaker();
    Counter c = new Counter();
    for (Animal animal : animals) {
        c.visit(animal);
    }
    c.log();
    for (Animal animal : animals) {
        s.visit(animal);
    }
}

对于一个方法,两个参数,传入的每个不同的类都要有不同的处理策略。

这就是双重分派,也是访问者模式的核心内容。

2.2 How

实现部分很简单,就是第一节双重多态模式的抽象(三重多态)

2.3 Why

为什么要用双重分派模式?怎么样才能用到双重分派模式?

我完全没有使用该模式,因此也只能抽取别人的原话来进行说明

你家(Client)有1室1厅1厨1卫(property)。
你需要请人打扫清洁(accept)。
有几个清洁套餐(visitor interface)符合你要求。
套餐选择后清洁工访问你家做事(visitor implement)。
整体服务级清洁工:1室(打扫)1厅(打扫)1厨(打扫+丢垃圾)1卫(打扫+擦马桶)
厨卫清洁工:1室(不打扫)1厅(打扫)1厨(打扫)1卫(打扫+擦马桶)
卫生间清洁工:1室(不打扫)1厅(不打扫)1厨(不打扫)1卫(打扫+擦马桶)
你选择定下方案后,清洁工就会去根据上面的套餐访问你家去做清洁。
你(调用方)有几个房间(visitor需要重载的函数参数),
有几个套餐(visitor 接口)满足你的房间打扫的需求(visitor重载函数和调用方对应)
你接受(accept)签了合约的清洁工(visitor 具体类)打扫(重载函数的具体实现)。
Visitor的经典场景就是:通过函数重载,动态选择要执行的具体实现。
下面是wiki的定义:
这个模式的基本想法如下:首先我们拥有一个由许多对象构成的对象结构,这些对象的类都拥有一个accept方法用来接受访问者对象;
访问者是一个接口,它拥有一个visit方法,这个方法对访问到的对象结构中不同类型的元素作出不同的反应;
在对象结构的一次访问过程中,我们遍历整个对象结构,
对每一个元素都实施accept方法,在每一个元素的accept方法中回调访问者的visit方法,从而使访问者得以处理对象结构的每一个元素。
我们可以针对对象结构设计不同的实在的访问者类来完成不同的操作。
访问者模式使得我们可以在传统的单分派语言(如Smalltalk、Java和C++)中模拟双分派技术。对于支持多分派的语言(如CLOS),访问者模式已经内置于语言特性之中了,从而不再重要。

双重多态的核心在于一批类将非核心逻辑外置。

而双重委托核心在于一批类将一批非核心逻辑外置。

并且还有重点在于实现不同类之间方法的排列组合。

很容易想到的就是坦克大战中,炮弹遇到可摧毁障碍物,不可摧毁障碍物,可穿透地形,坦克;

威力稍大的炮弹遇到这些的逻辑是完全不同的。

而“遇到”的实现即为双重分派

三 深入阅读

3.1 访问者模式的变形(过滤器链模式)

public interface Animal {
    public void accept(List<Visitor> vs);
}
class Cat implements Animal {
    @Override
    public void accept(List<Visitor> vs) {
        range v in vs {
            v.visit(this);
        }
    }
}
...

这种方式常见于过滤器链的设计

3.2 拦截器链模式

拦截器链在概念上和过滤器链类似,因此一起写一下。

但是实际上很不一样,过滤器链无论如何都能全部调用完,而拦截器链则能由某个拦截器中断以后的调用。

实现也差的远,因为拦截器链不涉及到双重分派。

当一批类需要跑一批拦截器时,类可见的只有一个拦截器,拦截器之间依次调用形成拦截器链。

一批类对一个拦截器类,也就谈不上双重分派了,简单的多态就完成了。

拦截器之间如何依次调用形成拦截器链呢?

#include <vector>
#include <functional>
#include <iostream>

class InterceptorManager;
class InterceptorInterfaceI {
public:
    virtual void Do(InterceptorManager *action) = 0;
};
class InterceptorManager {
public:
    void Add(InterceptorInterfaceI* interceptor) {
        interceptors_.push_back(interceptor);
    }
    void SetHandler(std::function<void()> handler) {
        handler_ = handler;
    }
    void Do() {
        index++;
        if(index >= interceptors_.size()) {
            handler_();
        }else {
            interceptors_[index]->Do(this);
        }
    }
private:
    int index = -1;
    std::function<void()> handler_;
    std::vector<InterceptorInterfaceI*> interceptors_;
};
class Interceptor1 : public InterceptorInterfaceI{
public:
    void Do(InterceptorManager *action) override {
        std::cout << "interceptor1" << std::endl;
        action->Do();
    }
};
class Interceptor2 : public InterceptorInterfaceI{
public:
    void Do(InterceptorManager *action) override {
        std::cout << "interceptor2" << std::endl;
        action->Do();
    }
};
int main() {
    Interceptor1 interceptor1;
    Interceptor2 interceptor2;
    InterceptorManager invocation;
    invocation.Add(&interceptor1);
    invocation.Add(&interceptor2);
    invocation.SetHandler([](){
        std::cout << "exec" << std::endl;
    });
    invocation.Do();
}

每个Interceptor都调用InterceptorManager的Do任务,将调度权限归还给调度器,使得调度器能再次调用其他的Interceptor

从而形成拦截器调度链

golang服务化框架sheep

这是我实现的服务化框架实例的一个变种,MergeInterceptor方法可以将多个拦截器合并成同一个供服务端使用

这里用了golang闭包的trick(InterceptorManager类使用闭包来实现进行隐藏)使得代码更简洁。

3.3 一篇好文

关于双分派(Double Dispatch)的一点探讨

这一篇讲的更为深入,重点在于双重分派如何设计避免新加类需要修改老的类的代码。

比较长,而我暂时没有用到双重分派的需求,因此没有细看。

18 Dec 2018