完成C++不能做到的事 - Visitor模式

發表於2016-01-05

拿著剛磨好的熱咖啡,我坐在了顯示器前。“美好的一天又開始了”,我想。

昨晚做完了一個非常困難的任務並送給美國同事Review,因此今天只需要根據他們提出的意見適當修改程式碼並提交,一週的任務就完成了。剩下的兩三天裡,我就可以有一些空餘的時間看看其它資料來繼續充實自己了。

開啟Review Board,可以看到我的程式碼已經被標記為可以提交,但是下面所留的註解引起了我的注意:

Great job! With this solution, we can start our integration work and perform testing earlier. One thing is that we have used several “instance of” in the overrided function. That’s double dispatch, an obvious signature for using Visitor pattern. We can switch to that pattern in our future work.

Visitor模式我知道,但是Double Dispatch是什麼意思?我開啟了搜尋引擎,找了幾篇有關Double Dispatch的介紹性文章開始讀了起來。

Double Dispatch

當然,對Double Dispatch描述最為清晰和準確的還是在Wikipedia上:

In software engineering, double dispatch is a special form of multiple dispatch, and a mechanism that dispatches a function call to different concrete functions depending on the runtime types of two objects involved in the call. In most object-oriented systems, the concrete function that is called from a function call in the code depends on the dynamic type of a single object and therefore they are known as single dispatch calls, or simply virtual function calls.

而在該段文字的最後,我看到了一個再熟悉不過的名詞“virtual function”。一看到這個詞,我腦中就開始回憶對虛擬函式進行呼叫的步驟:在呼叫虛擬函式的時候,C++執行時將首先查詢物件所對應的虛擬函式表,然後根據虛擬函式表中所記錄的地址來呼叫相應的虛擬函式實現。由於虛擬函式表是與型別相關聯的,因此對虛擬函式進行呼叫所執行的邏輯就與物件本身的型別相關。

而Double Dispatch則需要和參與函式呼叫的兩個物件相關。於是我想:那通過為型別新增一個函式過載,不就可以實現Double Dispatch了麼?我開啟Visual Studio,並在其中寫下了如下的程式碼:

點選執行,答案卻不是我想的那樣:

132149452929155

啊,銷售經理並沒有提供額外的折扣。這可是個大麻煩。啟動Visual Studio的除錯功能,我看到了語句“pSalesManager->GetDiscountRate(benz)”所呼叫的是SalesManager類中定義的為普通汽車所定義的過載:

難道我對函式過載的理解不對?在搜尋引擎中鍵入“C++ overload resolution”,我開啟了C++標準中有關函式過載決議的講解。其開始的一段話就給了我答案:

In order to compile a function call, the compiler must first perform name lookup, which, for functions, may involve argument-dependent lookup, and for function templates may be followed by template argument deduction. If these steps produce more than one candidate function, then overload resolution is performed to select the function that will actually be called.

哦,對!函式過載決議是在編譯時完成的。也正因為我們傳入的是Vehicle型別的引用,編譯器並沒有辦法知道在執行時傳入GetDiscountRate()這個函式的引數到底是Vehicle例項還是Benz例項,因此編譯器只可能選擇呼叫接受Vehicle型別引用的過載。如果傳入引數benz的型別不再是Vehicle的引用,而是更具體的Benz的引用,那麼編譯器將會正確地決定到底其所需要呼叫的函式:

132157339641208

但這就不再是根據引數的型別動態決定需要呼叫的邏輯了,也就不再是Double Dispatch了。要如何達到這種效果呢?我苦苦地思索著。

“你在想什麼?”身邊的同事遞給我今天公司派發的水果,一邊吃著一邊問我。我就把我剛剛寫出的程式以及我現在正在考慮的問題告訴了他。

“既然你要動態決定需要呼叫的邏輯,那麼就把這些邏輯放到動態執行的地方去啊,比如說放到你那些汽車類裡面然後暴露一個虛擬函式,就可以根據所傳入的汽車型別決定該汽車所需要使用的折扣率了啊。”

“哦對”,我恍然大悟。C++在執行時動態決議的基本方法就是虛擬函式,也就是一種Single Dispatch,如果依次在物件和傳入引數上連續呼叫兩次虛擬函式,那麼它不就是Double Dispatch了麼?在銷售汽車這個例子中,我希望同時根據銷售人員的職稱和所銷售的汽車型別一起決定需要執行的邏輯。那麼我們首先需要通過Sales型別的指標呼叫一個虛擬函式,從而可以根據銷售人員的實際型別來決定其在銷售時所需要執行的實際邏輯。而在執行這些邏輯的過程中,我們還可以繼續呼叫傳入引數例項上定義的虛擬函式,就可以根據傳入引數的型別來決定需要執行的邏輯了!

說做就做。我在Vehicle類中新增一個新的虛擬函式GetManagerDiscountRate(),以允許SalesManager類的函式實現中呼叫以獲得銷售經理所能拿到的折扣,並在Benz類中重寫它以返回針對賓士的特有折扣率。而在Sales以及SalesManager類的實現中,我們則需要分別呼叫GetBaseDiscountRate()以及新的GetManagerDiscountRate()函式來分別返回普通銷售和銷售經理所能拿到的折扣率。通過這種方式,我們就可以同時根據銷售人員的職務以及所銷售車型來共同決定所使用的折扣率了。更改後的程式碼如下所示:

再次執行程式,我發現現在已經可以得到正確的結果了:

132206516984449

也就是說,我自創的Double Dispatch實現已經能夠正確地執行了。

你好,Visitor

“你說為什麼C++這些高階語言不直接支援Double Dispatch?”我問身邊正在和水果奮鬥的同事。

“不需要唄。”他頭也不抬,隨口回答了一句,又拿起了另一隻水果。

話說,他可真能吃。

“真的不需要麼?”我心裡想,就又在搜尋引擎中輸入了“why C++ double dispatch”。

在多年的工作中,我已經養成了一種固定的學習習慣。例如對於一個知識點,我常常首先了解How,即它是如何工作的;然後是Why,也就是為什麼按照這樣的方式來工作;然後才是When,即在知道了為什麼按照這樣的方式來工作後,我們才能在適當的情況下使用它。

幸運的是,在很多論壇中已經討論過為什麼這些語言不直接支援Double Dispatch了。簡單地說,一個語言常常不能支援所有的功能,否則這個語言將會變得非常複雜,編寫它的編譯器及執行時也將變成非常困難的事情。因此到底支援哪些功能實際上由一個語言的目標領域所決定的。在一個語言可以通過一種簡單明瞭的方式解決一種特定問題的時候,該語言就不再必須為該特定問題提供一個內建的解決方案。這些解決方案會逐漸固定下來,並被賦予了一個特有的名字。例如C++中的一種常用模式就是Observer。該模式實現起來非常簡單,也易於理解。而在其它語言中就可能提供了對Observer的原生支援,如C#中的delegate。而Visitor模式實際上就是C++對Double Dispatch功能的標準模擬。

接下來,我又搜尋了幾個Visitor模式的標準實現並開始比較自己所實現的Double Dispatch與Visitor模式標準實現之間的不同之處。這又是我的另一個習慣:實踐常常可以檢驗出自己對於某個知識點的理解是否有偏差。就像我剛剛所犯下的對過載決議的理解錯誤一樣,形成自己解決方案的過程常常會使自己理解某項技術為什麼這麼做有更深的理解。而通過對比自己的解決方案和標準解決方案,我可以發現別人所做的一些非常精巧的解決方案,並標準化自己的實現。

我仔細地檢查了自己剛才所寫的有關銷售汽車的例項與標準Visitor模式實現之間的不同。顯然Visitor模式的標準實現更為聰明:在Sales和SalesManager的成員函式中,編譯器知道this所指向的例項的型別,因此將*this當作引數傳入到函式中就可以正確地利用C++所提供的函式過載決議功能。這比我那種在實現中呼叫不同函式的方法高明瞭不知多少:

那麼在Vehicle類以及Benz類中,我們只需要建立接收不同型別引數的函式過載即可:

而在Visitor模式的標準實現中,我們則需要使用Visit()及Accept()函式對替換上面的各成員函式,併為所誘得汽車及銷售人員定義一個公共介面。因此對於上面的銷售汽車的示例,其標準的Visitor模式實現為:

“那Visitor模式該如何進行擴充套件呢?”我自己問自己。畢竟在企業級應用中,各組成的擴充套件性可以很大程度上決定系統的維護性和擴充套件性。

我注意到上面的Visitor模式實現中主要分為兩大類型別:IVehicle和ISales。在該Visitor實現中新增一個新的汽車型別十分容易。從IVehicle派生並實現相應的邏輯即可:

但是新增一個實現了ISales介面的型別則非常困難:需要更改所有已知的汽車型別並新增特定於該介面實現型別的過載。

那在遇到兩部分組成都需要更改的情況該怎麼辦呢?經過查詢,我也發現了一種允許同時新增兩型別的模式:Acyclic Visitor。除此之外,還有一系列相關的模式,如Hierachical Visitor Pattern。看來和Visitor模式相關的各種知識還真是不少呢。

我再次開啟搜尋引擎,繼續我的自我學習之旅。而身邊的同事也繼續和水果奮鬥著。

關聯閱讀

面試中的Singleton:http://blog.jobbole.com/96963/

Reference

有關C++過載決議的講解:http://en.cppreference.com/w/cpp/language/overload_resolution

Acyclic Visitor模式:http://www.objectmentor.com/resources/articles/acv.pdf

Hierachical Visitor Pattern模式:http://en.wikipedia.org/wiki/Hierarchical_visitor_pattern

相關文章