? ?前言? ?
Visitor模式在日常工作中出場比較少,如果統計大家不熟悉的模式,那么它榜上有名的可能性非常大。使用頻率少,再加上很多文章提到Visitor模式都著重于它克服語言單分派的特點上面,而對何時應該使用這個模式及這個模式是怎么一點點演講出來的提之甚少,造成很多人對這個模式有種霧里看花的感覺,今天跟著老胡,我們一起來一點點揭開它的面紗吧。
? ?模式演進? ?
舉個例子
現在假設我們有一個簡單的需求,需要統計出一篇文檔中的字數、詞數和圖片數量。其中字數和詞數存在于段落中,圖片數量單獨統計。于是乎,我們可以很快的寫出第一版代碼
使用了基本抽象的版本
abstract?class?DocumentElement{public?abstract?void?UpdateStatus(DocumentStatus?status);}public?class?DocumentStatus{public?int?CharNum?{?get;?set;?}public?int?WordNum?{?get;?set;?}public?int?ImageNum?{?get;?set;?}public?void?ShowStatus(){Console.WriteLine("I?have?{0}?char,?{1}?word?and?{2}?image",?CharNum,?WordNum,?ImageNum);}}class?ImageElement?:?DocumentElement{public?override?void?UpdateStatus(DocumentStatus?status){status.ImageNum++;}}class?ParagraphElement?:?DocumentElement{public?int?CharNum?{?get;?set;?}public?int?WordNum?{?get;?set;?}public?ParagraphElement(int?charNum,?int?wordNum){CharNum?=?charNum;WordNum?=?wordNum;}public?override?void?UpdateStatus(DocumentStatus?status){status.CharNum?+=?CharNum;status.WordNum?+=?WordNum;}}class?Program{static?void?Main(string[]?args){DocumentStatus?docStatus?=?new?DocumentStatus();List<DocumentElement>?list?=?new?List<DocumentElement>();DocumentElement?e1?=?new?ImageElement();DocumentElement?e2?=?new?ParagraphElement(10,?20);list.Add(e1);list.Add(e2);list.ForEach(e?=>?e.UpdateStatus(docStatus));docStatus.ShowStatus();}}
運行結果如下,非常簡單
但是細看這版代碼,會發現有以下問題:
?所有的DocumentElement派生類必須訪問DocumentStatus,根據迪米特法則,這不是個好現象,如果在未來對DocumentStatus有修改,這些派生類被波及的可能性極大
?統計代碼散落在不同的派生類里面,維護不方便
有鑒于此,我們推出了第二版代碼
? ?使用了Tpye-Switch的版本? ?
這一版代碼中,我們摒棄了之前在具體的DocumentElement派生類中進行統計的做法,直接在統計類中統一處理
public?abstract?class?DocumentElement{//nothing?to?do?now}public?class?DocumentStatus{public?int?CharNum?{?get;?set;?}public?int?WordNum?{?get;?set;?}public?int?ImageNum?{?get;?set;?}public?void?ShowStatus(){Console.WriteLine("I?have?{0}?char,?{1}?word?and?{2}?image",?CharNum,?WordNum,?ImageNum);}public?void?Update(DocumentElement?documentElement){switch(documentElement){case?ImageElement?imageElement:ImageNum++;break;case?ParagraphElement?paragraphElement:WordNum?+=?paragraphElement.WordNum;CharNum?+=?paragraphElement.CharNum;break;}}}public?class?ImageElement?:?DocumentElement{}public?class?ParagraphElement?:?DocumentElement{public?int?CharNum?{?get;?set;?}public?int?WordNum?{?get;?set;?}public?ParagraphElement(int?charNum,?int?wordNum){CharNum?=?charNum;WordNum?=?wordNum;}}class?Program{static?void?Main(string[]?args){DocumentStatus?docStatus?=?new?DocumentStatus();List<DocumentElement>?list?=?new?List<DocumentElement>();DocumentElement?e1?=?new?ImageElement();DocumentElement?e2?=?new?ParagraphElement(10,?20);list.Add(e1);list.Add(e2);docStatus.ShowStatus();}}
測試結果和第一個版本的代碼一樣,這一版代碼克服了第一個版本中,統計代碼散落,具體類依賴統計類的問題,轉而我們在統計類中集中處理了統計任務。但同時它引入了type-switch, 這也是一個不好的信號,具體表現在:
?代碼冗長且難以維護?如果派生層次加多,需要很小心的選擇case順序以防出現繼承層次較低的類出現在繼承層次更遠的類前面,從而造成后面的case永遠無法被訪問的情況,這造成了額外的精力成本
嘗試使用重載的版本
有鑒于上面type-switch版本的問題,作為敏銳的程序員,可能馬上有人就會提出重載方案:“如果我們針對每個具體的DocumentElement寫出相應的Update方法,不就可以了嗎?”就像下面這樣
public?class?DocumentStatus{//省略相同代碼public?void?Update(ImageElement?imageElement){ImageNum++;}public?void?Update(ParagraphElement?paragraphElement){WordNum?+=?paragraphElement.WordNum;CharNum?+=?paragraphElement.CharNum;}}//省略相同代碼class?Program{static?void?Main(string[]?args){DocumentStatus?docStatus?=?new?DocumentStatus();List<DocumentElement>?list?=?new?List<DocumentElement>();list.Add(new?ImageElement());list.Add(new?ParagraphElement(10,?20));list.ForEach(e?=>?docStatus.Update(e));docStatus.ShowStatus();}}
看起來很好,不過可惜,這段代碼編譯失敗,編譯器會抱怨說,不能將DocumentElement轉為它的子類,這是為什么呢?講到這里,就不能不提一下編程語言中的單分派和雙分派
? ?單分派與雙分派? ?
大家都知道,多態是OOP的三個基本特征之一,即形如以下的代碼
public?class?Father{public?virtual?void?DoSomething(string?str){}}public?class?Son?:?Father{public?override?void?DoSomething(string?str){}}Father?son?=?new?Son();son.DoSomething();
son 雖然被聲明為Father類型,但在運行時會被動態綁定到其實際類型Son并調用到正確的被重寫后的函數,這是多態,通過調用函數的對象執行動態綁定。在主流語言,比如C#, C++ 和 JAVA中,編譯器在編譯類函數的時候會進行擴充,把this指針隱含的傳遞到方法里面,上面的方法會擴充為
void?DoSomething(this,?string);void?DoSomething(this,?string);
在多態中實現的this指針動態綁定,其實是針對函數的第一個參數進行運行時動態綁定,這個也是單分派的定義。至于雙分派,顧名思義,就是可以針對兩個參數進行運行時綁定的分派方法,不過可惜,C#等都不支持,所以大家現在應該能理解為什么上面的代碼不能通過編譯了吧,上面的代碼通過編譯器的擴充,變成了
public?void?Update(DocumentStatus?status,?ImageElement?imageElement)public?void?Update(DocumentStatus?status,?ParagraphElement?imageElement)
因為C#不支持雙分派,第二參數無法動態解析,所以就算實際類型是ImageElement,但是聲明類型是其基類DocumentElement,也會被編譯器拒絕。所以,為了在本不支持雙分派的C#中實現雙分派,我們需要添加一個跳板函數,通過這個函數,我們讓第二參數充當被調用對象,實現動態綁定,從而找到正確的重載函數,我們需要引出今天的主角,Visitor模式。
? ?Visitor模式? ?
Visitor is a behavioral design pattern that lets you separate algorithms from the objects on which they operate.
翻譯的更直白一點,Visitor模式允許針對不同的具體類型定制不同的訪問方法,而這個訪問者本身,也可以是不同的類型,看一下UML
在Visitor模式中,我們需要把訪問者抽象出來,以方便之后定制更多的不同類型的訪問者。
抽象出DocumentElementVisitor,含有兩個版本的Visit方法,在其子類中具體定制針對不同類型的訪問方法
public?abstract?class?DocumentElementVisitor{public?abstract?void?Visit(ImageElement?imageElement);public?abstract?void?Visit(ParagraphElement?imageElement);}public?class?DocumentStatus?:?DocumentElementVisitor{public?int?CharNum?{?get;?set;?}public?int?WordNum?{?get;?set;?}public?int?ImageNum?{?get;?set;?}public?void?ShowStatus(){Console.WriteLine("I?have?{0}?char,?{1}?word?and?{2}?image",?CharNum,?WordNum,?ImageNum);}public?void?Update(DocumentElement?documentElement){documentElement.Accept(this);}public?override?void?Visit(ImageElement?imageElement){ImageNum++;}public?override?void?Visit(ParagraphElement?paragraphElement){WordNum?+=?paragraphElement.WordNum;CharNum?+=?paragraphElement.CharNum;}}
在被訪問類的基類中添加一個Accept方法,這個方法用來實現雙分派,這個方法就是我們前文提到的跳板函數,它的作用就是讓第二參數充當被調用對象,第二次利用多態(第一次多態發生在調用Accept方法的時候)
public?abstract?class?DocumentElement{public?abstract?void?Accept(DocumentElementVisitor?visitor);}public?class?ImageElement?:?DocumentElement{public?override?void?Accept(DocumentElementVisitor?visitor){visitor.Visit(this);}}public?class?ParagraphElement?:?DocumentElement{public?int?CharNum?{?get;?set;?}public?int?WordNum?{?get;?set;?}public?ParagraphElement(int?charNum,?int?wordNum){CharNum?=?charNum;WordNum?=?wordNum;}public?override?void?Accept(DocumentElementVisitor?visitor){visitor.Visit(this);}}
這里,Accept方法就是Visitor模式的精髓,通過調用被訪問基類的Accept方法,被訪問基類通過語言的單分派,動態綁定了正確的被訪問子類,接著在子類方法中,將第一參數當做執行對象再調用一次它的方法,根據語言的單分派機制,第一參數也能被正確的動態綁定類型,這樣就實現了雙分派
這就是Visitor模式的簡單介紹,這個模式的好處在于:
?克服語言沒有雙分派功能的缺陷,能夠正確的解析參數的類型,尤其當想要對一個繼承族群類的不同子類定制訪問方法時,這個模式可以派上用場
?非常便于添加訪問者,試想,如果我們未來想要添加一個DocumentPriceCount,需要對段落和圖片計費,我們只需要新建一個類,繼承自DocumentVisitor,同時實現相應的Visit方法就行
希望大家通過這篇文章,能對Visitor模式有一定了解,在實踐中可以恰當的使用。?