於海寧(景帆) 淘系技術
前言在軟體開發領域,我們每次遇到的問題可能都各不相同,有些是跟電商業務相關的,有些是跟底層資料結構相關的,而有些則可能重點在效能最佳化上。然而不管怎麼樣,我們在程式碼層面上解決問題的方法都有一定的共性。有沒有人總結過這些共性呢?
當然有。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 { ...}
初步解決方法我們定義一個新的類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”才對。認真的你又去查了一些資料,這才發現了原因。
解釋原因
為了弄清楚原因,我們首先了解一下編輯器的兩種變數型別繫結模式。
我們來看一下這段程式碼
class NodeService { public String getName(Node node) { return node.getName(); }}
當程式執行NodeService::getName的時候,它必須判斷出引數Node的型別,到底是Factory,是School,還是Building,因為這樣才能呼叫對應實現類的getName方法。那程式能夠在編譯階段就拿到這個資訊嗎?顯然不能,因為Node的型別是可能會根據執行環境而變化的,甚至有可能是另外一個系統傳過來的,我們不可能在編譯階段拿到這個資訊。程式能做的,就是先啟動,在執行到getName方法的時候,看一下Node到底是什麼型別,然後再呼叫對應型別的getName()實現,拿到結果。 在執行時(而不是編譯時)決定呼叫哪個方法,這就叫做Dynamic/Late 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,比如
String name = node.getName();
我們呼叫的是Factory::getName, School::getName還是Building::getName呢?這主要取決於node的實現類是什麼,這就是Single 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 Steroidshttp://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 - Visitorhttps://refactoring.guru/design-patterns/visitor Pattern Matching for instanceof in Java 14https://refactoring.guru/design-patterns/visitor