《設計模式之美》- 23種設計模式

學習《設計模式之美》筆記。

23 種經典設計模式共分爲 3 種類型,分別是創建型、結構型和行爲型

創建型模式

創建型設計模式包括:單例模式、工廠模式、建造者模式、原型模式。它主要解決對象的創建問題,封裝複雜的創建過程,解耦對象的創建代碼和使用代碼

1. 單例模式

單例模式用來創建全局唯一的對象。一個類只允許創建一個對象(或者叫實例),那這個類就是一個單例類,這種設計模式就叫作單例模式。單例有幾種經典的實現方式,它們分別是:餓漢式、懶漢式、雙重檢測、靜態內部類、枚舉。

儘管單例是一個很常用的設計模式,在實際的開發中,我們也確實經常用到它,但是,有些人認爲單例是一種反模式(anti-pattern),並不推薦使用,主要的理由有以下幾點:

  • 單例對 OOP 特性的支持不友好
  • 單例會隱藏類之間的依賴關係
  • 單例對代碼的擴展性不友好
  • 單例對代碼的可測試性不友好
  • 單例不支持有參數的構造函數

那有什麼替代單例的解決方案呢?如果要完全解決這些問題,我們可能要從根上尋找其他方式來實現全局唯一類。比如,通過工廠模式、IOC 容器來保證全局唯一性。

人把單例當作反模式,主張杜絕在項目中使用。我個人覺得這有點極端。模式本身沒有對錯,關鍵看你怎麼用。如果單例類並沒有後續擴展的需求,並且不依賴外部系統,那設計成單例類就沒有太大問題。對於一些全局類,我們在其他地方 new 的話,還要在類之間傳來傳去,不如直接做成單例類,使用起來簡潔方便。

除此之外,我們還講到了進程唯一單例、線程唯一單例、集羣唯一單例、多例等擴展知識點,這一部分在實際的開發中並不會被用到,但是可以擴展你的思路、鍛鍊你的邏輯思維。這裏我就不帶你回顧了,你可以自己回憶一下。

2. 工廠模式

工廠模式包括簡單工廠、工廠方法、抽象工廠這 3 種細分模式。其中,簡單工廠和工廠方法比較常用,抽象工廠的應用場景比較特殊,所以很少用到,不是我們學習的重點。

工廠模式用來創建不同但是相關類型的對象(繼承同一父類或者接口的一組子類),由給定的參數來決定創建哪種類型的對象。實際上,如果創建對象的邏輯並不複雜,那我們直接通過 new 來創建對象就可以了,不需要使用工廠模式。當創建邏輯比較複雜,是一個“大工程”的時候,我們就考慮使用工廠模式,封裝對象的創建過程,將對象的創建和使用相分離。

當每個對象的創建邏輯都比較簡單的時候,我推薦使用簡單工廠模式,將多個對象的創建邏輯放到一個工廠類中。當每個對象的創建邏輯都比較複雜的時候,爲了避免設計一個過於龐大的工廠類,我們推薦使用工廠方法模式,將創建邏輯拆分得更細,每個對象的創建邏輯獨立到各自的工廠類中。

詳細點說,工廠模式的作用有下面 4 個,這也是判斷要不要使用工廠模式最本質的參考標準。

  • 封裝變化:創建邏輯有可能變化,封裝成工廠類之後,創建邏輯的變更對調用者透明。
  • 代碼複用:創建代碼抽離到獨立的工廠類之後可以複用。
  • 隔離複雜性:封裝複雜的創建邏輯,調用者無需瞭解如何創建對象。控制複雜度:將創建代碼抽離出來,讓原本的函數或類職責更單一,代碼更簡潔。

除此之外,我們還講了工廠模式一個非常經典的應用場景:依賴注入框架,比如 Spring IOC、Google Guice,它用來集中創建、組裝、管理對象,跟具體業務代碼解耦,讓程序員聚焦在業務代碼的開發上。DI 框架已經成爲了我們平時開發的必備框架,在專欄中,我還帶你實現了一個簡單的 DI 框架,你可以再回過頭去看看。

3. 建造者模式

建造者模式用來創建複雜對象,可以通過設置不同的可選參數,“定製化”地創建不同的對象。建造者模式的原理和實現比較簡單,重點是掌握應用場景,避免過度使用。

如果一個類中有很多屬性,爲了避免構造函數的參數列表過長,影響代碼的可讀性和易用性,我們可以通過構造函數配合 set() 方法來解決。但是,如果存在下面情況中的任意一種,我們就要考慮使用建造者模式了。

  • 我們把類的必填屬性放到構造函數中,強制創建對象的時候就設置。如果必填的屬性有很多,把這些必填屬性都放到構造函數中設置,那構造函數就又會出現參數列表很長的問題。如果我們把必填屬性通過 set() 方法設置,那校驗這些必填屬性是否已經填寫的邏輯就無處安放了。
  • 如果類的屬性之間有一定的依賴關係或者約束條件,我們繼續使用構造函數配合 set() 方法的設計思路,那這些依賴關係或約束條件的校驗邏輯就無處安放了。
  • 如果我們希望創建不可變對象,也就是說,對象在創建好之後,就不能再修改內部的屬性值,要實現這個功能,我們就不能在類中暴露 set() 方法。構造函數配合 set() 方法來設置屬性值的方式就不適用了。

4. 原型模式

如果對象的創建成本比較大,而同一個類的不同對象之間差別不大(大部分字段都相同),在這種情況下,我們可以利用對已有對象(原型)進行復制(或者叫拷貝)的方式,來創建新對象,以達到節省創建時間的目的。這種基於原型來創建對象的方式就叫作原型模式。

原型模式有兩種實現方法,深拷貝和淺拷貝。淺拷貝只會複製對象中基本數據類型數據和引用對象的內存地址,不會遞歸地複製引用對象,以及引用對象的引用對象……而深拷貝得到的是一份完完全全獨立的對象。所以,深拷貝比起淺拷貝來說,更加耗時,更加耗內存空間。

如果要拷貝的對象是不可變對象,淺拷貝共享不可變對象是沒問題的,但對於可變對象來說,淺拷貝得到的對象和原始對象會共享部分數據,就有可能出現數據被修改的風險,也就變得複雜多了。操作非常耗時的情況下,我們比較推薦使用淺拷貝,否則,沒有充分的理由,不要爲了一點點的性能提升而使用淺拷貝。

二、結構型設計模式

結構型模式主要總結了一些類或對象組合在一起的經典結構,這些經典的結構可以解決特定應用場景的問題。結構型模式包括:代理模式、橋接模式、裝飾器模式、適配器模式、門面模式、組合模式、享元模式。

1. 代理模式

代理模式在不改變原始類接口的條件下,爲原始類定義一個代理類,主要目的是控制訪問,而非加強功能,這是它跟裝飾器模式最大的不同。一般情況下,我們讓代理類和原始類實現同樣的接口。但是,如果原始類並沒有定義接口,並且原始類代碼並不是我們開發維護的。在這種情況下,我們可以通過讓代理類繼承原始類的方法來實現代理模式。

靜態代理需要針對每個類都創建一個代理類,並且每個代理類中的代碼都有點像模板式的“重複”代碼,增加了維護成本和開發成本。對於靜態代理存在的問題,我們可以通過動態代理來解決。我們不事先爲每個原始類編寫代理類,而是在運行的時候動態地創建原始類對應的代理類,然後在系統中用代理類替換掉原始類。

代理模式常用在業務系統中開發一些非功能性需求,比如:監控、統計、鑑權、限流、事務、冪等、日誌。我們將這些附加功能與業務功能解耦,放到代理類統一處理,讓程序員只需要關注業務方面的開發。除此之外,代理模式還可以用在 RPC、緩存等應用場景中。

2. 橋接模式

橋接模式的代碼實現非常簡單,但是理解起來稍微有點難度,並且應用場景也比較侷限,所以,相對來說,橋接模式在實際的項目中並沒有那麼常用,你只需要簡單瞭解,見到能認識就可以了,並不是我們學習的重點。

橋接模式有兩種理解方式。第一種理解方式是“將抽象和實現解耦,讓它們能獨立開發”。這種理解方式比較特別,應用場景也不多。另一種理解方式更加簡單,等同於“組合優於繼承”設計原則,這種理解方式更加通用,應用場景比較多。不管是哪種理解方式,它們的代碼結構都是相同的,都是一種類之間的組合關係。

對於第一種理解方式,弄懂定義中“抽象”和“實現”兩個概念,是理解它的關鍵。定義中的“抽象”,指的並非“抽象類”或“接口”,而是被抽象出來的一套“類庫”,它只包含骨架代碼,真正的業務邏輯需要委派給定義中的“實現”來完成。而定義中的“實現”,也並非“接口的實現類”,而是的一套獨立的“類庫”。“抽象”和“實現”獨立開發,通過對象之間的組合關係組裝在一起。

3. 裝飾器模式

裝飾器模式主要解決繼承關係過於複雜的問題,通過組合來替代繼承,給原始類添加增強功能。這也是判斷是否該用裝飾器模式的一個重要的依據。除此之外,裝飾器模式還有一個特點,那就是可以對原始類嵌套使用多個裝飾器。爲了滿足這樣的需求,在設計的時候,裝飾器類需要跟原始類繼承相同的抽象類或者接口。

4. 適配器模式

代理模式、裝飾器模式提供的都是跟原始類相同的接口,而適配器提供跟原始類不同的接口。適配器模式是用來做適配的,它將不兼容的接口轉換爲可兼容的接口,讓原本由於接口不兼容而不能一起工作的類可以一起工作。適配器模式有兩種實現方式:類適配器和對象適配器。其中,類適配器使用繼承關係來實現,對象適配器使用組合關係來實現。

適配器模式是一種事後的補救策略,用來補救設計上的缺陷。應用這種模式算是“無奈之舉”。如果在設計初期,我們就能規避接口不兼容的問題,那這種模式就無用武之地了。在實際的開發中,什麼情況下才會出現接口不兼容呢?我總結下了下面這 5 種場景:

  • 封裝有缺陷的接口設計
  • 統一多個類的接口設計
  • 替換依賴的外部系統
  • 兼容老版本接口
  • 適配不同格式的數據

5. 門面模式

門面模式原理、實現都非常簡單,應用場景比較明確。它通過封裝細粒度的接口,提供組合各個細粒度接口的高層次接口,來提高接口的易用性,或者解決性能、分佈式事務等問題。

6. 組合模式

組合模式跟我們之前講的面向對象設計中的“組合關係(通過組合來組裝兩個類)”,完全是兩碼事。這裏講的“組合模式”,主要是用來處理樹形結構數據。正因爲其應用場景的特殊性,數據必須能表示成樹形結構,這也導致了這種模式在實際的項目開發中並不那麼常用。但是,一旦數據滿足樹形結構,應用這種模式就能發揮很大的作用,能讓代碼變得非常簡潔。

組合模式的設計思路,與其說是一種設計模式,倒不如說是對業務場景的一種數據結構和算法的抽象。其中,數據可以表示成樹這種數據結構,業務需求可以通過在樹上的遞歸遍歷算法來實現。組合模式,將一組對象組織成樹形結構,將單個對象和組合對象都看作樹中的節點,以統一處理邏輯,並且它利用樹形結構的特點,遞歸地處理每個子樹,依次簡化代碼實現。

7. 享元模式

所謂“享元”,顧名思義就是被共享的單元。享元模式的意圖是複用對象,節省內存,前提是享元對象是不可變對象。

具體來講,當一個系統中存在大量重複對象的時候,我們就可以利用享元模式,將對象設計成享元,在內存中只保留一份實例,供多處代碼引用,這樣可以減少內存中對象的數量,以起到節省內存的目的。實際上,不僅僅相同對象可以設計成享元,對於相似對象,我們也可以將這些對象中相同的部分(字段),提取出來設計成享元,讓這些大量相似對象引用這些享元。

三、行爲型設計模式

我們知道,創建型設計模式主要解決“對象的創建”問題,結構型設計模式主要解決“類或對象的組合”問題,那行爲型設計模式主要解決的就是“類或對象之間的交互”問題。行爲型模式比較多,有 11 種,它們分別是:觀察者模式、模板模式、策略模式、職責鏈模式、迭代器模式、狀態模式、訪問者模式、備忘錄模式、命令模式、解釋器模式、中介模式。

1. 觀察者

觀察者模式將觀察者和被觀察者代碼解耦。觀察者模式的應用場景非常廣泛,小到代碼層面的解耦,大到架構層面的系統解耦,再或者一些產品的設計思路,都有這種模式的影子,比如,郵件訂閱、RSS Feeds,本質上都是觀察者模式。

不同的應用場景和需求下,這個模式也有截然不同的實現方式:有同步阻塞的實現方式,也有異步非阻塞的實現方式;有進程內的實現方式,也有跨進程的實現方式。同步阻塞是最經典的實現方式,主要是爲了代碼解耦;異步非阻塞除了能實現代碼解耦之外,還能提高代碼的執行效率;進程間的觀察者模式解耦更加徹底,一般是基於消息隊列來實現,用來實現不同進程間的被觀察者和觀察者之間的交互。

框架的作用有隱藏實現細節,降低開發難度,實現代碼複用,解耦業務與非業務代碼,讓程序員聚焦業務開發。針對異步非阻塞觀察者模式,我們也可以將它抽象成 EventBus 框架來達到這樣的效果。EventBus 翻譯爲“事件總線”,它提供了實現觀察者模式的骨架代碼。我們可以基於此框架非常容易地在自己的業務場景中實現觀察者模式,不需要從零開始開發。

2. 模板模式

模板方法模式在一個方法中定義一個算法骨架,並將某些步驟推遲到子類中實現。模板方法模式可以讓子類在不改變算法整體結構的情況下,重新定義算法中的某些步驟。這裏的“算法”,我們可以理解爲廣義上的“業務邏輯”,並不特指數據結構和算法中的“算法”。這裏的算法骨架就是“模板”,包含算法骨架的方法就是“模板方法”,這也是模板方法模式名字的由來。

模板模式有兩大作用:複用和擴展。其中複用指的是,所有的子類可以複用父類中提供的模板方法的代碼。擴展指的是,框架通過模板模式提供功能擴展點,讓框架用戶可以在不修改框架源碼的情況下,基於擴展點定製化框架的功能。

除此之外,我們還講到回調。它跟模板模式具有相同的作用:代碼複用和擴展。在一些框架、類庫、組件等的設計中經常會用到,比如 JdbcTemplate 就是用了回調。

相對於普通的函數調用,回調是一種雙向調用關係。A 類事先註冊某個函數 F 到 B 類,A 類在調用 B 類的 P 函數的時候,B 類反過來調用 A 類註冊給它的 F 函數。這裏的 F 函數就是“回調函數”。A 調用 B,B 反過來又調用 A,這種調用機制就叫作“回調”。

回調可以細分爲同步回調和異步回調。從應用場景上來看,同步回調看起來更像模板模式,異步回調看起來更像觀察者模式。回調跟模板模式的區別,更多的是在代碼實現上,而非應用場景上。回調基於組合關係來實現,模板模式基於繼承關係來實現。回調比模板模式更加靈活。

3. 策略模式

策略模式定義一組算法類,將每個算法分別封裝起來,讓它們可以互相替換。策略模式可以使算法的變化獨立於使用它們的客戶端(這裏的客戶端代指使用算法的代碼)。策略模式用來解耦策略的定義、創建、使用。實際上,一個完整的策略模式就是由這三個部分組成的。

策略類的定義比較簡單,包含一個策略接口和一組實現這個接口的策略類。策略的創建由工廠類來完成,封裝策略創建的細節。策略模式包含一組策略可選,客戶端代碼選擇使用哪個策略,有兩種確定方法:編譯時靜態確定和運行時動態確定。其中,“運行時動態確定”纔是策略模式最典型的應用場景。

在實際的項目開發中,策略模式也比較常用。最常見的應用場景是,利用它來避免冗長的 if-else 或 switch 分支判斷。不過,它的作用還不止如此。它也可以像模板模式那樣,提供框架的擴展點等等。實際上,策略模式主要的作用還是解耦策略的定義、創建和使用,控制代碼的複雜度,讓每個部分都不至於過於複雜、代碼量過多。除此之外,對於複雜代碼來說,策略模式還能讓其滿足開閉原則,添加新策略的時候,最小化、集中化代碼改動,減少引入 bug 的風險。

4. 職責鏈模式

在職責鏈模式中,多個處理器依次處理同一個請求。一個請求先經過 A 處理器處理,然後再把請求傳遞給 B 處理器,B 處理器處理完後再傳遞給 C 處理器,以此類推,形成一個鏈條。鏈條上的每個處理器各自承擔各自的處理職責,所以叫作職責鏈模式。

在 GoF 的定義中,一旦某個處理器能處理這個請求,就不會繼續將請求傳遞給後續的處理器了。當然,在實際的開發中,也存在對這個模式的變體,那就是請求不會中途終止傳遞,而是會被所有的處理器都處理一遍。

職責鏈模式常用在框架開發中,用來實現過濾器、攔截器功能,讓框架的使用者在不需要修改框架源碼的情況下,添加新的過濾、攔截功能。這也體現了之前講到的對擴展開放、對修改關閉的設計原則。

5. 迭代器模式

迭代器模式也叫遊標模式,它用來遍歷集合對象。這裏說的“集合對象”,我們也可以叫“容器”“聚合對象”,實際上就是包含一組對象的對象,比如,數組、鏈表、樹、圖、跳錶。迭代器模式主要作用是解耦容器代碼和遍歷代碼。大部分編程語言都提供了現成的迭代器可以使用,我們不需要從零開始開發。

遍歷集合一般有三種方式:for 循環、foreach 循環、迭代器遍歷。後兩種本質上屬於一種,都可以看作迭代器遍歷。相對於 for 循環遍歷,利用迭代器來遍歷有 3 個優勢:

  • 迭代器模式封裝集合內部的複雜數據結構,開發者不需要了解如何遍歷,直接使用容器提供的迭代器即可;
  • 迭代器模式將集合對象的遍歷操作從集合類中拆分出來,放到迭代器類中,讓兩者的職責更加單一;
  • 迭代器模式讓添加新的遍歷算法更加容易,更符合開閉原則。除此之外,因爲迭代器都實現自相同的接口,在開發中,基於接口而非實現編程,替換迭代器也變得更加容易。

在通過迭代器來遍歷集合元素的同時,增加或者刪除集合中的元素,有可能會導致某個元素被重複遍歷或遍歷不到。針對這個問題,有兩種比較乾脆利索的解決方案,來避免出現這種不可預期的運行結果。一種是遍歷的時候不允許增刪元素,另一種是增刪元素之後讓遍歷報錯。第一種解決方案比較難實現,因爲很難確定迭代器使用結束的時間點。第二種解決方案更加合理,Java 語言就是採用的這種解決方案。增刪元素之後,我們選擇 fail-fast 解決方式,讓遍歷操作直接拋出運行時異常。

6. 狀態模式

狀態模式一般用來實現狀態機,而狀態機常用在遊戲、工作流引擎等系統開發中。狀態機又叫有限狀態機,它由 3 個部分組成:狀態、事件、動作。其中,事件也稱爲轉移條件。事件觸發狀態的轉移及動作的執行。不過,動作不是必須的,也可能只轉移狀態,不執行任何動作。

針對狀態機,我們總結了三種實現方式。

第一種實現方式叫分支邏輯法。利用 if-else 或者 switch-case 分支邏輯,參照狀態轉移圖,將每一個狀態轉移原模原樣地直譯成代碼。對於簡單的狀態機來說,這種實現方式最簡單、最直接,是首選。

第二種實現方式叫查表法。對於狀態很多、狀態轉移比較複雜的狀態機來說,查表法比較合適。通過二維數組來表示狀態轉移圖,能極大地提高代碼的可讀性和可維護性。

第三種實現方式就是利用狀態模式。對於狀態並不多、狀態轉移也比較簡單,但事件觸發執行的動作包含的業務邏輯可能比較複雜的狀態機來說,我們首選這種實現方式。

7. 訪問者模式

訪問者模式允許一個或者多個操作應用到一組對象上,設計意圖是解耦操作和對象本身,保持類職責單一、滿足開閉原則以及應對代碼的複雜性。

對於訪問者模式,學習的主要難點在代碼實現。而代碼實現比較複雜的主要原因是,函數重載在大部分面向對象編程語言中是靜態綁定的。也就是說,調用類的哪個重載函數,是在編譯期間,由參數的聲明類型決定的,而非運行時,根據參數的實際類型決定的。除此之外,我們還講到 Double Disptach。如果某種語言支持 Double Dispatch,那就不需要訪問者模式了。

正是因爲代碼實現難理解,所以,在項目中應用這種模式,會導致代碼的可讀性比較差。如果你的同事不瞭解這種設計模式,可能就會讀不懂、維護不了你寫的代碼。所以,除非不得已,不要使用這種模式。

8. 備忘錄模式

備忘錄模式也叫快照模式,具體來說,就是在不違背封裝原則的前提下,捕獲一個對象的內部狀態,並在該對象之外保存這個狀態,以便之後恢復對象爲先前的狀態。這個模式的定義表達了兩部分內容:一部分是,存儲副本以便後期恢復;另一部分是,要在不違背封裝原則的前提下,進行對象的備份和恢復。

備忘錄模式的應用場景也比較明確和有限,主要用來防丟失、撤銷、恢復等。它跟平時我們常說的“備份”很相似。兩者的主要區別在於,備忘錄模式更側重於代碼的設計和實現,備份更側重架構設計或產品設計。

對於大對象的備份來說,備份佔用的存儲空間會比較大,備份和恢復的耗時會比較長。針對這個問題,不同的業務場景有不同的處理方式。比如,只備份必要的恢復信息,結合最新的數據來恢復;再比如,全量備份和增量備份相結合,低頻全量備份,高頻增量備份,兩者結合來做恢復。

9. 命令模式

命令模式在平時工作中並不常用,你稍微瞭解一下就可以。

落實到編碼實現,命令模式用到最核心的實現手段,就是將函數封裝成對象。我們知道,在大部分編程語言中,函數是沒法作爲參數傳遞給其他函數的,也沒法賦值給變量。藉助命令模式,我們將函數封裝成對象,這樣就可以實現把函數像對象一樣使用。

命令模式的主要作用和應用場景,是用來控制命令的執行,比如,異步、延遲、排隊執行命令、撤銷重做命令、存儲命令、給命令記錄日誌等,這纔是命令模式能發揮獨一無二作用的地方。

10. 解釋器模式

解釋器模式爲某個語言定義它的語法(或者叫文法)表示,並定義一個解釋器用來處理這個語法。實際上,這裏的“語言”不僅僅指我們平時說的中、英、日、法等各種語言。從廣義上來講,只要是能承載信息的載體,我們都可以稱之爲“語言”,比如,古代的結繩記事、盲文、啞語、摩斯密碼等。

要想了解“語言”要表達的信息,我們就必須定義相應的語法規則。這樣,書寫者就可以根據語法規則來書寫“句子”(專業點的叫法應該是“表達式”),閱讀者根據語法規則來閱讀“句子”,這樣才能做到信息的正確傳遞。而我們要講的解釋器模式,其實就是用來實現根據語法規則解讀“句子”的解釋器。

解釋器模式的代碼實現比較靈活,沒有固定的模板。我們前面說過,應用設計模式主要是應對代碼的複雜性,解釋器模式也不例外。它的代碼實現的核心思想,就是將語法解析的工作拆分到各個小類中,以此來避免大而全的解析類。一般的做法是,將語法規則拆分一些小的獨立的單元,然後對每個單元進行解析,最終合併爲對整個語法規則的解析。

11. 中介模式

中介模式的設計思想跟中間層很像,通過引入中介這個中間層,將一組對象之間的交互關係(或者說依賴關係)從多對多(網狀關係)轉換爲一對多(星狀關係)。原來一個對象要跟 n 個對象交互,現在只需要跟一箇中介對象交互,從而最小化對象之間的交互關係,降低了代碼的複雜度,提高了代碼的可讀性和可維護性。

觀察者模式和中介模式都是爲了實現參與者之間的解耦,簡化交互關係。兩者的不同在於應用場景上。在觀察者模式的應用場景中,參與者之間的交互比較有條理,一般都是單向的,一個參與者只有一個身份,要麼是觀察者,要麼是被觀察者。而在中介模式的應用場景中,參與者之間的交互關係錯綜複雜,既可以是消息的發送者、也可以同時是消息的接收者。

代碼Demo

23種設計模式的代碼實現https://github.com/lbshold/springboot/tree/master/design_demo

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章