換種思路去理解設計模式(下)

王福朋發表於2014-06-04

開寫之前,先把前兩部分的連結貼上。要看此篇,不許按順序看完前兩部分,因為這本來就是一篇文章,只不過內容較多,分開寫的。

換種思路去理解設計模式(上)

換種思路去理解設計模式(中)

 

8       物件行為與操作物件

8.1     過程描述

所謂物件行為和操作物件,需要三方面內容:

操作過程:

一般表現為一個方法。該方法接收一個物件或者組合型別的引數,然後對這個物件或者組合進行操作,例如修改屬性、狀態或者結構等。

操作的物件或組合:

會作為實參傳入操作過程,會被操作過程修改屬性、狀態或者結構等。

受影響的物件或組合:

由於修改其他物件或者組合,可能會影響到另外的物件或者組合,因此需要考慮這種影響關係,一般會用通知或者訊息訂閱的方式實現。

 

從上文的物件建立和物件組合兩個模組,應該理解出在系統設計中會遇到各種複雜的情況。而物件操作比前兩者更加複雜,因為前兩者是建立一個靜態的結構,而物件操作則是一個動態的變化。在日常的開發工作中,物件操作也是我們付出精力最多的地方。

下面我們就物件操作過程中遇到的一些常見情況做詳細的分析。

8.2     情況1:“多配置”操作的優化

  當我們的一個方法因為需要實現多配置而不得不寫許多條件判斷語句時,我們會考慮將這個方法抽象出來,然後派生不同的子類。這是基本的設計思路。

  現在我們將這個情況複雜化,業務場景多了,一個方法無法實現這些功能,就需要拆分。

  

  如果這種情況下,再出現因為很多配置而不得不寫許多條件判斷語句時,我們肯定還需要再次考慮抽象和派生。效果如下圖:

  

   這就是——模板方法模式

  理解這個模式其實很簡單,只要知道根據多配置需要抽象、拆分即可。至於這裡的“模板”,可根據實際情況來使用或者改變。

 

8.3     情況2:序列操作的優化

  針對物件進行操作時,類似於流程一樣的序列操作,在系統中應用非常廣泛。而且各個序列的節點都有相對統一的操作過程,例如工作流的每個審批節點,都會修改物件狀態以及設定下級審批人等。

  遇到這種場景,我們最初會思考以下思路:

  

  後來隨著系統的升級和變更,程式碼越來越多,維護越來越困難。我們會先考慮將每一步操作都獨立成一個方法:

  

   一般的序列操作,可以用以上程式碼結構來處理,需要修改處可以再根據實際情況再重構。但如果序列操作中有條件因素,可能就有優化的空間了。如下程式碼:

  

  當隨著我們的條件越來越多,業務關係越來越負責時,維護這段程式碼就越來越複雜,也可能因為多人維護而帶來版本問題。需要優化。

  分析任何問題,都先要從業務上抽象。以上程式碼抽象出來有兩點:“操作”和“條件”。“操作”很容易抽象的,但是“條件”卻不好抽象。沒關係,職責鏈模式給了我們靈感,我們可以把“條件”看作“下一步的操作”。

  好了,我們把“操作”和“下一步的操作”抽象出來。然後將每一步的操作處理作為抽象的一個派生類。

  

  如上圖,每個子類是一步處理。每一步處理都實現了抽象類的RequestHandler()方法,都繼承了抽象類的success屬性(即下一步執行者)。這樣我們就可以設計ConcreteHandler1的success為ConcreteHandler2,ConcreteHandler2的success為ConcreteHandler3,從而構成了一個“職責鏈”。

  這就是職責鏈模式的設計思想。

 

8.4     情況3:遍歷物件各元素

  當對一個物件操作時,遍歷物件元素是最常見的操作之一,使用Java和C#的人用的最多的就是for和foreach(先放下foreach不說,後文會有解釋),C和C++一般用For迴圈。For迴圈簡單易用,但是有它設計上的缺點,先看一段for迴圈程式碼:

  

  程式碼中obj是個特別刺眼的變數,如果程式碼較多了,就會出現滿篇的obj。這裡的程式碼是客戶端,過多的obj就會導致了大量的耦合。如果obj的型別一點有修改,就會可能導致整個程式碼都要修改。

  設計是為了抽象和分離。我們需要一種設計來封裝這裡的obj以及對obj的遍歷過程。我們定義一個型別,它接收obj,負責遍歷obj,並把遍歷的介面開放給客戶端。

  

  程式碼中,我們通過Next()和IsDone()就可以完成一個遍歷。可以給客戶端提供First()、Current()、Last()等快捷介面。

  這樣,我們可以用這種方式迭代物件。

  

  程式碼中只用到一個obj,因為我們如果再有迭代過程,可以用iterator物件,而不是obj。這就是迭代器模式的設計思路。

  

  前文中提到了foreach迴圈。其實foreach是C#和java已經封裝好的一個迭代器,它的實現原理就是上文中講到的方法。在日常應用中,foreach在大部分情況下能滿足我們的需求。但是要真正理解foreach的用以,還需這個迭代器模式的幫助。

 

8.5     情況4:物件狀態變化

  改變一個物件的狀態,是再常見不過的操作了。例如一個物件的狀態變化是:

  

  這幾乎是最簡單的流程了,我們一般的開發思路如下:

  

  這是最基本的思路,如果再改進,可能會把狀態統一維護成一個列舉,而不是硬編碼在程式碼中直接寫“編輯”“結束”等。

  

  但是這樣改進,程式碼的邏輯和結構是不變的。仍然存在一個問題——當狀態變化的邏輯變複雜時,這段程式碼將變得非常複雜——大家應該都明白,在真正的系統中,狀態變化可比上面那個圖片複雜N倍。

  大家可能都注意到了,一旦遇到這種問題,那肯定是違反了開放封閉原則和單一職責原則。要改變這個問題,我們就得重構這段程式碼,從設計上徹底避免。

  首先要做的還是抽象,然後再去隔離和解耦。當前這個業務場景中,能抽象出來的是“狀態”和“狀態變化”。

  那麼我們就把狀態作為一個抽象類,把狀態變化這個都做作為抽象類中的抽象方法,然後針對每個狀態,都實現成一個子類。結構如下:

  

  然後再把物件關聯到狀態上,根據依賴倒置原則,物件將依賴於抽象類而非子類。

  

  上圖中,Context的狀態是一個State型別,所以無論State派生出多少個子類,都能成為Context的狀態。至於Context的狀態變化,就在State子類的Handle方法中實現。例如ConcreateStateA的handle方法,可以將Context的狀態賦值成ConcreteStateB物件,ConcreteStateB的handle方法,可以將Context的狀態賦值成ConcreteStateC(圖中沒有)物件……一次類推。這樣就將一個複雜的狀態變化鏈,分解到每一步狀態物件中。

  這種設計思路就是——狀態模式

8.6     情況5:記錄變化,撤銷操作

  上文提到如何改變物件的狀態,從這裡我們可以想到狀態的撤銷,以及其他各個屬性修改之後的撤銷操作。撤銷操作的主要問題就在於如何去儲存、恢復舊資料。

  最簡單的思路是直接定義一個額外的物件來儲存舊資料,

  

  如果需要撤銷,再從儲存舊資料的物件中獲取資訊,重新賦值給主物件。

  

  由此可能發現,上圖中客戶端的程式碼非常繁瑣,而且客戶端幾乎檢視到了主物件型別和封裝物件型別的所有資訊,沒有了所謂的“封裝”。這樣帶來的後果是,如果主物件屬性有變化,客戶端立刻就不可用,必須修改。

  其實客戶端應該只關注“備忘”和“撤銷”這兩件事情、這兩個操作,不必去關心主物件中有什麼屬性,以及備忘物件有什麼屬性。再思考,“備忘”和“撤銷”這兩個動作都是針對主物件進行的,主物件是這兩個動作的執行者和受益者。那麼為何不把這兩個動作直接交給主物件進行呢?

  根據以上思路重構程式碼可得:

  

  在原來程式碼的基礎上,我們又給主物件增加了兩個方法——建立備忘和撤銷。接下來客戶端的程式碼就簡單了。

  

  正如我們上面所說的,客戶端關注的只是這兩個動作,而不關心具體的屬性和內容。

  這就是——備忘錄模式。看起來很簡單,也很好理解。它沒有用到物件導向的太多特點,也沒有很複雜的程式碼,僅僅體現了一個設計原則——單一職責原則。利用簡單的程式碼對程式碼職責就行分離,從而解耦。

8.7    情況6:物件之間的通訊 – 一對多

  一個物件的屬性反生變化,或者一個物件的某方法被呼叫時,肯定會帶來一些影響。所謂的“影響”具體到系統中來看,無非就是導致其他物件的屬性發生變化或者事件被觸發。

  近來在生活中遇到這樣的兩個場景。第一,白天用手機上的某客戶端軟體看NBA文字直播,發現只要某個球員有進球或者籃板之類的資料,系統中所有的地方都會更新這個資料,包括兩隊的總分比分。第二,看jquery原始碼解讀時,jquery的callbacks的應用也是這種情況,事例程式碼之後貼出。這兩種情況都是一對多通訊的情況。

  

  (看以上程式碼的形式,很像C#中的委託)

  

   先不管上面的程式碼或者例子。先想想這種一對多的通訊,該如何去設計,然後慢慢重構升級。最簡單的當然是在客戶端直接寫出來,淺顯易懂。這樣寫程式碼的人肯定大有人在(因為我之前曾是這樣寫的):

        

  如果系統中這段程式碼只用一次,這樣寫是沒有問題的。但是如果系統有多地方都是context.Update(),那將導致一旦有修改,每個地方都得修改。耦合太多,不符合開放封閉原則。

  解決這個問題很簡單,我們把受影響物件的更新,全部放到主物件的更新中。

  

  再想想還會遇到一個問題:難道我們每次呼叫Context的Update時,受影響的物件都是固定的嗎?有工作經驗的人肯定回答否。所以,我們這樣盲目的把受影響物件的更新全部塞到Context的Update是有問題的。

  其實我們應該抽象出來的是“更新”這個動作,而應該把具體的哪些物件受影響交給客戶端。如下:

  

  上圖中,我們把受影響物件的型別抽象出一個Subject類,它有一個Update()方法。在主物件型別中,將儲存一個Subject型別的列表,將儲存受影響的物件,更新時,迴圈這個列表,呼叫Update方法。可見,Context依賴的是一個抽象類——依賴倒置原則。

  這樣,我們客戶端的程式碼就成了這樣:

  

  這次終於實現了我們的預想。可以對比一下我一開始貼出來的js程式碼。效果差不多。

  

  這就是大家耳熟但並不一定能詳的——觀察者模式。最常見的例子除了jquery的callbacks之外,還有.net中的委託和事件。此處不再深入介紹,有機會再把委託和事件如何應用觀察者模式的思路介紹一下。

8.8     情況7:物件之間的通訊 – 多對多

  上文中提到一對多的通訊情況,比一對多更復雜的是多對多的通訊。如果用最原始的模式,那將出現這種情況,並且這種情況會隨著物件的增加而變得更加複雜。N個物件就會有N*(N-1)個通訊方式。

  

  所以,當你確定系統中遇到這種問題,並且越發不可收拾的時候,就需要重構程式碼優化設計。思路還是一樣的——抽象、隔離、解耦。當前場景的複雜之處是過多的“通訊”鏈條。我們需要把所有的“通訊”鏈條都抽象出來,並隔離通訊雙方直接聯絡。所以,我們希望的結構是這樣的。

  

  其實這就是中介者模式

  以上只是一個思路,看它是怎麼實現的。

 

  

  首先,把所有需要通訊的型別(成為“同事”型別),抽象出一個基類,基類中包含一箇中介者Mediator物件,這樣所有的子類中都會有一個Mediator物件。子類有Send()方法,用於傳送請求;Notify()方法用於接收請求。

  

  其次,中介者Mediator型別,基類中定義Send()抽象方法,子類中要重寫。子類中定義了所有需要通訊的物件,然後重寫Send()方法時,根據不同情況,呼叫不同的同事型別的Notify()方法。如下:

  

  這樣,在同事型別中,每個同事類的Send()方法,就可以直接呼叫中介者Mediator的send()方法。如下:

  

  最後,總體的設計結構如下:

  

  越看似簡單的東西,就越難用。因為簡單的東西具有通用性,而通用就必須適應各種環境,環境不同,應用不同。中介者模式就是這樣一種情況。如果不信,可以具體思考一下,你的系統中哪個場景可以用中介者模式,並且不影響其他功能和設計。

  在具體應用中,還是把重點放在這個設計的思路上,不必太拘泥與它的程式碼和類圖,這只是一個demo而已。

8.9     情況8:如何呼叫一個操作?

  對於這個問題,我想大部分人都會一笑而過:如何呼叫?呼叫就是了。一般情況下是觸發一個單獨的方法或者一個物件的某個方法。但是你應該知道,我既然在這個地方提出這個問題,就肯定不是這樣簡單的答案。

   難點在於如何分析“操作”這個業務過程。其實“操作”分為以下三部分:

  • 呼叫者
  • 操作
  • 執行者

  首先,呼叫者不一定都是客戶端,可能是一個物件或者集合。例如我們常見的電視遙控器,就是一個典型的呼叫者物件。

  其次,操作和執行者不一樣。操作時,除了真正執行之外,還可能有其他的動作,如快取、判斷等。

  最後,這樣的劃分是為了職責單一和充分解耦。當你的需求可以用簡單的函式呼叫解決時,那當然最好。但是如果後期隨著系統的升級和變更而變得業務複雜時,就應該考慮用這種設計模式——命令模式

  

  上圖是命令模式的類圖。左側是的Command和ConcreteCommand是操作(命令)的抽象和實現,這個不重要,我們可以把這兩個統一看成一個“操作”整體。Invoker是呼叫者,Receiver是真正的執行者。

  呼叫過程是:Invoker.Excute() -> Command.Excute() -> Receiver.Action()。這樣我們還可以在Command中實現一些快取、判斷之類的業務操作。可以按照自己具體的需求編寫程式碼。

  具體的程式碼這裡就不寫了,把這個業務過程理解了,寫程式碼也很簡單。重點還是在於理解“操作”(命令)的業務過程,以及在複雜過程下對呼叫者、操作、執行者之間的解耦。

8.10     情況9:一種策略,多種演算法

  

  假如上圖是我們系統中一個功能的類圖,定義一個介面,用兩個不同的類去實現。客戶端的程式碼呼叫為:

  

  有出現了比較討厭的條件判斷,任何條件判斷的複雜化都將導致職責的混亂和程式碼的臃腫。如果想要解決這種問題,我們需要把這些邏輯判斷分離出來。先定義一個類來封裝客戶端和實現類的直接聯絡。

  

  如此一來,客戶端的呼叫程式碼為:

  

  這就是——策略模式。類圖如下:

  

  

  附:關於這個策略模式,思考了很久也沒有想出一個何時的表達方法,我沒有真正理解它的用處,感覺它說的很對,但是我覺得它沒多少用處。所以,針對這個模式的表述,大家先以參考為主。如果我有了更好的理解方式,再修改。

8.11     情況10:簡化一個物件組合的操作

  針對一個物件組合,例如一個遞迴的樹形結構,往往對每個節點都會有相同的操作。程式碼如下:

  

   如果物件結構較複雜,而且新增功能較多,程式碼將會變得非常臃腫。

  解決這個問題時,不好直接去抽象。一來是因為現在已經在一個抽象的結構中,二來也因為每個節點新增的功能,不一定都相同。所以,現在我們最好的方式是將“新增功能”這個未來不確定的事情,交給另外物件去做。先去隔離。

  另外定義一個Visitor類,由它來接收一個Element物件,然後執行各種操作。

  

  此時在Element類中,就不需要每次新增功能時,都重寫程式碼。只需要在Element類中加入一個方法,該方法將呼叫Visitor的方法來實現具體的功能。

  

  這就是——訪問者模式,它使你可以在不改變各元素的類的前提下定義作用於這些元素的新操作。

8.12     總結

  注:Interpreter直譯器模式不常用,暫不說明。

  本節介紹了物件行為和物件操作過程中的一些常用業務過程以及其中遇到的問題,同時針對每種情況引入了相應的設計模式。

  這塊兒的過程較複雜,梳理起來也比較麻煩,當前的描述和一個系統的流程相比,我想還是有不少差距的。但是相信大家在看到每種情況的時候,或多或少的肯定有過類似的開發經歷,每種情況都是和我們日常的開發工作息息相關的。如果這些介紹起不到一個教程或者引導的作用,那就權當是一次拋磚引玉,或者一次學習交流。

  最終還是希望大家能從系統的設計、重構、解決問題的角度去引出設計模式,然後才能真正理解設計模式。

 

9      總結

  從5.12開始寫,到今天6.4,磕磕絆絆的總算寫完了初稿。雖然不到一個月,但是堅持下來也很不容易。而且這只是一個開始,我想再在這個基礎上繼續寫第二版、第三版,當然還需要再去看更多的書、部落格以及結合實際的開發經驗和例證。

  且先不說應用,即便是真正理解設計模式,也不是易事,沒有開發經驗、沒有一個有效的方法,學起來也是事倍功半。甚至會像我之前一樣,學一次忘一次。我覺得我現在提出的思路有一定效果,至少對於我是有效的。大家如果有其他建議或者思路,歡迎和我交流 wangfupeng1988$163.com($->@)

 

相關文章