設計模式(Design Pattern)是前輩們在代碼實踐中所總結的經驗,是解決某些特定問題的套路。在使用一些優秀的框架時,可能會接觸到它里面所運用到的一些設計模式,又或許你在編碼去設計一些模塊時,為了提高代碼可復用性、擴展性、可讀性等,運用到的一些設計理念也會與某些設計模式思想相吻合。
系統的了解和學習設計模式是很有必要的,能幫助提升面對對象設計的能力,了解各種設計模式的特點和運用場景
在學習設計模式前,先了解下面對對象的設計原則
面對對象設計原則
對于一個好的面對對象軟件系統的設計來說,可維護性和可復用性是很重要的,如何同時提高一個系統的可維護性和可復用性是面對對象設計需要解決的核心問題之一。
在面對對象設計中,面對對象設計原則是為了去支持可維護性和可復用性的,這些原則會體現在很多的設計模式中,也就是說這些設計原則實際上就是從這些設計方案中總結提取出來的指導性原則。
最常見的7種面向對象設計原則
設計原則名稱 | 定義 |
---|---|
開閉原則(Open-Closed Principle, OCP) | 軟件實體應對擴展開放,而對修改關閉 |
單一職責原則(Single Responsibility Principle, SRP) | 一個類只負責一個功能領域中的相應職責 |
里氏代換原則(Liskov Substitution Principle, LSP) | 所有引用基類對象的地方能夠透明地使用其子類的對象 |
依賴倒轉原則(Dependence Inversion Principle, DIP) | 抽象不應該依賴于細節,細節應該依賴于抽象 |
接口隔離原則(Interface Segregation Principle, ISP) | 使用多個專門的接口,而不使用單一的總接口 |
合成復用原則(Composite Reuse Principle,CRP) | 盡量使用對象組合,而不是繼承來達到復用的目的 |
迪米特法則(Law of Demeter, LoD) | 一個軟件實體應當盡可能少地與其他實體發生相互作用 |
設計原則
開閉原則
開閉原則(開放-封閉原則)有兩個特征,對擴展是開放的(Open for extension),對修改是封閉的(Open for modification)。也就是說一個軟件實體(模塊、類、函數等等)要實現變化,應該是通過擴展而不是修改已有的代碼
任何的軟件在其生命周期內需求都可能會發生變化,既然變化是必然的,我們就應該在設計時盡量適應這些變化,以提高項目的穩定性和靈活性。如果一個軟件設計符合開閉原則,那么可以非常方便地對系統進行擴展,而且在擴展時無須修改現有代碼,使得軟件系統在擁有適應性和靈活性的同時具備較好的穩定性和延續性。隨著軟件規模越來越大,軟件壽命越來越長,軟件維護成本越來越高,設計滿足開閉原則的軟件系統也變得越來越重要
為了滿足開閉原則,需要對系統進行抽象化設計,抽象化是開閉原則的關鍵。設計模塊時,對最可能發生變化的地方,通過構造抽象來隔離這些變化。在Java、C#等編程語言中,可以為系統定義一個相對穩定的抽象層,而將不同的實現行為移至具體的實現層中完成。在很多面向對象編程語言中都提供了接口、抽象類等機制,可以通過它們定義系統的抽象層,再通過具體類來進行擴展。如果需要修改系統的行為,無須對抽象層進行任何改動,只需要增加新的具體類來實現新的業務功能即可,實現在不修改已有代碼的基礎上擴展系統的功能,達到開閉原則的要求
這里舉一個簡單的例子,某個系統中某個功能可以來顯示各種類型的圖表,比如餅圖和柱狀圖。開始的設計方案如下:
ChartDisplay中的display方法如下
if (type.equals("pie")) {
PieChart chart = new PieChart();
chart.display();
}else if (type.equals("bar")) {
BarChart chart = new BarChart();
chart.display();
}
在這個例子中,假如我需要添加新的圖表對象(折線圖LineChart),那么我需要在ChartDisplay中的display方法中去添加新的判斷邏輯,這是不符合開閉原則。ChartDisplay類是用來做圖表的顯示工作,但具體的圖表是變化的,需要將這些變化隔離出來
抽象化的方法:
- 增加一個抽象類AbstractChart,作為其他具體圖表類的父類
- ChartDisplay的display方法只針對抽象父類AbstractChart,而具體的圖表類交由客戶端去選擇
重構后的結構如下
如上,ChartDisplay只針對抽象類AbstractChart編程,通過setChart來獲得具體的圖表對象,dispalay方法中直接執行 chart.display(),當我們要新增新的圖表,那么直接創建圖表子類繼承AbstractChart,并實現自己的display方法就好,并不需要修改已有的代碼。
單一職責原則
單一職責原則(Single Responsibility Principle, SRP):一個類應該只有一個職責,對外只提供一種功能,應該有且僅有一個原因引起類的變化
能力越大,責任越大?我們不能創建一個“超級類”,能解決所有的事情,相反,一個類(大到模塊,小到方法)所承擔的責任越多,那么他被復用的可能性就越小。而且一個類承擔的職責過多,這些職責耦合度會很高,當其中一個職責變化時,可能會影響其他職責的運作,因此要將這些職責進行分離,將不同的職責封裝在不同的類中,將不同的變化原因封裝在不同的類中,如果多個職責總是同時發生改變則可將它們封裝在同一類中
單一職責原則,用于控制類的粒度大小,實現高內聚、低耦合,它是最簡單但又最難運用的原則,如何發現類的不同職責并將其分離,需要具有較強的分析設計能力和相關實踐經驗。如果你能夠想到多于一個動機去改變一個類,那么這個類就有多于一個的職責,就要考慮類的職責分離
記得在剛入門Java接觸到 JDBC的時候,為了實現查詢學生列表,一口氣從數據庫的連接到數據查詢再到數據展示,簡直“一氣呵成”,但這種面向過程式的編程卻沒有很好的擴展性,當我想要再實現其他功能時,將會有大量重復的代碼,而重復的地方需要修改,那就更麻煩了。后來稍微改進了,建立了只負責數據庫連接資源的類DBUtil,再到后來使用持久層的框架。職責劃分后,開發時便只需關注業務的處理
單一職責適用于接口、類,同時也適用于方法,一個方法盡可能做一件事情,比如一個方法修改用戶密碼,不要把這個方法放到“修改用戶信息”方法中,這個方法的顆粒度很粗
上面的方法就職責不清晰,不單一,下面替換成具體的修改動作,通過命名我們就能知曉方法的大概處理邏輯
里式替換原則
里氏代換原則(Liskov Substitution Principle, LSP):所有引用基類(父類)的地方必須能透明地使用其子類的對象
里氏代換原則告訴我們,在軟件中,只要父類能出現的地方子類就可以出現,而且替換為子類也不會產生任何錯誤或異常,程序將不會產生任何錯誤和異常,反過來則不成立,如果一個軟件實體使用的是一個子類對象的話,那么它不一定能夠使用基類對象
里式替換才使得開發-封閉成為可能,子類的可替代性才使得使用父類類型的地方可以在無需修改的情況下就可以擴展。里氏代換原則是實現開閉原則的重要方式之一,由于使用基類對象的地方都可以使用子類對象,因此在程序中盡量使用基類類型來對對象進行定義,而在運行時再確定其子類類型,用子類對象來替換父類對象
- 子類的所有方法必須在父類中聲明,或子類必須實現父類中聲明的所有方法。根據里氏代換原則,為了保證系統的擴展性,在程序中通常使用父類來進行定義,如果一個方法只存在子類中,在父類中不提供相應的聲明,則無法在以父類定義的對象中使用該方法。
- 我們在運用里氏代換原則時,盡量把父類設計為抽象類或者接口,讓子類繼承父類或實現父接口,并實現在父類中聲明的方法,運行時,子類實例替換父類實例,我們可以很方便地擴展系統的功能,同時無須修改原有子類的代碼,增加新的功能可以通過增加一個新的子類來實現。里氏代換原則是開閉原則的具體實現手段之一
依賴倒轉原則
如果說開閉原則是面向對象設計的目標的話,那么依賴倒轉原則就是面向對象設計的主要實現機制之一,它是系統抽象化的具體實現
依賴倒轉原則(Dependency Inversion Principle, DIP):高層模塊不應該依賴低層模塊,兩者都應該依賴其抽象;抽象不應該依賴細節,細節應該依賴抽象
上面的定義有些別扭,引入《設計模式之禪》的話來說明依賴倒轉
高層模塊和低層模塊容易理解,每一個邏輯的實現都是由原子邏輯組成的,不可分割的原子邏輯就是低層模塊,原子邏輯的再組裝就是高層模塊。那什么是抽象?什么又是細節呢?在Java語言中,抽象就是指接口或抽象類,兩者都是不能直接被實例化的;細節就是實現類,實現接口或繼承抽象類而產生的類就是細節,其特點就是可以直接被實例化,也就是可以加上一個關鍵字new產生一個對象。
依賴倒置原則在Java語言中的表現就是:
- 模塊間的依賴通過抽象發生,實現類之間不發生直接的依賴關系,其依賴關系是通過接口或抽象類產生的;
- 接口或抽象類不依賴于實現類;
- 實現類依賴接口或抽象類
更精簡的定義就是要面向接口編程(Object-Oriented Design),而不是針對實現編程
看到依賴倒轉和它的定義,是否會想起Spring的依賴注入(Dependency Injection, DI)和控制反轉(Inversion of Control,IOC),通常我們使用Spring的IoC容器時,會聲明依賴的接口,在程序運行時確定具體的實現類并注入。這樣便降低了類間的耦合性、提高了系統的穩定性
接口分離原則
接口隔離原則(Interface Segregation Principle, ISP):使用多個專門的接口,而不使用單一的總接口,
即客戶端不應該依賴那些它不需要的接口
根據接口隔離原則,當一個接口太大時,我們需要將它分割成一些更細小的接口,使用該接口的客戶端僅需知道與之相關的方法即可。每一個接口應該承擔一種相對獨立的角色,不干不該干的事,該干的事都要干。這里的“接
口”往往有兩種不同的含義:一種是指一個類型所具有的方法特征的集合,僅僅是一種邏輯上的抽象;另外一種是指某種語言具體的“接口”定義,有嚴格的定義和結構,比如Java語言中的interface。對于這兩種不同的含義,ISP的表達方式以及含義都有所不同:
(1) 當把“接口”理解成一個類型所提供的所有方法特征的集合的時候,這就是一種邏輯上的概念,接口的劃分將直接帶來類型的劃分。可以把接口理解成角色,一個接口只能代表一個角色,每個角色都有它特定的一個接口,此時,這個原則可以叫做“角色隔離原則”。
(2) 如果把“接口”理解成狹義的特定語言的接口,那么ISP表達的意思是指接口僅僅提供客戶端需要的行為,客戶端不需要的行為則隱藏起來,應當為客戶端提供盡可能小的單獨的接口,而不要提供大的總接口。在面向對象編程語言中,實現一個接口就需要實現該接口中定義的所有方法,因此大的總接口使用起來不一定很方便,為了使接口的職責單一,需要將大接口中的方法根據其職責不同分別放在不同的小接口中,以確保每個接口使用起來都較為方便,并都承擔某一單一角色。接口應該盡量細化,同時接口中的方法應該盡量少,每個接口中只包含一個客戶(如子模塊或業務邏輯類)所需的方法即可,這種機制也稱為“定制服務”,即為不同的客戶端提供寬窄不同的接口。
接口隔離原則和單一職責都是為了提高類的內聚性、降低它們之間的耦合性,體現了封裝的思想,但兩者是不同的:
- 單一職責原則注重的是職責,而接口隔離原則注重的是對接口依賴的隔離
- 單一職責原則主要是約束類,它針對的是程序中的實現和細節;接口隔離原則主要約束接口,主要針對抽象和程序整體框架的構建
合成復用原則
合成復用原則又稱為組合/聚合復用原則(Composition/Aggregate Reuse Principle, CARP)
合成復用原則(Composite Reuse Principle, CRP):盡量使用對象組合,而不是繼承來達到復用的目的
合成復用原則就是在一個新的對象里通過關聯關系(包括組合關系和聚合關系)來使用一些已有的對象,使之成為新對象的一部分;新對象通過委派調用已有對象的方法達到復用功能的目的。簡言之:復用時要盡量使用組合/聚合關系(關聯關系),少用繼承
在面向對象設計中,可以通過兩種方法在不同的環境中復用已有的設計和實現,即通過組合/聚合關系或通過繼承,但首先應該考慮使用組合/聚合,組合/聚合可以使系統更加靈活,降低類與類之間的耦合度,一個類的變化對其他類造成的影響相對較少;其次才考慮繼承,在使用繼承時,需要嚴格遵循里氏代換原則,有效使用繼承會有助于對問題的理解,降低復雜度,而濫用繼承反而會增加系統構建和維護的難度以及系統的復雜度,因此需要慎重使用繼承復用
繼承復用的主要問題在于繼承復用會破壞系統的封裝性:
- 因為繼承會將基類的實現細節暴露給子類,由于基類的內部細節通常對子類來說是可見的,所以這種復用又稱“白箱”復用
- 子類與父類的耦合度高,如果父類發生改變,那么子類的實現也不得不發生改變,這不利于類的擴展與維護
- 從父類繼承而來的實現是靜態的,不可能在運行時發生改變,沒有足夠的靈活性
- 而且繼承只能在有限的環境中使用(如類沒有聲明為不能被繼承)
組合或聚合關系可以將已有的對象到新對象中,使之成為新對象的一部分
- 新對象可以調用已有對象的功能,這樣做可以使得成員對象的內部實現細節對于新對象不可見,所以這種復用又稱為“黑箱”復用
- 相對繼承關系而言,其耦合度相對較低,成員對象的變化對新對象的影響不大,可以在新對象中根據實際需要有選擇性地調用成員對象的操作
- 合成復用可以在運行時動態進行,新對象可以動態地引用與成員對象類型相同的其他對象
一般而言,如果兩個類之間是“Has-A”的關系應使用組合或聚合,如果是“Is-A”關系可使用繼承。"Is-A"是嚴格的分類學意義上的定義,意思是一個類是另一個類的"一種";而"Has-A"則不同,它表示某一個角色具有某一項責任。
迪米特原則
迪米特法則(Law of Demeter,LoD)也稱為最少知識原則(Least Knowledge Principle,LKP)
迪米特法則(Law of Demeter, LoD):一個軟件實體應當盡可能少地與其他實體發生相互作用
如果一個系統符合迪米特法則,那么當其中某一個模塊發生修改時,就會盡量少地影響其他模塊,擴展會相對容易,這是對軟件實體之間通信的限制,迪米特法則要求限制軟件實體之間通信的寬度和深度。迪米特法則可降低系統的耦合度,使類與類之間保持松散的耦合關系。
迪米特法則還有幾種定義形式,包括:不要和“陌生人”說話、只與你的直接朋友通信等,在迪米特法則中,對于一個對象,其朋友包括以下幾類:
當前對象本身(this);
以參數形式傳入到當前對象方法中的對象;
當前對象的成員對象;
如果當前對象的成員對象是一個集合,那么集合中的元素也都是朋友;
當前對象所創建的對象。
任何一個對象,如果滿足上面的條件之一,就是當前對象的“朋友”,否則就是“陌生人”。在應用迪米特法則時,一個對象只能與直接朋友發生交互,不要與“陌生人”發生直接交互,這樣做可以降低系統的耦合度,一個對象的改變不會給太多其他對象帶來影響。
迪米特法則要求我們在設計系統時,應該盡量減少對象之間的交互,如果兩個對象之間不必彼此直接通信,那么這兩個對象就不應當發生任何直接的相互作用,如果其中的一個對象需要調用另一個對象的某一個方法的話,可以通過第三者轉發這個調用。簡言之,就是通過引入一個合理的第三者來降低現有對象之間的耦合度。
在將迪米特法則運用到系統設計中時,要注意下面的幾點:
- 在類的劃分上,應當盡量創建松耦合的類,類之間的耦合度越低,就越有利于復用,一個處在松耦合中的類一旦被修改,不會對關聯的類造成太大波及
- 在類的結構設計上,每一個類都應當盡量降低其成員變量和成員函數的訪問權限
- 在類的設計上,只要有可能,一個類型應當設計成不變類
- 在對其他類的引用上,一個對象對其他對象的引用應當降到最低
總結
這 7 種設計原則是軟件設計模式必須盡量遵循的原則,各種原則要求的側重點不同。
- 開閉原則是總綱,它告訴我們要【對擴展開放,對修改關閉】
- 里氏替換原則告訴我們【不要破壞繼承體系】
- 依賴倒置原則告訴我們要【面向接口編程】
- 單一職責原則告訴我們實現類要【職責單一】
- 接口隔離原則告訴我們在設計接口的時候要【精簡單一】
- 迪米特法則告訴我們要【降低耦合度】
- 合成復用原則告訴我們要【優先使用組合或者聚合關系復用,少用繼承關系復用】
23種設計模式
總體來說,設計模式按照功能分為三類23種:
- 創建型(5種) : 工廠模式、抽象工廠模式、單例模式、原型模式、建造者模式
- 結構型(7種): 適配器模式、裝飾模式、代理模式 、外觀模式、橋接模式、組合模式、享元模式
- 行為型(11種): 模板方法模式、策略模式 、觀察者模式、中介者模式、狀態模式、責任鏈模式、命令模式、迭代器模式、訪問者模式、解釋器模式、備忘錄模式
參考:《大話設計模式》、《設計模式之禪》、網上相關設計模式文章