cover_image

设计模式|从Visitor Pattern说到Pattern Matching

于海宁(景帆) 大淘宝技术
2020年12月21日 08:21

图片


前言



在软件开发领域,我们每次遇到的问题可能都各不相同,有些是跟电商业务相关的,有些是跟底层数据结构相关的,而有些则可能重点在性能优化上。然而不管怎么样,我们在代码层面上解决问题的方法都有一定的共性。有没有人总结过这些共性呢?


当然有。1994年,Erich Gamma, Richard Helm, Ralph Johnson和John Vlissides合作发表了一本在业界具有重大意义的书:Design Patterns: Elements of Reusable Object-Oriented Software,这本书把人们在开发领域所能遇到的各种问题共性都做了一系列抽象,最终形成了23种非常经典的设计模式。很多问题都可以抽象为这23种设计模式中的一种或几种。由于设计模式的通用性非常强,它们也成为了开发者所用的一种通用的语言,抽象为设计模式的代码更易于理解和维护。


从整体上来说,设计模式分为三个类别:


1. Creational Patterns: 创造和重用对象相关的设计模式
2. Structural patterns: 组合和搭建对象相关的设计模式
3. Behavioral patterns: 对象之间行为相关的设计模式


本文所阐述的设计模式是Visitor Pattern,它属于Behavior Pattern的一种,用于解决具有类似行为的对象如何组合并扩展的问题。更具体一点来说,本文介绍了Visitor Pattern的使用场景、优势、劣势,与Visitor Pattern相关的Double Dispatch技术。并在文章的最后,说明了如何用Java 14刚推出的Pattern Matching解决以往Visitor Pattern所解决的问题。



问题



假设现在有一个地图程序,地图上有很多节点,比如楼房(Building),工厂(Factory),学校(School),如下所示:


interface Node {    String getName();    String getDescription();    // 其余的方法这里忽略......}
class Building implements Node { ...}
class Factory implements Node { ...}
class School implements Node { ...}


来了一个新需求:需要加入绘制Node功能,你一想,很简单啊,我们就在Node里面加上一个方法draw(),然后其余的实现类分别实现这个方法就好了。但是这么做其实有个问题,我们这次加上的是draw()方法,那下次还要加个export方法呢?再加还是要再修改接口。 接口作为衔接组件的桥梁,应该尽可能保持稳定,不应频繁更改。 所以,你希望能够让 接口的可扩展性尽可能高,在不频繁更改接口的情况下最大程度上扩展接口的功能范围 。一番权衡之后,你提出了以下解法。


初步解决方法



我们定义一个新的类DrawService,把所有的draw逻辑都写在这里面,代码如下:


public class DrawService {    public void draw(Building building) {        System.out.println("draw building");    }    public void draw(Factory factory) {        System.out.println("draw factory");    }    public void draw(School school) {        System.out.println("draw school");    }    public void draw(Node node) {        System.out.println("draw node");    }}


这是类图:


图片


你觉得这下解决问题了,因此准备再稍微测试一下就下班回家:


public class App {    private void draw(Node node) {        DrawService drawService = new DrawService();        drawService.draw(node);    }
public static void main(String[] args) { App app = new App(); app.draw(new Factory()); }}


点击运行,输出:


draw node


这是怎么回事?你又仔细看了看自己的代码:“我确实传的是个Factory对象啊,应该输出draw factory”才对。认真的你又去查了一些资料,这才发现了原因。



解释原因



为了弄清楚原因,我们首先了解一下编辑器的两种变量类型绑定模式。


★  Dynamic/Late Binding


我们来看一下这段代码


class NodeService {    public String getName(Node node) {        return node.getName();    }}


当程序运行NodeService::getName的时候,它必须判断出参数Node的类型,到底是Factory,是School,还是Building,因为这样才能调用对应实现类的getName方法。那程序能够在编译阶段就拿到这个信息吗?显然不能,因为Node的类型是可能会根据运行环境而变化的,甚至有可能是另外一个系统传过来的,我们不可能在编译阶段拿到这个信息。程序能做的,就是先启动,在运行到getName方法的时候,看一下Node到底是什么类型,然后再调用对应类型的getName()实现,拿到结果。 在运行时(而不是编译时)决定调用哪个方法,这就叫做Dynamic/Late Binding。


★  Static/Early Binding


我们再来看另外一段代码


public void drawNode(Node node) {    DrawService drawService = new DrawService();    drawService.draw(node);}


当我们运行到 drawService.draw(node) 的时候,编译器知道node的类型吗?运行时是肯定知道的,那为什么我们传了一个Factory进去,却输出了 draw node 而不是 draw factory 呢?我们可以站在程序的角度来想这个问题。DrawService中只有4个draw方法,参数类型分别是Factory, Building, School和Node,如果调用方传了一个City进来怎么办?毕竟调用方可以自己实现一个City类传进来。这种情况下程序该调用什么方法呢?我们没有draw(City)方法,为了防止这种情况发生,程序在编译阶段就直接选择使用DrawService::draw(Node)方法。无论调用方传了什么实现进来,我们都统一使用DrawService::draw(Node)方法以确保程序安全运行。 在编译时(而不是运行时)决定调用哪个方法,这就叫做Static/Early Binding。 这也就解释了我们为什么输出了 draw node 。


最终解决方法



原来这是因为编译器不知道变量类型导致的,既然这样的话,我们直接告诉编译器这是什么类型好了。这能做到吗?这当然能做到,我们提前检测变量类型。


if (node instanceof Building) {    Building building = (Building) node;    drawService.draw(building);} else if (node instanceof Factory) {    Factory factory = (Factory) node;    drawService.draw(factory);} else if (node instanceof School) {    School school = (School) node;    drawService.draw(school);} else {    drawService.draw(node);}


这段代码是可行的,但是就是写起来非常繁琐,我们需要让调用方判断node类型并选择需要调用的方法,有没有更好的方案?有,那就是Visitor Pattern,Visitor Pattern使用了一种叫做Double Dispatch的方法,它可以把路由的工作从调用方转移到各自的实现类中,这样客户端就不需要写这些繁琐的判断逻辑了,我们首先看一下实现后的代码是什么样的。


interface Visitor {    void visit(Node node);    void visit(Factory factory);    void visit(Building building);    void visit(School school);}
class DrawVisitor implements Visitor {
@Override public void visit(Node node) { System.out.println("draw node"); }
@Override public void visit(Factory factory) { System.out.println("draw factory"); }
@Override public void visit(Building building) { System.out.println("draw building"); }
@Override public void visit(School school) { System.out.println("draw school"); }}
interface Node { ... void accpet(Visitor v);}
class Factory implements Node { ...
@Override public void accept(Visitor v) { /** * 调用方知道visit的参数就是Factory类型的,并且知道Visitor::visit(Factory)方法确实存在, * 因此会直接调用Visitor::visit(Factory)方法 */ v.visit(this); }}
class Building implements Node { ...
@Override public void accept(Visitor v) { /** * 调用方知道visit的参数就是Building类型的,并且知道Visitor::visit(Building)方法确实存在, * 因此会直接调用Visitor::visit(Building)方法 */ v.visit(this); }}
class School implements Node { ...
@Override public void accept(Visitor v) { /** * 调用方知道visit的参数就是School类型的,并且知道Visitor::visit(School)方法确实存在, * 因此会直接调用Visitor::visit(School)方法 */ v.visit(this); }}


调用方这么用就可以了


Visitor drawVisitor = new DrawVisitor();Factory factory = new Factory();factory.accept(drawVisitor);


可以看出,Visitor Pattern其实就是优雅地实现了我们上面的if instanceof,这样调用方的代码就干净了很多,整体类图如下


图片



为什么叫Double Dispatch?



了解了Visitor Pattern如何解决这个问题之后,有些同学可能就会产生好奇,为什么Visitor Pattern使用的技术叫做Double Dispatch?到底什么叫做Double Dispatch?在了解Double Dispatch之前,我们先了解一下什么叫做Single Dispatch


★  Single Dispatch


根据运行时类实现的不同选择不同的调用方法,这就叫做Single Dispatch,比如


String name = node.getName();


我们调用的是Factory::getName, School::getName还是Building::getName呢?这主要取决于node的实现类是什么,这就是Single Dispatch:一层路由


★  Double Dispatch


回顾一下我们刚才的Visitor Pattern代码


node.accept(drawVisitor);


这里面有两层路由:


  • 择accept的具体实现方法(Factory::accept, School::accept或者Building::accept)

  • 选择visit的具体方法(本例中只有一个DrawVisit::visit)


做了两次路由,才执行到了对应的逻辑,这就叫做Double Dispatch



Visitor Pattern的优势



1、Visitor Pattern能够尽可能地在不频繁改变接口(只需要改变一次:增加一个accept方法)的情况下,增加接口的可扩展性    


还是上面那个draw的例子,假设我们现在又来了一个新需求,需要加上显示节点信息的功能。当然传统的做法是在Node里面增加一个新的方法showDetails(),但是现在我们不需要更改接口了,我们只需要再增加一个新的Visitor就可以了。


class ShowDetailsVisitor implements Visitor {
@Override public void visit(Node node) { System.out.println("node details"); }
@Override public void visit(Factory factory) { System.out.println("factory details"); }
@Override public void visit(Building building) { System.out.println("building details"); }
@Override public void visit(School school) { System.out.println("school details"); }}
// 调用方这么使用Visitor showDetailsVisitor = new ShowDetailsVisitor();Factory factory = new Factory();factory.accept(showDetailsVisitor); // factory details


从这个例子中,我们就可以看出Visitor Pattern的一个典型的使用场景:它非常适合用在需要经常增加接口方法的场景里。比如说,我们现在有4个类A,B,C,D,三个方法x, y, z,横向画方法,纵向画类,我们可以得到下图:


               x      y      z    A       A::x   A::y   A::z    B       B::x   B::y   B::z    C       C::x   C::y   C::z


一般情况下我们这个表格是纵向扩展的,也就是说,我们习惯于增加实现类而不是实现方法。而Visitor Pattern却恰好适用于另一种场景:横向扩展。我们需要频繁地增加接口方法,而不是增加实现类。Visitor Pattern能让我们在不频繁修改接口的情况下实现这一目标。


2、Visitor Pattern可以方便地让多个实现类共用一个逻辑


由于所有的实现方法均写在一个类中(如DrawVisitor),我们可以非常方便地让各个类型(如Factory/Building/School)都使用同一个逻辑,而不是把这个逻辑重复写在每个接口实现类中。



Visitor Pattern的劣势



  • Visitor Pattern打破了领域模型的封装


正常情况下,关于Factory的逻辑我们都会写在Factory这个类中,但是Visitor Pattern却要求我们把Factory的一部分逻辑(如draw)挪动到另一个类中(DrawVisitor),一个领域模型的逻辑分散在两个地方,这对领域模型的理解和维护带来了不便。


  • Visitor Pattern在某种程度上造成了实现类逻辑耦合


所有实现类(Factory/School/Building)的方法(draw)全部都写在一个类(DrawVisitor)中,这在某种程度上属于逻辑耦合,不利于代码维护。


  • Visitor Pattern使得类之间的关系变得复杂,不易于理解


就像Double Dispatch这个名字所显示的那样,我们需要两次dispatch才能成功调用到对应的逻辑:第一步是调用accpet方法,第二部是调用visit方法,调用关系变得比较复杂,后面的代码维护人很容易就能搞乱这些代码。



Pattern Matching



这里再加个小插曲。java 14引入了Pattern Matching特性,这个特性虽然在Scala/Haskel领域已经存在多年了,但是由于Java刚刚引入,很多同学还不知道这是什么东西。因此,在说明Pattern Matching和Visitor Pattern的关系之前,我们先简单介绍一下Pattern Matching是什么。还记得我们写过这段代码吗?


if (node instanceof Building) {    Building building = (Building) building;    drawService.draw(building);} else if (node instanceof Factory) {    Factory factory = (Factory) factory;    drawService.draw(factory);} else if (node instanceof School) {    School school = (School) school;    drawService.draw(school);} else {    drawService.draw(node);}


有了Pattern Matching后,我们就可以简化这段代码:


if (node instanceof Building building) {    drawService.draw(building);} else if (node instanceof Factory factory) {    drawService.draw(factory);} else if (node instanceof School school) {    drawService.draw(school);} else {    drawService.draw(node);}


不过Java的Pattern Matching还是略显繁琐,Scala的能够好一些:


node match {  case node: Factory => drawService.draw(node)  case node: Building => drawService.draw(node)  case node: School => drawService.draw(node)  case _ => drawService.draw(node)}


由于写起来比较简洁,很多人就提倡将Pattern Matching作为Visitor Pattern的替代品。我个人也是觉得Pattern Matching看起来简洁了很多。很多人以为Pattern Matching就是高级版的switch case,其实不然,具体可以看一下TOUR OF SCALA - PATTERN MATCHING(https://docs.scala-lang.org/tour/pattern-matching.html),关于Visitor Pattern和Pattern Matching的关系可以看一下Scala's Pattern Matching = Visitor Pattern on Steroids,本文就不再赘述了。

参考资料:

  • Scala's Pattern Matching = Visitor Pattern on Steroids

    http://andymaleh.blogspot.com/2008/04/scalas-pattern-matching-visitor-pattern.html 

  • When should I use the Visitor Design Pattern?

    http://andymaleh.blogspot.com/2008/04/scalas-pattern-matching-visitor-pattern.html 

  • Design Pattern - Behavioral Patterns - Visitor

    https://refactoring.guru/design-patterns/visitor 

  • Pattern Matching for instanceof in Java 14

    https://refactoring.guru/design-patterns/visitor 


淘系技术部-行业与智能运营-诚招英才

我们是阿里巴巴运营工作台数据洞察团队,这里有海量的数据、高性能的实时计算引擎,充满挑战的业务场景。从618到双11,从淘宝到天猫,从数据分析到业务沉淀,我们将追求完美的意志和氛围散播到技术圈的每一个角落。期待有技术追求和技术深度的你的加入!

招聘岗位:Java技术专家、数据工程师
如果您有兴趣可将简历发至
haining.yhn@alibaba-inc.com 欢迎来撩~


✿  拓展阅读


图片
图片
图片
作者|于海宁(景帆)

编辑|橙子君

出品|阿里巴巴新零售淘系技术

图片

图片


后端技术分享 · 目录
上一篇CDN工作原理及其在淘宝图片业务中的应用下一篇DDD系列第四讲:领域层设计规范
继续滑动看下一个
大淘宝技术
向上滑动看下一个