双分派-访问者模式的前世今生


这里是个人博客链接,这里的阅读效果会更好哦~

学了那么久的Java,你是否知道Java是属于单分派语言还是双分派语言?什么?单分派和双分派是什么意思还不知道?了解了分派机制,就能明白访问者模式的前世今生了。

访问者模式是23种设计模式当中比较少见少用的一种,相比于其他常见的,如单例、工厂、观察者、代理等模式,理解起来要稍微费劲一点。但,如果从访问者模式的产生由来去思考和理解,或许会更容易,那为什么会出现访问者这一种设计模式呢?首先先要理解什么是单分派和双分派。


分派

分派是什么样的概念呢?分派即Dispatch,在面向对象编程语言中,我们可以把方法调用理解为一种消息传递(Dispatch)。一个对象调用另一个对象的方法,相当于给被调用对象发送一个消息,这个消息包括对象名、方法名、方法参数等信息。

单分派

单分派,即执行哪个对象的方法,根据对象的运行时类型决定;执行对象的哪个方法,根据方法参数的编译时类型决定。

双分派

双分派,即执行哪个对象的方法,根据对象的运行时类型来决定;执行对象的哪个方法,根据方法参数的运行时的类型来决定。

听起来似乎很绕口,其实很简单,下面以表格的形式展示,可能会更加明了一点。

分派机制

分派机制如果在是在编程语言中,单分派和双分派就是跟多态和函数重载直接相关。那Java是属于单分派还是双分派呢?我们直接用个代码示例在验证好了。

1
2
3
4
5
6
7
public class Parent {<!-- -->

    public void call() {<!-- -->
        System.out.println("I'm Parent.");
    }

}
1
2
3
4
5
6
7
public class Child extends Parent {<!-- -->

    public void call() {<!-- -->
        System.out.println("I'm Child.");
    }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class App {<!-- -->

    public void call(Parent parent) {<!-- -->
        parent.call();
    }

    public  void sayName(Parent parent) {<!-- -->
        System.out.println("saveName重载,参数类型: Parent");
    }

    public  void sayName(Child child) {<!-- -->
        System.out.println("saveName重载,参数类型: Child");
    }

    public static void main(String[] args) {<!-- -->
        App app = new App();
        Parent obj = new Child();
        app.call(obj);

        app.sayName(obj);
    }

}

你觉得上述代码的执行结果输出是什么呢?如下:

1
2
3
4
I'm Child.
saveName重载,参数类型: Parent

Process finished with exit code 0

简单分析下:

new对象时,我们new的是Child对象,通过call方法的调用结果可知,最终打印出的是I'm Child.,即App类的call方法中,到底调用Parent的call还是会调用Child的call,由我们创建的对象实例类型决定,是运行时决定的,正所谓多态;而通过saveName方法的调用结果可知,尽管我们new的是Child对象,结果调用的却是形参类型是Parent的那个重载,由此可知,这里决定调用那个saveName重载,在编译时就决定了。所以,很明显了,Java支持单分派,不支持多分派

也因此得知,Java语言中的函数重载,并不是在运行时,根据传递给函数的参数的实际类型,来决定调用哪个重载函数,而是在编译时,根据传递给函数的参数的声明类型,来决定调用哪个重载函数。即Java语言中,多态是一种动态绑定,函数重载是一种静态绑定


访问者模式的由来

正因为在某些编程语言中,如Java不支持双分派,所以访问者设计模式诞生了。何出此言?我们先来看一个业务功能场景。

假设有一个图像视频存储服务器,这个服务器需要针对用户上传的图像、视频进行合法性校验,如不能涉及黄色内容,也是鉴黄拦截处理。而图像视频有多种封装格式,如有图片jpg、png,动图gif,视频mp4、avi、flv等,然后不同类型的文件,提取内容的方式也不一样,下面是针对这个功能场景的一个代码设计。

如果你有一定的代码架构设计基础,相信一开始你也能想到可以利用多态特性来实现比较友好的封装抽象设计,如下:

#基本数据类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
 * 媒体文件基类
 */
public abstract class MediaFile {<!-- -->
    protected String filePath;
    public MediaFile(String filePath) {<!-- -->
        this.filePath = filePath;
    }
}

/**
 * 假设这个Image类是从不同媒体文件中提取出来的对象,
 * 里面存储图像鉴别的相关内容和信息,此处省略相关变量和方法
 */
public class Image {<!-- -->
}

/**
 * 该类代表静态图片,如jpg、png
 */
public class Picture extends MediaFile {<!-- -->
    public Picture(String filePath) {<!-- -->
        super(filePath);
    }
}

/**
 * 此类表示动态图,如gif
 */
public class Gif extends MediaFile {<!-- -->
    public Gif(String filePath) {<!-- -->
        super(filePath);
    }
}

/**
 * 此类代表视频媒体文件,如MP4、AVI等
 */
public class Video extends MediaFile {<!-- -->
    public Video(String filePath) {<!-- -->
        super(filePath);
    }
}

#图像内容提取器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
 * 为了以后更好的扩展,如针对媒体文件可能还会有其他操作,而不仅仅是提取图像内容
 * 因此,我们把提取图像内容单独分离出来,弄成一个提取器
 */
public class Extractor {<!-- -->

    public void extract(Picture picture) {<!-- -->
        Image image = new Image();
        System.out.println("提取静态图片[" + picture.filePath + "]中的图像信息");
       // 省略:image对象数据填充操作,图像鉴别操作
    }

    public void extract(Gif gif) {<!-- -->
        Image image = new Image();
        System.out.println("提取动态图片[" + gif.filePath + "]中的图像信息");
        // 省略:image对象数据填充操作,图像鉴别操作
    }

    public void extract(Video video) {<!-- -->
        Image image = new Image();
        System.out.println("提取视频[" + video.filePath + "]中的图像信息");
        // 省略:image对象数据填充操作,图像鉴别操作
    }
}

#鉴别处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class App {<!-- -->
    public static void main(String[] args) {<!-- -->
        Extractor extractor = new Extractor();
        List<MediaFile> mediaFiles = Arrays.asList(
                new Picture("a.jpg"),
                new Picture("b.png"),
                new Gif("c.gif"),
                new Video("d.mp4"),
                new Video("e.avi")
        );

        for (MediaFile media : mediaFiles) {<!-- -->
            extractor.extract(media);
        }
    }
}

代码写到这里,看起来应该没有什么问题,整体的设计也比较符合开闭原则,唯一一点不太优雅的就是Extractor提取器,如果日后新增一种媒体类型文件,就得增加一个重载方法,但这问题不大,整体设计也相当灵活了。

但是,如果你没自己去在IDEA写这个代码的话,第一眼很难看出,其实上面的代码有一处地方是编译不通过的。就是主函数入口中,for循环里的这句代码:

1
Image image = extractor.extract(media);

在这个for循环中,media是MediaFile基类的引用类型,而在Extractor中,并没有针对MediaFile基类的重载方法,而又由于Java是不支持双分派的,因此这里是编译不通过的。

有点可惜,挺好架构设计,却行不通,那该如何解决这个问题呢?其实,懂的自然懂,不懂的,也不容易想到这种设计,就是MediaFile基类中新增一个抽象方法,该方法接收Extractor作为形参,表示提取操作,然后子类重写该方法,执行相应的Extractor的重载即可,重新设计架构后的代码如下:

#基本数据类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public abstract class MediaFile {<!-- -->
    protected String filePath;
    public MediaFile(String filePath) {<!-- -->
        this.filePath = filePath;
    }
   
    public abstract void accept(Extractor extractor);
}

public class Image {<!-- -->}

public class Picture extends MediaFile {<!-- -->
    public Picture(String filePath) {<!-- -->
        super(filePath);
    }
   
    @Override
    public void accept(Extractor extractor) {<!-- -->
        extractor.extract(this);
    }
}

public class Gif extends MediaFile {<!-- -->
    public Gif(String filePath) {<!-- -->
        super(filePath);
    }
   
    @Override
    public void accept(Extractor extractor) {<!-- -->
        extractor.extract(this);
    }
}

public class Video extends MediaFile {<!-- -->
    public Video(String filePath) {<!-- -->
        super(filePath);
    }
   
    @Override
    public void accept(Extractor extractor) {<!-- -->
        extractor.extract(this);
    }
}

Extractor保持原有设计不变,主函数调用如下:

1
2
3
4
5
6
7
8
public class App {<!-- -->
    public static void main(String[] args) {<!-- -->
        // 此处省略,跟第一种设计里的代码一样
        for (MediaFile media : mediaFiles) {<!-- -->
            media.accept(extractor);
        }
    }
}

到这里,编译通过, 看一下程序运行结果:

1
2
3
4
5
6
7
提取静态图片[a.jpg]中的图像信息
提取静态图片[b.png]中的图像信息
提取动态图片[c.gif]中的图像信息
提取视频[d.mp4]中的图像信息
提取视频[e.avi]中的图像信息

Process finished with exit code 0

其实,到这里,你就已经知道了什么是访问者模式了,上述的第二种设计思路,就是访问者模式的设计思路,只不过不是完整版的访问者模式,只能算是简版的。为什么这么说呢?我们都知道,每一个设计模式的诞生,都是有某一种业务功能场景需求作为背景的,而设计模式则是为了更好的对代码结构进行优化,使其变得更加解耦、易维护、可扩展。上述的简版访问者模式代码设计中,有一个很严重的弊端:

如果日后需要对媒体文件新增一种功能操作,如给媒体文件添加水印,那么MediaFile基类,就得新增一个抽象方法,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
public abstract class MediaFile {<!-- -->

    protected String filePath;

    public MediaFile(String filePath) {<!-- -->
        this.filePath = filePath;
    }

    // 提取图像内容
    public abstract void accept(Extractor extractor);
    // 添加水印
    public abstract void accept(Watermarker watermarker);
}

同时它的所有子类都得重写多这个方法,违反了开闭原则。

接下来是该完整版的访问者模式登场了。

#定义一个访问者接口或者基类

1
2
3
4
5
6
7
8
/**
 * 访问者接口,具体如何访问,由其实现类定义
 */
public interface Visitor {<!-- -->
    void visit(Picture picture);
    void visit(Gif gif);
    void visit(Video video);
}

#基本数据类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 媒体文件基类
public abstract class MediaFile {<!-- -->
    protected String filePath;
    public MediaFile(String filePath) {<!-- -->
        this.filePath = filePath;
    }
    public abstract void accept(Visitor visitor);
}

// 静态图片
public class Picture extends MediaFile {<!-- -->
    public Picture(String filePath) {<!-- -->
        super(filePath);
    }

    @Override
    public void accept(Visitor visitor) {<!-- -->
         visitor.visit(this);
    }
}

// 动态图片
public class Gif extends MediaFile {<!-- -->
    public Gif(String filePath) {<!-- -->
        super(filePath);
    }

    @Override
    public void accept(Visitor visitor) {<!-- -->
        visitor.visit(this);
    }
}

// 视频
public class Video extends MediaFile {<!-- -->
    public Video(String filePath) {<!-- -->
        super(filePath);
    }

    @Override
    public void accept(Visitor visitor) {<!-- -->
        visitor.visit(this);
    }
}

#图像信息提取并鉴别访问者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Extractor implements Visitor {<!-- -->

    public void visit(Picture picture) {<!-- -->
        Image image = new Image();
        System.out.println("提取静态图片[" + picture.filePath + "]中的图像信息");
        // 省略:image对象数据填充操作,图像鉴别操作
    }

    public void visit(Gif gif) {<!-- -->
        Image image = new Image();
        System.out.println("提取动态图片[" + gif.filePath + "]中的图像信息");
        // 省略:image对象数据填充操作,图像鉴别操作
    }

    public void visit(Video video) {<!-- -->
        Image image = new Image();
        System.out.println("提取视频[" + video.filePath + "]中的图像信息");
        // 省略:image对象数据填充操作,图像鉴别操作
    }
}

#添加水印访问者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Watermarker implements Visitor {<!-- -->
    @Override
    public void visit(Picture picture) {<!-- -->
        System.out.println("给静态图片[" + picture.filePath + "]添加水印");
    }

    @Override
    public void visit(Gif gif) {<!-- -->
        System.out.println("给动态图片[" + gif.filePath + "]添加水印");
    }

    @Override
    public void visit(Video video) {<!-- -->
        System.out.println("给视频[" + video.filePath + "]添加水印");
    }
}

#执行函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class App {<!-- -->
    public static void main(String[] args) {<!-- -->
        List<MediaFile> mediaFiles = Arrays.asList(
                new Picture("a.jpg"),
                new Picture("b.png"),
                new Gif("c.gif"),
                new Video("d.mp4"),
                new Video("e.avi")
        );

        Extractor extractor = new Extractor();
        for (MediaFile media : mediaFiles) {<!-- -->
            media.accept(extractor);
        }

        Watermarker watermarker = new Watermarker();
        for (MediaFile media : mediaFiles) {<!-- -->
            media.accept(watermarker);
        }

    }
}

#程序执行结果

1
2
3
4
5
6
7
8
9
10
11
12
提取静态图片[a.jpg]中的图像信息
提取静态图片[b.png]中的图像信息
提取动态图片[c.gif]中的图像信息
提取视频[d.mp4]中的图像信息
提取视频[e.avi]中的图像信息
给静态图片[a.jpg]添加水印
给静态图片[b.png]添加水印
给动态图片[c.gif]添加水印
给视频[d.mp4]添加水印
给视频[e.avi]添加水印

Process finished with exit code 0

可见,完整版的访问者模式,大大提高代码的可扩展性,使其更解耦、更易维护,假如日后需要新增一种媒体文件操作,直接新增一个访问者,在这个访问者类里实现相关逻辑操作即可。

细心的你,可能也会发现,访问者模式也并不是没有缺点,比如,如果要处理的媒体类型文件不止三种,那么每一个访问者都得相应增加对应的媒体类型文件的操作方法。是的,这是不可避免的,事实上,也不存在一种十全十美的设计模式,每一种设计模式都必然存在着缺点和优点,就像一把双刃剑。我们需要明白的是,在代码架构设计中,设计模式只是一种工具,一种辅助性的东西,而真正帮助我们设计更好的代码架构,是设计模式中背后的那些设计思想、设计原理,知道这个设计模式怎么来,可能会比如何使用这个设计模式更加重要,正所谓授人以鱼不如授人以渔,同时我们也该知道,每一种设计模式,都是在某一种业务场景需求下诞生的,是因为产生了问题,才会有解决问题的办法。


总结

这里总结一下,如果某一种编程语言支持双分派,也就不需要访问者模式了,访问者模式,相比于上述编译不通过的代码,还更复杂些。而访问者模式,它的本质是针对相同的内容或信息,不同的访问者可以做不同的处理,对于上述的业务场景,当然也可以有其他的设计方案,如策略模式,但在这里,访问者模式可能更适合一些,策略模式更偏向于同样的操作,不同的算法。不过,如果你已经掌握访问者模式背后的设计思想,在实际应用中,不论你的设计是不是真正的访问者模式,能应用上其背后的设计思想,就已经达到目的了。


THE END

如果本文对你有帮助,不妨关注个公众号,平时一起聊聊技术,聊聊职场,聊聊人生。
在这里插入图片描述