趣学设计模式29 访问者模式:如何实现对象级别的矩阵结构?

2023年 10月 15日 61.9k 0

从这一讲开始,我们就进入行为型设计模式的学习。行为型设计模式主要的关注点是对象内部算法及对象之间的职责和分配,比如,具体实现算法、选择策略、状态变化等抽象概念。

除了我们熟悉的 GoF 的分类方式外,行为型模式还可以分为以下两种。

  • 类行为型模式:使用继承的方式来关联不同类之间的行为。
  • 对象行为型模式:使用组合或聚合方式来分配不同类之间的行为。

今天我们先来讲一个原理看似很简单,但是理解起来有一定难度,使用场景相对较少的行为型模式:访问者模式。虽然访问者模式的使用场景不多,但我还是希望你能好好学习、好好揣摩,这样你在未来遇到相关场景时也不至于手足无措,才能大显身手。

模式原理分析

访问者模式的原始定义是:允许在运行时将一个或多个操作应用于一组对象,将操作与对象结构分离。

这个定义会比较抽象,但是我们依然能看出两个关键点:一个是运行时使用一组对象的一个或多个操作,比如,对不同类型的文件(.pdf、.xml、.properties)进行扫描;另一个是分离对象的操作和对象本身的结构,比如,扫描多个文件夹下的多个文件,对于文件来说,扫描是额外的业务操作,如果在每个文件对象上都加一个扫描操作,太过于冗余,而扫描操作具有统一性,非常适合访问者模式。

所以说,访问者模式核心关注点是分离一组对象结构和对象的操作,对象结构可以各不相同,但必须以某一个或一组操作作为连接的中心点。换句话说,访问者模式是以行为(某一个操作)作为扩展对象功能的出发点,在不改变已有类的功能的前提下进行批量扩展。

我们来看一下访问者模式的 UML 图:

image.png

从这个 UML 图中,我们能看出访问者模式包含的关键角色有四个。

  • 访问者类(Visitor):这是一个接口或抽象类,定义声明所有可访问类的访问操作。
  • 访问者实现类(VisitorBehavior):实现在访问者类中声明的所有访问方法。
  • 访问角色类(Element):定义一个可以获取访问操作的接口,这是使客户端对象能够“访问”的入口点。
  • 访问角色实现类(Element A 等):实现访问角色类接口的具体实现类,将访问者对象传递给此对象作为参数。

也就是说,访问者模式可以在不改变各来访类的前提下定义作用于这些来访类的新操作。比如,在没有二维码的时代,旅游景区检票大门提供的访问者实现类就是人工检票,无论哪里来的游客只有购票、检票后才能入园。现在有了二维码后,园区新增一个检票机,将二维码作为一种新的进景点的操作(对应访问者类),那么各类游客(对应访问角色类)可能用支付宝扫二维码,也可能使用微信扫二维码,这时二维码就是新的一个访问点。园区没有改变来访者,但是提供了一种新的操作,对于园区来说,不管你是什么身份的人,对他们来说都只是一个访问者而已。

接下来,我们再来看看 UML 对应的代码实现:

public interface Visitor {
    void visitA(ElementA elementA);
    void visitB(ElementB elementB);
    //...
    //void visitN(ElementN elementN);
}
public class VisitorBehavior implements Visitor {
    @Override
    public void visitA(ElementA elementA) {
        int x = elementA.getAState();
        x++;
        System.out.println("=== 当前A的state为:"+x);
        elementA.setAState(x);
    }
    @Override
    public void visitB(ElementB elementB) {
        double x = elementB.getBState();
        x++;
        System.out.println("=== 当前B的state为:"+x);
        elementB.setBState(x);
    }
}
public interface Element {
    void accept(Visitor v);
}
public class ElementA implements Element {
    private int stateForA = 0;
    public void accept(Visitor v) {
        System.out.println("=== 开始访问元素 A......");
        v.visitA(this);
    }
    public int getAState(){
        return stateForA;
    }
    public void setAState(int value){
        stateForA = value;
    }
}
// 单元测试
public class Demo {
    public static void main(String[] args) {
        List elementList = new ArrayList();
        ElementA elementA = new ElementA();
        elementA.setAState(11);
        ElementB elementB = new ElementB();
        elementA.setAState(12);
        elementList.add(elementA);
        elementList.add(elementB);
        for (Element element :elementList) {
            element.accept(new VisitorBehavior());
        }
    }
}
//输出结果

这段代码实现的逻辑比较简单,在单元测试中我们先建立了一个来访类的列表,并依次新建操作访问角色实现类 A 和访问角色实现类 B,将两个对象都加入来访类的列表中,创建相同的访问者实现类 VisitorBehavior,这样就完成了一个访问者模式。

为什么使用访问者模式?

下面我们再来说说使用访问者模式的原因,主要有以下三个。

第一个,解决编程部分语言不支持动态双分派的能力。比如,Java 是静态多分派、动态单分派的语言。什么叫双分派?所谓双分派技术就是在选择一个方法的时候,不仅要根据消息接收者的运行时来判断,还要根据参数的运行时判断。与之对应的就是单分派,在选择一个方法的时候,只根据消息接收者的运行时来判断。实际上,很多时候我们都无法提前预测所有程序运行的行为,需要在运行时动态传入参数来改变程序的行为,对于 Java 这类语言就需要通过设计模式来弥补这部分功能。

第二个,需要动态绑定不同的对象和对象操作。  比如,对不同类型的文件进行扫描、复制并转换新的文件、翻译不同语言文字等,在这一类的场景中,我们往往只是希望在程序运行的过程中进行操作的绑定,用完以后就释放。如果按照传统的方式,每一个新的操作都需要在类上增加方法,不仅得频繁修改代码,而且还会编译打包运行,这些都会非常耗时,这时使用访问者模式就能很好地解决这个问题。

第三个,通过行为与对象结构的分离,实现对象的职责分离,提高代码复用性。访问者模式能够在对象结构复杂的情况下动态地为对象添加操作,这就做到了对象的职责分离,尤其对于一些老旧的系统来说,能够快速地扩展功能,提高代码复用性。

使用场景分析

为了帮助你直观地理解访问者模式是如何使用的,下面我们通过不同路由访问不同操作系统的例子来说明。

假设你是一家路由器软件的生产商,接了很多家不同硬件品牌的路由器的软件需求(比如,D-Link、TP-Link),这时你需要针对不同的操作系统(比如,Linux 和 Windows)做路由器发送数据的兼容功能。于是,你先定义了路由器的访问角色类 Router,如下代码:

public interface Router {
    void sendData(char[] data);
    void accept(RouterVisitor v);
}

然后,再分别针对不同型号的路由器具体实现对应的功能。

public class DLinkRouter implements Router{
    @Override
    public void sendData(char[] data){}
    
    @Override
    public void accept(RouterVisitor v){}
}
public class TpLinkRouter implements Router{
    @Override
    public void sendData(char[] data){}
    
    @Override
    public void accept(RouterVisitor v){
        v.visit(this);
    }
}

接下来,再配置一下访问者类 RouterVisitor、访问者实现类 LinuxRouterVisitor 和 WindowsRouterVisitor,用于给不同的路由器提供访问的入口点。

public interface RouterVisitor {
    void visit(DLinkRouter router);
    void visit(TPLinkRouter router);
}
public class LinuxRouterVisitor implements RouterVisitor{
    @Override
    public void visit(DLinkRouter router) {
        System.out.println("=== DLinkRouter Linux visit success!");
    }
    @Override
    public void visit(TPLinkRouter router) {
        System.out.println("=== TPLinkRouter Linux visit success!");
    }
 }
public class WindowsRouterVisitor implements RouterVisitor{
     @Override
    public void visit(DLinkRouter router) {
        System.out.println("=== DLinkRouter Windwos visit success!");
    }
    @Override
    public void visit(TPLinkRouter router) {
        System.out.println("=== DLinkRouter Windwos visit success!");
    }
}

到此就完成了所有配置,最后再运行一下测试:

public class Client {
    public static void main(String[] args) {
        LinuxRouterVisitor linuxRouterVisitor = new LinuxRouterVisitor();
        WindowsRouterVisitor windowsRouterVisitor = new WindowsRouterVisitor();
        DLinkRouter dLinkRouter = new DLinkRouter();
        dLinkRouter.accept(linuxRouterVisitor);
        dLinkRouter.accept(windowsRouterVisitor);
        TPLinkRouter tpLinkRouter = new TPLinkRouter();
        tpLinkRouter.accept(linuxRouterVisitor);
        tpLinkRouter.accept(windowsRouterVisitor);
    }
}
//输出结果
=== DLinkRouter Linux visit success!
=== DLinkRouter Windows  visit success!
=== TPLinkRouter Linux visit success!
=== DLinkRouter Windows  visit success!

从这个示例我们可以发现,不同型号的路由器可以在运行时动态添加(第一次分派),对于不同的操作系统来说,路由器可以动态地选择适配(第二次分派),整个过程完成了两次动态绑定。这也就引出了访问者模式常用的场景,我将其大致总结为如下三类。

  • 当对象的数据结构相对稳定,而操作却经常变化的时候。  比如,上面例子中路由器本身的内部构造(也就是数据结构)不会怎么变化,但是在不同操作系统下的操作可能会经常变化,比如,发送数据、接收数据等。
  • 需要将数据结构与不常用的操作进行分离的时候。  比如,扫描文件内容这个动作通常不是文件常用的操作,但是对于文件夹和文件来说,和数据结构本身没有太大关系(树形结构的遍历操作),扫描是一个额外的动作,如果给每个文件都添加一个扫描操作会太过于重复,这时采用访问者模式是非常合适的,能够很好分离文件自身的遍历操作和外部的扫描操作。
  • 需要在运行时动态决定使用哪些对象和方法的时候。  比如,对于监控系统来说,很多时候需要监控运行时的程序状态,但大多数时候又无法预知对象编译时的状态和参数,这时使用访问者模式就可以动态增加监控行为。

所以说,访问者模式重点关注不同类型对象在运行时动态进行绑定,以及对多个对象增加统一操作的场景。

收益什么?损失什么?

通过上面的分析,我们也可以得出访问者模式主要有以下优点。

  • 简化客户端操作。比如,扫描文件时,对于客户端来说只需要执行扫描,而不需要关心不同类型的文件该怎么读取,也不用知道文件该如何被读取。
  • 增加新的访问操作和访问者会非常便捷。在每次新增操作时,都是将对象视为统一的访问者,只需要关注操作是否正确地在对象上执行,而不需要关注对象如何被构建,这样使操作变得更加容易。
  • 满足开闭原则。由于访问者模式没有对原有对象进行修改,只是新增了外部的统一操作,扩展新类不影响旧类,满足开闭原则。
  • 满足单一职责原则。每一个行为都是一个单一的行为操作,能够组合相关类的功能逻辑,到代码内聚度更高。
  • 通过行为能够快速组合一组复杂的对象结构。比如,访问角色类可能是树形结构,增加一个新操作后,对每一个对象都是进行的统一操作,这时新增一个其他结构的对象也能按照统一操作进行,便能将多个不同结构的对象进行自由组合。

当然,访问者模式同样不是万能的,它也有一些缺点。

  • 增加新的数据结构困难。因为新增结构,又需要新增操作,这时就让结构和操作发生关联,也就破坏了原有的模式,并且可能造成原有结构的不可用。
  • 具体元素在变更时需要修改代码,容易引入问题。虽然访问者模式分离了操作和对象,但是当访问者对象本身发生变化时,依然需要修改代码,这时可能会对操作的方法造成影响。

总结

访问者模式能够通过添加新的行为来封装不同类型的对象,并隐藏不同对象各自的变化。这里所说的隐藏的变化主要包括:

  • 允许添加新行为到一组对象里;
  • 行为的实现和数量;
  • 在运行时动态给对象添加额外行为;
  • 访问者类和访问角色类之间的调用关系。

你发现没,访问者模式的原理理解起来不像之前结构型和创建型模式那么容易,主要原因在于行为的动态多变性,而且涉及很多编译原理和操作系统的知识。说实话,访问者模式在真实的应用中算得上是比较难的模式之一了。

如果你遇到需要使用访问者模式的场景,我建议你可以先尝试模仿着去使用访问者模式,实践完之后再去试着理解访问者模式的原理,这样在学习时才能更好地有的放矢。

课后思考

虽然访问者模式能够动态地添加新行为,但是这样是不是会破坏接口分离的设计原则?

相关文章

服务器端口转发,带你了解服务器端口转发
服务器开放端口,服务器开放端口的步骤
产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
如何使用 WinGet 下载 Microsoft Store 应用
百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

发布评论