解讀設計原則
概述
設計原則就一本菜譜,告訴我們一道美味的菜應該是什麽樣的,或者說需要具備什麽。但是又沒有一個固化或可測量的標準。寫代碼就和烹飪一樣,只有當自己品嘗以後才知其味。
1 開閉原則
定義:
開閉原則(Open-Closed Principle, OCP):一個軟件實體應當對擴展開放,對修改關閉。即軟件實體應盡量在不修改原有代碼的情況下進行擴展。
解讀
開閉原則很簡單,就是當需求變更的時候,盡量不要修改已有代碼。開閉原則是整個設計原則的核心思想。如果當你發現某個需求的改動需要涉及許多地方的代碼改動,那麽你的代碼很有可能是不滿足開閉原則的。
小結
單獨說開閉原則是沒有什麽內容可講的,就像和你說“堅強的人可以克服任何困難”。那麽其實我們關心的是怎麽才能變得“堅強”。其他的設計原則其實都是圍繞著怎麽實現開閉原則而進行的。
2 單一職責原則
定義:
單一職責原則(Single Responsibility Principle, SRP):一個類只負責一個功能領域中的相應職責,或者可以定義為:就一個類而言,應該只有一個引起它變化的原因。
解讀
通俗一點說,就是一個類不能承擔太多的功能。只專心一件事,並且把這件事做好。舉一個例子,假如要寫一個食堂類。對於客戶端來說,食堂核心功能是要能打飯和回收餐具。如果要打飯那麽肯定需要有先把菜做好,如果要做菜還要有人去買菜。那麽如果這些功能都放在一個類裏面就會如下圖:
很明顯違反了單一職責原則。為什麽?明明就是只負責吃相關職能啊。你都說了是吃相關,那麽肯定就不只是一個簡單功能。吃前要準備,吃後要收拾。其實作為一個服務,就和我們去食堂吃飯,我只關心怎麽打飯,我吃完以後把餐具放哪裏。其他的至於菜市從哪裏買的,誰炒的都不關心。因此,改良一下
作為食堂,提供多種多樣的菜品(如川菜、粵菜)。如果讓一個廚師即炒粵菜又炒川菜又違背了單一職責原則。因此再改一下:
單一職責好處就是: 可以降低類的復雜度,提高類的可讀性,降低變更引起的風險降低。變更是必然的,如果單一職責原則遵守的好,當修改一個功能時,可以顯著降低對其他功能的影響。
小結
其實單一職責不僅僅是針對一個類的設計,往小的說一個方法、往大的說一個模塊都應該滿足單一職責原則。只是怎麽去確定職責及其範圍是需要根據具體的場景來確定。
3 裏氏代換原則
定義:
裏氏代換原則(Liskov Substitution Principle, LSP):所有引用基類(父類)的地方必須能透明地使用其子類的對象。
解讀
裏氏代換原則核心就是要滿足繼承體系,能用父類的地方,那麽其任何子類也可以正確調用。否則,就不應該做為其子類(或者父類的定義就不正確)。例如鳥是一個父類,那麽所有的鳥都應該是卵生。但是如果父類定義了方法fly()就不一定了,因為有的鳥不會飛的。這個就不滿足裏氏替換原則。從代碼層面上來說:如果一個類實現一個接口,那麽就應該實現該接口的所有方法。比較經典的案例就是Spring的CacheManager的接口。
小結
如果你理解(或者使用過)面向對象語言,那麽裏氏替換原則理解起來就十分簡單。例如java在編譯的時候就會檢查一個程序是否符合裏氏替換原則。雖然很簡單,但是這也是實現開閉原則的基本條件。試想,當擴展一個接口的時候,發現擴展類在實現該接口的方法不能正確調用,那麽這個該擴展也是沒有任何意義的。
4 依賴倒轉原則
定義:
依賴倒轉原則(Dependency Inversion Principle, DIP):抽象不應該依賴於細節,細節應當依賴於抽象。換言之,要針對接口編程,而不是針對實現編程。
解讀
上面定義中的抽象可以簡單的理解為接口,細節就是接口的實現類。也就是說,無論是定義變量、方法參數類型還是方法返回都盡量使用抽象接口,而不是返回具體的實現類。這樣做的目的就是方便以後擴展(也就是實現開閉原則的手段)。還是前面的食堂為例,假如一開始的時候只有MarketA賣菜,采購員就只有去哪裏賣菜。也許一開始的代碼就會這樣寫:
class MarketA { public String name() { return ("MarketA"); } } class Buyer { public void buy(MarketA marketA) { System.out.println("采購員在" + marketA.name() + "買菜"); } } public void CanteenBuyFood() { Buyer buyer = new Buyer(); buyer.buy(new MarketA()); }
突然有一天,MarketB開業,並且每周一的價格比MarketA便宜,因此,選擇周一在MarketB買菜。
class MarketA { public String name() { return ("MarketA"); } } class MarketB { public String name() { return ("MarketB"); } } class Buyer { public void buyA(MarketA marketA) { System.out.println("采購員在" + marketA.name() + "買菜"); } public void buyB(MarketB marketB) { System.out.println("采購員在" + marketB.name() + "買菜"); } } public void CanteenBuyFood() { Buyer buyer = new Buyer(); // 其實這也也不滿足單一職責原則,即要選擇菜市場,又要派采購員買菜。 if(to is monday){ buyer.buyA(new MarketA()); }else{ buyer.buyB(new MarketB()); } }
突然又有一天,MarketC,MarketD...MarketCX 開業了,並且....好吧,是不是發現這樣寫下去什麽時候是個頭啊。因此我們需要進行代碼重構,使其滿足開閉原則。
interface Market { String name(); } class MarketA implements Market { public String name() { return ("MarketA"); } } class MarketB implements Market { public String name() { return ("MarketB"); } } interface Buyer { public void buy(Market market); } //省略Buyer的子類 public void CanteenBuyFood() { Market market = selectMarket(); Buyer buyer = selectBuyer(); buyer.buy(market); } private Buyer selectBuyer() { //根據實際情況選擇采購員 } private Market selectMarket() { // 根據情況選擇市場 }
重構後代碼是不是無論加多少market,只要修改selectMarket()的方法。無論加多少采購員,只要修改Buyer.getBuyer()中的代碼。
小結
可以看出,在代碼重構過程中,在大多數情況下開閉原則、裏氏代換原則和依賴倒轉原則這三個設計原則會同時出現。開閉原則是目標,裏氏代換原則是基礎,依賴倒轉原則是手段,他們目的都是為了代碼的擴展性,只是分析問題時所站角度不同而已。
5 接口隔離原則
定義:
接口隔離原則(Interface Segregation Principle, ISP):使用多個專門的接口,而不使用單一的總接口,即客戶端不應該依賴那些它不需要的接口。
解讀
接口隔離就是定義一個接口的時候不要定義得太而廣,而是把他們分割成一些更細小的接口。一般不滿足單一職責原則都不滿足接口隔離原則,這樣的例子很多,就不多說明了。但反過來卻不一定,舉一個例子(往往原則的例子舉反例是最清晰的)。還是買菜的問題,如果我們定義一個市場,在市場裏面買肉、買蔬菜、買水果....),往往一開始我們定義接口的時候是按照一個樣例來定義接口,比如上面就是按照一個超級市場來定義的接口,其實這樣做是很正常的情況。
interface Market { void buyMeat(); void buyVegetables(); } class SuperMarket implements Market { public void buyMeat() { System.out.println("買肉"); } public void buyVegetables() { System.out.println("買菜"); } }
這樣定義接口在一開始是沒問題,但是隨著業務擴展,如果現在存在另外一個市場,他只賣菜,不賣肉。。那麽該類在 buyMeat()下面怎麽辦?當你發現你實現一個接口的時候,某個需要實現的方法你沒辦法去實現,這種情況也算不滿足接口隔離的原則了。這時候就需要進行重構,把接口進一步細化,例如,把Martet細化為MeatMarket和VegetableMarket。
interface Market { //其他共有方法 } interface MeatMarket extends Market{ void buyMeat(); } interface VegetableMarket extends Market{ void buyVegetables(); } class SuperMarket implements MeatMarket,VegetableMarket { public void buyMeat() { System.out.println("買肉"); } public void buyVegetables() { System.out.println("買菜"); } } class SmallMarket implements VegetableMarket { public void buyVegetables() { System.out.println("買菜"); } }
上面的實例代碼僅僅是為了說明接口隔離原則,
小節
接口隔離原則核心思想就是細化接口,提高程序的靈活性。但細化到什麽程度卻沒有具體的度量,接口不能太小,如果太小會導致系統中接口泛濫,反而不利於維護。因此如何把握這個讀就是經驗了。
迪米特法則
定義
迪米特法則(Law of Demeter, LoD):一個軟件實體應當盡可能少地與其他實體發生相互作用。
解讀
迪米特法則還有幾種定義形式,包括:不要和“陌生人”說話、只與你的直接朋友通信等
一個類只與他的朋友類進行交互
所謂一個類的朋友,就是該類
- 當前對象本身(this);
- 以參數形式傳入到當前對象方法中的對象,或者返回的方法返回的對象
- 當前對象的成員對象;
從代碼層面上講,當該類的所有方法體為空的時候,該類所依賴的類就是朋友類 所謂只與他朋友類進行交互意思就比較好理解了,就是不能直接調用他朋友的朋友方法。也就是在中,食堂服務\=\=朋友==>食堂後勤\=\=朋友==>廚師,我們不能直接在食堂服務中直接調用讓廚師去炒菜。食堂服務類要與廚師類通信,也是必須要有食堂後勤類作為中介。從代碼層面上來說,一般 A.getB().getC().xxxMethod() 往往都是破壞迪米特法則。
即使是朋友也要保持距離
一個類公開的public屬性或方法越多,修改時涉及的面也就越大,變更引起的風險擴散也就越大。例如,
class A { public void step1(){ //do something } public void step2(){ //do something} } public void step3(){ //do something} } } class M { public void someCall(A a) { a.step1(); a.step2(); a.step3(); } }
從代碼裏面看,A確實是M的朋友,但是,M和A太"親密"了,如果以後需要在調用step2的之前調用一個check2()的方法,就必須要修改M中的方法,如果這個調用方式大量出現在工程中,就會引起擴散。如果我之前是這麽設計的交互的方式就不會存在這個問題。也就是說,朋友之間的交互不要太"親密"。同時作為別人朋友,最好能提供“一站式服務”。
class A { private void step1(){ //do something } private void step2(){ //do something} } private void step3(){ //do something} } public void exe(){ step1(); step2(); step3(); } } class M { public void someCall(A a) { a.exe(); } }
小節
迪米特發展的目的就是降低系統的耦合度,使類與類之間保持松散的耦合關系。和接口隔離原則一樣,迪米特法則也是一個無法進行度量。過度使用迪米特法則,也會造成系統的不同模塊之間的通信效率降低,這就直接導致了系統中存在大量的中介類。因此,如何使用迪米特法則還是那句話,根據經驗。
總結
單一職責原則告訴我們實現類的功能不要太多。裏氏替換原則告訴我們不要破壞繼承體系;依賴倒置原則告訴我們要面向接口編程;接口隔離原則告訴我們在設計接口的時候要精簡單一;迪米特法則告訴我們要降低耦合。而開閉原則總的大綱,要對擴展開放,對修改關閉。
設計原則只是給我們一些指導性的意見,在實際工作中往往要根據實際情況來判斷。就如開篇所說的那樣,往往菜譜往往給我們的意見都是“少許”,“適量”,而真正要烹飪出一道美味還需要我們自己不斷去積累和調整。
解讀設計原則