經典永不過時!重溫設計模式

在軟工程中,設計模式(design pattern)是對軟件設計中普遍存在(反覆出現)的各種問題,所提出的解決方案。這個術語是由埃裏希 · 伽瑪(Erich Gamma)等人在 1990 年代從建築設計領域引入到計算機科學的,設計模式是針對軟件設計中常見問題的工具箱,其中的工具就是各種經過實踐驗證的解決方案。即使你從未遇到過這些問題,瞭解模式仍然非常件有用,因爲它能指導你如何使用面向對象的設計原則來解決各種問題。

大家好,我是 Alex,今天談一談設計模式,一名優秀的開發,應該多少都需要了解一些常用的設計模式和使用場景,讓我們一起來重溫一下那些年經典設計模式;

本文主要內容

爲什麼要掌握設計模式

歷史的教訓

時間回到 20 世紀 80 年代,當時的軟件行業正處於第二次軟件危機中。根本原因是,隨着軟件規模和複雜度的快速增長,如何高效高質的構建和維護這樣大規模的軟件成爲了一大難題。無論是開發何種軟件產品,成本和時間都最重要的兩個維度。較短的開發時間意味着可比競爭對手更早進入市場;較低的開發成本意味着能夠留出更多營銷資金,因此能更廣泛地覆蓋潛在客戶。

設計模式是銀彈嗎?

代碼複用是減少開發成本,減低複雜度最常用的方式之一,這個想法表面看起來很棒,但實際上要讓已有代碼在全新的上下文中工作,通常還是需要付出額外努力的。組件間緊密的耦合、對具體類而非接口的依賴和硬編碼的行爲都會降低代碼的靈活性,使得複用這些代碼變得更加困難。設計模式目標就是幫助軟件提高內聚,減低耦‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍合,‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍使用設計模式是增加軟件組件靈活性並使其易於複用的方式之一。

變化是程序員生命中唯一不變的事情,客戶需求可能經常會變,緊急上線的版本,要不要下次重構一下,還是繼續打各種補丁, 技術債會越積越多,因此在設計程序架構時,所有有經驗的開發者會盡量選擇支持未來任何可能變更的方式。可擴展性成爲了程序設計必須要考慮指標,而設計模式是可以借鑑的,成熟的優化程序設計的解決方案;

總體來說,深刻理解設計模式會給我們帶來很多好處:

  1. 可以和麪試官 "暢談" 設計模式相關問題.

  2. 很多開源軟件框架大量使用了設計模式,比如 Linux 系統,Redis,Spring,C++STL 等等,可以把幫你加快理解開源軟件框架。

  3. 當你寫的代碼越來越優美后,你的代碼鑑賞能力就會提高,對團隊 code review 貢獻也會更大,在個人影響力也會提高。

  4. 你不會再畏手畏腳,你的工具箱裏面工具很多後,可以幫助你,應對各種大型項目的代碼設計和開發。

  5. 每個領域都會一些成熟 "套路", 編程也不例外,熟悉這些套路,可以更好方便交流和更快速地解決問題;

爲了更好理解設計模式,我們首先要理解一些重要的設計原則,而不是片面理解設計模式哪些模式名詞,要看清楚這背後的原理,這個纔是最重要的。

代碼設計原則

代碼設計原則貫穿在整個設計模式之中,是理解其中的精華,本文討論了一些重要的設計原則,包括通用設計原則,DRY 原則,KISS 原則,SOLID 原則等:

通用設計原則

隔離變化

找到程序中的變化內容並將其與不變的內容區分開,該原則的主要目的是將變更造成的影響最小化。

面向接口編程

面向接口進行開發, 而不是面向實現;依賴於抽象類型,而不是具體類,要求接口標準化設計,只要對外的接口沒有變,內部實現就可以任意變化,爲以後留有更多優化空間,方便以後更新迭代,可以說這樣的設計是靈活的。

組合優於繼承

繼承可能是類之間最明顯、最簡便的代碼複用方式。如果你有兩個代碼相同的類, 就可以爲它們創建一個通用的基類,然後將相似的代碼移動到其中。但繼承可能帶來的問題:

組合是代替繼承的一種方法。繼承代表類之間的 “是” 關係(汽車是交通工具),而組合則代表 “有” 關係(汽車有一個引擎)。

DRY 原則

DRY-Don't Repeat Yourself(不要重複代碼)

降低可管理單元的複雜度的基本策略是將系統分成多個部分。

理解這一原理是如此重要,它通常以首字母縮寫詞 DRY 來指代,並出現在 Andy Hunt 和 Dave Thomas 的書《實用程序員》中,但是這個概念本身已經有很長時間了。它指的是軟件的最小部分。

當您構建一個大型軟件項目時,通常會因整體複雜性而感到不知所措。人類不善於管理複雜性;他們擅長爲特定範圍的問題找到有創意的解決方案。降低可管理單元的複雜性的基本策略是將系統分成更方便的部分。首先,您可能希望將系統分爲多個組件,其中每個組件代表其自己的子系統,其中包含完成特定功能所需的一切。

KISS 原則

KISS 是使它保持簡單,愚蠢的首字母縮寫,是美國海軍在 1960 年提出的設計原則。KISS 原則指出,大多數系統如果保持簡單而不是變得複雜,則效果最佳。因此,簡單性應該是設計的主要目標,並且應該避免不必要的複雜性。

SOLID 原則

SOLID 原則是在羅伯特 · 馬丁的著作《敏捷軟件開發:原則、模式與實踐》中首次提出的,SOLID 是讓軟件設計更易於理解、更加靈活和更易於維護的五個原則的簡稱。

儘量讓每個類或者函數只負責軟件中的一個功能,這條原則的主要目的是減少複雜度,你不需要費盡心機地去構思如何僅用 200 行代碼來實現複雜設計,實際上完全可以使用十幾個清晰的方法,這裏核心是: 通過實現最基本 "原子函數", 其他複雜功能都可以通過這些原子函數構建,每一層的函數語義都是單一的,通過層層封裝,最終構建一個龐大可控的系統。

本原則的主要理念是在實現新功能時能保持已有代碼不變,爲什麼呢,主要是修改存量代碼,很可能會影響軟件穩定性,很多線上代碼跑了好多年了,經歷很多輪迭代,各種補丁,如果考慮不全面,很容易帶來風險,下圖比較形象說明:

替換原則是用於預測子類是否與代碼兼容,以及是否能與其超類對象協作的一組檢查。這一概念在開發程序庫和框架時非常重要, 因爲其中的類將會在他人的代碼中使用——你是無法直接訪問和修改這些代碼的。里氏替換原則的重點在不影響原功能。

根據接口隔離原則,你必須將 “臃腫” 的方法拆分爲多個顆粒度更小的具體方法。客戶端必須僅實現其實際需要的方法。否則,對於 “臃腫” 接口的修改可能會導致程序出錯,即使客戶端根本沒有使用修改後的方法。

通常在設計軟件時,你可以辨別出不同層次的類。

• 低層次的類實現基礎操作(例如磁盤操作、傳輸網絡數據和連接數據庫等)。

• 高層次類包含複雜業務邏輯以指導低層次類執行特定操作。

經典設計模式

這裏列舉了 22 種設計模式,大致分爲三類:創建型模式結構型模式行爲模式

創建型模式提供創建對象的機制,增加已有代碼的靈活性和可複用性

結構型模式介紹如何將對象和類組裝成較大的結構,並同時保持結構的靈活和高效:

行爲模式負責對象間的高效溝通和職責委派:

推薦一個經典學習網站:

https://refactoring.guru

上面每種模式配有形象圖,比如工廠方法模式:

還提供對應的設計類圖:

也提供了對應代碼示例:

支持 9 種語言的實現:

    代碼在:https://github.com/RefactoringGuru

推薦給大家,拿走別謝

更多請參考:

《設計模式:可複用面向對象軟件的基礎》

  https://refactoring.guru


Linux 經典設計模式

 內核面向對象設計模式

Linux 雖然是面向過程的 c 語言寫成的,但是卻可以表達面向對象的思想,Linux 內核大量使用面向對象的編碼風格,我們可以從中至少學習到兩點:

我們用例子來說明。

封裝

以內核 proto 定義爲例:

struct proto 定義傳輸層接口方法和相應成員數據,類似 C++ 的 class 定義;可以根據這個 class 生產很多實例,比如 TCP 實例,可以通過統一接口訪問 TCP 實例的方法和數據。

繼承

以內核套接字體系爲例:

基於此繼承體系,對於一些接受 struct sock* 形參的接口,就可以直接把上述的子類套接字實例 struct udp_sock* sk 作爲實參傳進去(當然,這裏需要指針強轉一次 (struct sock*)sk)。這裏就是 OOP 中 “is a" 的 public 繼承關係,子類對象可以直接作爲父類對象使用,並且這種實現只支持單繼承。

多態

用 C 實現多態需要自己維護繼承關係中的虛函數體系,C++ 有編譯器自動生成、維護 vtbl 與 vptr。Linux 內核的實現中,將系列函數指針放入結構體,即視其爲 “虛函數”,亦或是專門定義一個 xxx_ops 結構,裏面放上一堆函數指針,作爲 “虛函數表”。仍以套接字體系爲例,在基類 sock 中,有協議結構體指針 struct proto *skc_prot; 這個 proto 即可大體上視爲一個虛函數表 vtbl,內有具體協議的函數指針,而這個 skc_prot 指針,即可視爲虛指針 vptr。

在套接字創建時,根據參數中的協議族、協議類型、協議號信息,調用協議族的 create 函數執行創建,綁定具體協議 proto 指針到該 vptr 上,自此實現了靜態類型到動態類型的綁定。之後,當調用虛函數時,即可直接通過這些函數指針進行多態的調用 , 比如下面例子 socket 調用 connect 接口:

這裏第一個參數 sk 即可看做 this 指針,不同 socket 對象,會訪問對應協議接口, 從而實現多態訪問:

list 設計模式

list 作爲常用數據結構,寫代碼時候經常會遇到,可以看一下傳統 list 設計和內核 list 設計有什麼不一樣。

一般的雙向鏈表一般是如下的結構:

傳統 list 如下圖:

傳統的鏈表不同 node 類型,需要重新定義結構,不夠通用化,還需要爲 node 實現脫鏈、入鏈操作等。

我們需要抽象出一個 “基類” 來實現鏈表的功能,其他數據結構只需要簡單的繼承這個鏈表類就可以了。

內核 list 設計如下:

‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍

如下圖:

這樣設計的好處是鏈表的節點將獨立於用戶數據之外,便於把鏈表的操作獨立出來,和具體數據節點無關,這裏可能有些人會問,數據節點怎麼訪問呢?  內核通過一個 container_of 的宏從鏈表節點找到數據節點起始地址:

找到數據節點起始地址後,通過數據節點定義就可以訪問數據了,內核紅黑樹 rbtree 也是同樣的設計。

設備驅動框架設計模式

從 Linux2.6 開始 Linux 加入了一套驅動管理和註冊機制—platform 平臺總線驅動模型:

當調用 platform_device_register(或 platform_driver_register)註冊 platform_device(或 platform_driver)時,首先會將其加入 platform 總線上,依次匹配 platform 總線上的 platform_driver(或 platform_device),然後調用 platform_driver 的. probe 函數。其中 platform_device 存放設備資源(硬件息息相關代碼,易變動),platform_driver 則使用資源(比較穩定的代碼),這樣當改動硬件資源時,我們的上層使用資源的代碼部分幾乎可以不用去改動。


這裏設計通過中間 bus 層,把強耦合 Device 和對應 Driver 進行了解耦隔離,定好 match,probe 等標準通信接口,就可以獨立開發,通過總線 bus 進行關聯通信,有點類似中介模式。

C++ Idioms(設計習語)

由於篇幅優先,這裏列舉一些非常重要且非常實用的 C++ 專有的設計模式。

RAII-Resource Acquisition Is Initialization

‘資源獲取即初始化‘(簡稱 RAII)是 C++ 防止內存泄露一個很好解決方案,它結合構造函數和析構函數,把資源生命週期和對象生命週期綁定起來,在構造函數中獲取資源(這些錯誤會引發異常),然後將其釋放到析構函數中(永不拋出),並且不需要顯式清理,從而防止忘記釋放資源;

C ++STL 庫很多類遵循 RAII 設計原則,比如 std :: string,std :: vector,std :: thread 等。

Policy-based class Design

基於策略設計又名 policy-based class design 是一種基於 C++ 計算機程序設計模式,以策略(Policy)爲基礎,並結合 C++ 的模板元編程。就是將原本複雜的系統,拆解成多個獨立運作的 “策略類別”,每一組 policy class 都只負責單純如行爲或結構的某一方面。多重繼承由於繼承自多組 Base Class,故缺乏型別消息,而 Templetes 基於型別,擁有豐富的型別消息。多重繼承容易擴張,而 Templetes 的特化不容易擴張。Policy-Based Class Design 同時使用了 Template 以及 Multiple Inheritance 兩項技術,結合兩者的優點,看下面例子:

ResourceManager則稱爲宿主類別(host class),只需要切換不同 Policy Class(ReadPolicy or WritePolicy),就可以得到不同的功能實體。Policy不一定要被宿主繼承,只需要用委託完成這一工作。但policies必須遵守一個隱含的constraint,接口必須一樣,故參數不能有巨大改變,policy 的一個重要的特徵是,宿主類別經常(並不一定要)使用多重繼承的機制去使用多個 policy classes. 因此在進行 policy 拆解時,必須要儘可能達成正交分解,policy之間最好彼此獨立運作,不相互影響。

Pimpl - Pointer to implementation

Pimpl 是一種廣泛使用的削減編譯依賴項的技術, 看下面例子可能就明白了:

  

因爲 Widget 的成員變量有 std::string,std::vector 和 Gadget,那麼這些類型的頭文件在 Widget 編譯時必須出現,這意味 Widget 的用戶必須包含 “gadget.h”。這些增加的頭文件會增加 Widget 用戶的編譯時間,而且這使得用戶依賴於這些頭文件,即如果某個頭文件的內容被改變了,Widget 的用戶就要重新編譯。標準庫頭文件不會經常改變,但是 “gadget.h” 可能會經常修改。所以需要 Pimp 技術來消除這種變化影響 -- 隔離變化;

 

這樣 Widget 頭文件裏面就不需要包含 “gadget.h” 文件了,再 CPP 文件中再聲明具體的類型:

  

在這裏,我展示了 “#include” 指令,只爲了說明所有對頭文件的依賴(即 std::string,std::vector 和 Gadget)依然存在。不過呢,依賴已經從“widget.h”(Widget 用戶可見的和使用的)轉移到“widget.cpp”(只有 Widget 的實現者‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍才能看見和使用),這樣就把 widget 頭文件變化影響隔離在內部實現中,對外接口不變,這裏就體會到這種設計模‍‍‍‍‍‍‍‍‍‍‍‍‍‍‍式的好處。

CRTP -The curiously recurring template pattern 

CRTP (奇異遞歸模板模式)是一種在編譯期實現多態方法,是對運行時多態一種優化,多態是個很好的特性,但是動態綁定比較慢,因爲要查虛函數表。而使用 CRTP,完全消除了動態綁定,降低了繼承帶來的虛函數表查詢開銷。

CRTP 包含:

 

這樣做的目的是在基類中使用派生類。從基礎對象的角度來看,派生對象本身就是對象,但是是向下轉換的對象。因此,基類可以通過將 static_cast 自身放入派生類來訪問派生類.

總結

爲什麼要掌握設計模式,軟件危機帶來剛性要求,設計模式提倡的高內聚,低耦合,代碼複用,可擴展性等思想,可以給我們軟件設計帶來一些思考,有了思考,就會產生一些積極變化;

理解設計模式前提,是要理解背後的設計原則,這是整個設計模式的精華;

經典的設計模式包含 22 種設計模式(沒有解釋器模式,日常開發中,很少使用),大致分爲三類:創建型模式,結構型模式,行爲模式;

Linux 系統裏面包含大量設計模式思想,面向對象設計,List/Rbtree 抽象設計,驅動框架 bus 總線解耦設計,都值得我們學習;

每種編程語言都會有一些獨特特殊習慣用法,Java 的 MVC,Golang 的對象池模式 (Object Pool) 等,文中列舉的 C++ 一些常見的慣用法 RAII,Policy-based Design ,Pimpl,CRTP 等,對 C++ 開發來說,瞭解和掌握他們,對於特定場景問題多了一些好的解決方案;

設計模式是銀彈嗎?不是,就像軟件工程也不是銀彈一樣,這些都只是工具,關鍵還是看是否真正理解其背後反射出的設計精髓,我們需要多一些批判性的思考,沒有絕對好壞,軟件設計的最終方案很多時候都是權衡(trade-off)結果,但我們的長期目標始終沒有變化。


本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/qI2ZmTv77E7k-3MWfmdeGg