S.O.L.I.D 是面向對象設計(OOD)和面向對象編程(OOP)中的幾個重要編碼原則(Programming Priciple)的首字母縮寫。
SRP | The Single Responsibility Principle | 單一職責原則 |
OCP | The Open Closed Principle | 開放封閉原則 |
LSP | The Liskov Substitution Principle | 里氏替換原則 |
ISP | The Interface Segregation Principle | 接口分離原則 |
DIP | The Dependency Inversion Principle | 依賴倒置原則 |
一、單一職責原則(SRP)
從面向對象角度解釋這個原則為:”引起類變化的因素永遠不要多于一個。” 或者說 “一個類有且僅有一個職責”。這似乎不太好理解,特別是”引起類變化的因素永遠不要多于一個。”這句話更是有點虛,讓人有點摸不著頭腦。
我們通常都說“低耦合,高內聚”。在我看來,這里的”單一職責”就是我們通常所說的“高內聚”,即一個類只完成它應該完成的職責,不能推諉責任,也不可越殂代皰,不能成為無所不能的上帝類。如果你的團隊中實施寬松的“代碼集體所有權”,在編碼的過程中出現許多人同時修改(維護)同一個類的現象,而且成員之間的溝通不夠及時,主動和暢通的話,那么時間一長,就很可能出現“承擔過多職責”的上帝類。這時,提煉基類/接口和提煉類重構將能幫助我們消除或減輕這種設計臭味。
看一個例子:
這是一個違反了“單一職責原則” 的類結構圖。
這里,Rectangle類做了下面兩件事:
- 計算矩形面積;
- 在界面(繪制設備)上繪制矩形;
并且,有兩個應用使用了Rectangle類:
- 計算幾何應用程序(Computational Geometry Application)用這個類計算面積;
- 圖形程序(Graphical Application)用這個類在界面上繪制矩形;
這違反了SRP(單一職責原則)。因為Rectangle類做了兩件事,在一個方法里它計算了面積,在另外一個方法了它返回一個表示矩形的GUI。這會帶來一些有趣的問題:在計算幾何應用程序中我們必須包含GUI。也就是在開發幾何應用時,我們必須引用GUI庫;圖形應用程序中Rectangle類的變化可能導致計算幾何應用程序的變化,編譯和測試,反之亦然。那么,怎么修改才能讓其符合單一職責原則呢?
答案是:拆分!拆分職責到兩個不同的類中,如:
- Rectangle: 這個類應該只定義Area()方法;
- RectangleUI: 這個類應繼承Rectangle類,并定義Draw()方法。
二、開放封閉原則 (OCP)
從面向對象設計角度看,這個原則可以這么理解:”軟件實體(類,模塊,函數等等)應當對擴展開放,對修改閉合。” 通俗來講,它意味著你(或者類的客戶)應當能在不修改一個類的前提下擴展這個類的行為。在OOD里,對擴展開放意味著類或模塊的行為能夠改變,在需求變化時我們能以新的,不同的方式讓模塊改變,或者在新的應用中滿足需求。
也就是說,對擴展是開放的,而對修改是封閉的。我們通常都說:向系統中增加功能時應該只是添加新代碼,而應該盡量少的修改原代碼。在我看來,這就是遵循開放封閉原則所能帶來的效果。曾經在網上看到過這樣一句話“哪里變化,封裝哪里”。這其實就是說,我們要將系統中可能變化的地方封裝起來,即對修改封閉。同時,為了應對系統需求(功能)的擴展,需要抽象!
這里抽象是關鍵。《設計模式》中的state模式和strategy模式是這個原則的最好體現。
舉一個例子:
違反了開放封閉原則的類結構圖。
客戶端代碼直接面向服務器端的具體實現編程,缺乏靈活性。這樣如果服務器因為某些原因被其他服務器替換了,那么客戶端調用服務器的代碼也必須做相應的修改或替換。這其實就是”面向實現編程“的設計臭味!
那么,如何修改才能得到正確靈活的設計?
答案是:抽象!為服務器端的代碼(類型)抽象出一個抽象基類(定義一組完成服務職責的最小接口)。
下面是正確的設計:
遵循開放封閉原則的類結構圖。
基本上,你抽象的東西是你系統的核心內容,如果你抽象得好,很可能增加一個新的服務器類型(擴展)只需要添加新類型(繼承自AbstractServer即可)。因此代碼要盡可能以抽象(這里的AbstractServer)為依據,這會允許你擴展抽象事物,定義一個新的實現而不需要修改任何客戶端代碼。即”面向接口編程,不要面向實現編程“!
三、Liskov’s 替換原則(LSP)
Liskov’s 替換原則意思是:”子類型必須能夠替換它們的基類型。”或者換個說法:”使用基類引用的地方必須能使用繼承類的對象而不必知道它。” 這個原則正是保證繼承能夠被正確使用的前提。通常我們都說,“優先使用組合(委托)而不是繼承”或者說“只有在確定是 is-a 的關系時才能使用繼承”,因為繼承經常導致”緊耦合“的設計。
在基本的面向對象原則里,”繼承”通常是”is a”的關系。如果”Developer” 是一個”SoftwareProfessional”,那么”Developer”類應當繼承”SoftwareProfessional”類。在類設計中”Is a”關系非常重要,但它容易沖昏頭腦,導致使用錯誤的繼承造成錯誤設計。
看一個最最經典的例子:
遵循Liskov替換原則的類結構圖。
注:這里,KingFisher(翠鳥)類擴展了Bird基類,并繼承了Fly()方法,這沒有問題。
但是下面這個類結構圖就存在設計上的問題:
違反Liskov替換原則的類結構圖。
Ostrich(鴕鳥)是一種鳥,這毋庸置疑,并從Bird類繼承,這從概念上說沒有問題。但是鴕鳥它能飛嗎?不能,那么這個設計就違反了LSP。因為在使用Bird的地方不一定能用Ostrich代替。所以,即使在現實中看起來沒問題,在類設計中,Ostrich不應該從Bird類繼承,這里應該從Bird中分離一個不會飛的類NoFlyBrid,Ostrich應該繼承這個不會飛的鳥類NoFlyBrid。
為什么LSP如此重要?
- 如果沒有LSP,類繼承就會混亂;如果子類作為一個參數傳遞給方法,將會出現未知行為;
- 如果沒有LSP,適用與基類的單元測試將不能成功用于測試子類;
四、接口分離原則(ISP)
這個原則的意思是”客戶端不應該被迫依賴于它們不用的接口。” 也就是說,一個接口或者類應該擁有盡可能少的行為(那么,什么叫盡可能少?就是少到恰好能完成它自身的職責),這也是保證“軟件系統模塊的粒度盡可能少,以達到高度可重用的目的。
接口包含太多的方法會降低其可用性,像這種包含了無用方法的”胖接口”會增加類之間的耦合。如果一個類想實現該接口,那么它需要實現所有的方法,盡管有些對它來說可能完全沒用,所以這樣做會在系統中引入不必要的復雜度,降低代碼的可維護性或魯棒性。
接口分離原則確保實現的接口有它們共同的職責,它們是明確的,
下面這個例子充分的說明了”接口應該僅包含必要的方法,而不該包含其它的“。如果一個接口包含了過多的方法,應該通過分離接口將其拆分。
這是一個違反接口分離原則的胖接口。
注意到IBird接口包含很多鳥類的行為,包括Fly()行為.現在如果一個Bird類(如Ostrich)實現了這個接口,那么它需要實現不必要的Fly()行為(Ostrich不會飛)。因此,這個”胖接口”應該拆分成兩個不同的接口,IBird和IFlyingBird, 而IFlyingBird繼承自IBird。如下圖所示:
這樣的話,重用將變得非常靈活:如果一種鳥不會飛(如Ostrich),那它實現IBird接口。如果一種鳥會飛(如KingFisher),那么它實現IFlyingBird。
因此,如果我們想要獲得可重用的方案,就應當遵循接口分離原則,把接口定義成僅包含必要的部分,
五、依賴倒置原則(DIP)
這個原則的意思是:高層模塊不應該依賴底層模塊,兩者都應該依賴其抽象。其實又是”面向接口編程,不要面向實現編程“的內在要求。
我們考慮一個現實中的例子,來看看依賴倒置原則給我們軟件帶來的好處。
你的汽車是由很多如引擎,車輪,空調和其它等部件組成,對嗎?
注意:這里的 Car 就是高層模塊;它依賴于抽象接口IToyotaEngine 和 IEighteenInchWheel.
而具體的引擎FifteenHundredCCEngine 屬于底層模塊,也依賴于抽象接口IToyotaEngine ;
具體的車輪 EighteenInchWheelWithAlloy同樣屬于底層模塊,也依賴于抽象接口IEighteenInchWheel。
上面Car類有兩個屬性(引擎和車輪列表),
除SOLID原則外還有很多其它的面向對象原則。如:
- “組合替代繼承”:這是說相對于繼承,要更傾向于使用組合;
- “笛米特法則”:這是說”你的類對其它類知道的越少越好”;
- “共同封閉原則”:這是說”相關類應該打包在一起”;
- “穩定抽象原則”:這是說”類越穩定,越應該由抽象類組成”;