物件導向設計原則和建立SOLID應用的5個方法

importnew發表於2014-04-08

  最近我聽到了很多關於函數語言程式設計(FP),受之啟發我覺得也應該關注物件導向程式設計(OOP)和麵向物件設計(OOD),因為在設計系統時這些仍然非常重要。

  我們將以SOLID原則為起點開始我們的旅程。SOLID原則是類級別的,物件導向的設計理念,它們與測試工具一起幫你改進腐壞的程式碼。SOLID由程式設計師們最喜歡的大叔 Robert C. Martin(Bob大叔)提出,它其實是五個其他縮略詞的組合——SRP, OCP, LSP, ISP, DIP,我會在下面有更深入的介紹。最重要的是,SOLID原則使你的軟體變得更有價值。

  呃,這個程式碼有壞味道…

  程式碼腐壞

  1.當應用程式程式碼大量腐壞,開發人員會發現程式碼越來越難以維護、臃腫。那麼如何識別將來的程式碼腐壞?這些跡象可能表明將要程式碼腐壞:

  • 僵化——小的變化導致整個系統重建。

  • 脆弱——一個模組的變化導致其他不相關模組不正常執行。想象一個汽車系統,改變電臺的功能會影響到窗戶的使用。

  • 固定——一個模組的內部元件不能被抽取並且在新環境重用。比如一個應用程式的登入模組不能在完全不同的系統中使用,那麼這個模組是固定的,這是由於各模組之間的耦合和依賴造成的。改進的策略是從低層次的細節,比如特定的資料庫,UI實現(Web,桌面),特殊框架等解耦核心抽象。

  • 粘性——程式碼構建和測試很難執行,並且要花費很長時間執行,甚至小的變化有很高的成本,並且要求在多個位置/層次修改。

  使用者期望從他們所用的軟體之外得到一些價值。一個應用程式的價值在於它能否幫助使用者將一些事情做得更好,增加生產力或者時間或金錢,在“浪費”上有所節省。人們通常付出金錢來換取價值高的軟體。

  但是使用者從偉大的軟體得到了次要價值。我想要談談這個價值,因為這也是人們在談論軟體價值時最先想到的:功能。

  如果軟體完成了使用者需求的同時沒有崩潰和延遲,那麼這個軟體的次要價值就高。軟體滿足了使用者的當前需求,使用者就獲得了次要價值。但是,使用者需求經常變化,軟體提供的功能和使用者需求很容易不同步,這導致了價值降低。為了保持次要價值高,軟體必須能夠跟上使用者不斷變化的需求。所以在這裡我們來談談軟體的首要價值,它必須能夠容忍和有助於正在進行的變化。

  試想一下,你的軟體目前可以滿足使用者的需求,但是實在是很難改變,改變成本很高。那麼,由於應用程式的不靈活性以及其盈利能力可能降低,使用者會不高興。

  現在試想其他的軟體開始時次要價值低,但是它可以容易且廉價地改變。盈利能力持續上升,使用者也越來越高興。

 那麼什麼是SOLID原則?

  單一職責原則(SRP)

  單一職責原則(Single Responsibility Principle,SRP)指出,一個類發生變化的原因不應該超過一個。這意味著程式碼中每個類,或者類似的結構只有一個功能。

  在類中的一切都與該單一目的有關,即內聚性。這並不是說類只應該含有一個方法或屬性。

  類中可以包括很多成員,只要它們與單一的職責有關。當類改變的一個原因出現時,類的多個成員可能多需要修改。也可能多個類將需要更新。

  下面的程式碼有多少職責?

class Employee {
  public Pay calculatePay() {...}
  public void save() {...}
  public String describeEmployee() {...}
}  

  正確答案是3個。

  在一個類中混合了1)支付的計算邏輯,2)資料庫邏輯,3)描述邏輯。如果你將多個職責結合在一個類中,可能很難實現修改一部分時不會破壞其他部分。混合職責也使這個類難以理解,測試,降低了內聚性。修改它的最簡單方法是將這個類分割為三個不同的相互分離的類,每個類僅僅有一個職責:資料庫訪問,支付計算和描述。

  開閉原則(OCP)

  開閉原則(Open-Closed Principle,OCP)指出:類應該對擴充套件開放但對修改關閉。“對擴充套件開放”指的是設計類時要考慮到新需求提出時類可以增加新的功能。“對修改關閉”指的是一旦一個類開發完成,除了改正bug就不再修改它。

  這個原則的兩個部分似乎是對立的。但是,如果正確地設計類和他們的依賴關係,就可以增加功能而不修改已有的原始碼。

  通常來說可以通過依賴關係的抽象實現開閉原則,比如介面或抽象類而不是具體類。通過建立新的類實現介面來增加功能。

  在專案中應用OCP原則可以限制程式碼的更改,一旦程式碼完成,測試和除錯之後就很少再去更改。這減少了給現有程式碼引入新bug的風險,增強軟體的靈活性。

  為依賴關係使用介面的另一個作用是減少耦合和增加靈活性。

void checkOut(Receipt receipt) {
  Money total = Money.zero;
  for (item : items) {
    total += item.getPrice();
    receipt.addItem(item);
  }
  Payment p = acceptCash(total);
  receipt.addPayment(p);
}

  那麼增加信用卡支援該怎麼做?你可能像下面的增加if語句,但這違反OCP原則。

Payment p;
if (credit)
  p = acceptCredit(total);
else
  p = acceptCash(total);
receipt.addPayment(p);

  更好的解決方案是:

public interface PaymentMethod {void acceptPayment(Money total);}
 
void checkOut(Receipt receipt, PaymentMethod pm) {
  Money total = Money.zero;
  for (item : items) {
    total += item.getPrice();
    receipt.addItem(item);
  }
  Payment p = pm.acceptPayment(total);
  receipt.addPayment(p);
}

  這兒有一個小祕密:OCP僅僅用於即將到來的變化可預見的情況,那麼只有類似的變化已經發生時應用它。所以,首先做最簡單的事情,然後判斷會有什麼變化,就能更加準確地預見將來的變化。

  這意味著等待使用者做出改變,然後使用抽象應對將來的類似變化。

  里氏替換原則(LSP)

  里氏替換原則(Liskov Substitution Principle,LSP)適用於繼承層次結構,指出設計類時客戶端依賴的父類可以被子類替代,而客戶端無須瞭解這個變化。

  因此,所有的子類必須按照和他們父類相同方式操作。子類的特定功能可能不同,但是必須符合父類的預期行為。要成為真正的行為子型別,子類必須不僅要實現父類的方法和屬性,也要符合其隱含行為。

  一般來說,如果父型別的一個子型別做了一些父型別的客戶沒有預期的事情,那這就違反LSP。比如一個派生類丟擲了父類沒有丟擲的異常,或者派生類有些不能預期的副作用。基本上派生類永遠不應該比父類做更少的事情。

  一個違反LSP的典型例子是Square類派生於Rectangle類。Square類總是假定寬度與高度相等。如果一個正方形物件用於期望一個長方形的上下文中,可能會出現意外行為,因為一個正方形的寬高不能(或者說不應該)被獨立修改。

  解決這個問題並不容易:如果修改Square類的setter方法,使它們保持正方形不變(即保持寬高相等),那麼這些方法將弱化(違反)Rectangle類setter方法,在長方形中寬高可以單獨修改。

public class Rectangle {
  private double height;
  private double width;
 
  public double area();
 
  public void setHeight(double height);
  public void setWidth(double width);
}

  以上程式碼違反了LSP。

public class Square extends Rectangle {  
  public void setHeight(double height) {
    super.setHeight(height);
    super.setWidth(height);
  }
 
  public void setWidth(double width) {
    setHeight(width);
  }
}

  違反LSP導致不明確的行為。不明確的行為意味著它在開發過程中執行良好但在產品中出現問題,或者要花費幾個星期除錯每天只出現一次的bug,或者不得不查閱數百兆日誌找出什麼地方發生錯誤。

  介面隔離原則(ISP)

  介面隔離原則(Interface Segregation Principle)指出客戶不應該被強迫依賴於他們不使用的介面。當我們使用非內聚的介面時,ISP指導我們建立多個較小的內聚度高的介面。

  當你應用ISP時,類和他們的依賴使用緊密集中的介面通訊,最大限度地減少了對未使用成員的依賴,並相應地降低耦合度。小介面更容易實現,提升了靈活性和重用的可能性。由於很少的類共享這些介面,為響應介面的變化而需要變化的類數量降低,增加了魯棒性。

  基本上,這裡的教訓是“不要依賴你不需要的東西”。下面是例子:

  想象一個ATM取款機,通過一個螢幕顯示我們想要的不同資訊。你會如何解決顯示不同資訊的問題?我們使用SRP,OCP和LSP想出一個方案,但是這個系統仍然很難維護。這是為什麼?

  想象ATM的所有者想要新增僅在取款功能出現的一條資訊,“ATM機將在您取款時收取一些費用,您同意嗎”。你會如何解決?

  可能你會給Messenger介面增加一個方法並使用這個方法完成。但是這會導致重新編譯這個介面的所有使用者,幾乎所有的系統需要重新部署,這直接違反了OCP。讓程式碼腐壞開始了!

  這裡出現了這樣的情形:對於取款功能的改變導致其他全部非相關功能也變化,我們現在知道這並不是我們想要的。這是怎麼回事?

  其實,這裡是向後依賴在作怪,使用了該Messenger介面每個功能依賴了它不需要,但是被其他功能需要的方法,這正是我們想要避免的。

public interface Messenger {
  askForCard();
  tellInvalidCard();
  askForPin();
  tellInvalidPin();
  tellCardWasSiezed();
  askForAccount();
  tellNotEnoughMoneyInAccount();
  tellAmountDeposited();
  tellBalance();
}

  相反,將Messenger介面分割,不同的ATM功能依賴於分離的Messenger。

public interface LoginMessenger {
  askForCard();
  tellInvalidCard();
  askForPin();
  tellInvalidPin(); 
}
 
public interface WithdrawalMessenger {
  tellNotEnoughMoneyInAccount();
  askForFeeConfirmation();
}
 
publc class EnglishMessenger implements LoginMessenger, WithdrawalMessenger {
  ...   
}

  依賴反轉原則(DIP)

  依賴反轉原則(Dependency Inversion Principle,DIP)指出高層次模組不應該依賴於低層次模組;他們應該依賴於抽象。第二,抽象不應該依賴於細節;細節依賴於抽象。方法是將類孤立在依賴於抽象形成的邊界後面。如果在那些抽象後面所有的細節發生變化,那我們的類仍然安全。這有助於保持低耦合,使設計更容易改變。DIP也允許我們做單獨測試,比如作為系統外掛的資料庫等細節。

  例子:一個程式依賴於Reader和Writer介面,Keyboard和Printer作為依賴於這些抽象的細節實現了這些介面。CharCopier是依賴於Reader和Writer實現類的低層細節,可以傳入任何實現了Reader和Writer介面的裝置正確地工作。

public interface Reader { char getchar(); }
public interface Writer { void putchar(char c)}
 
class CharCopier {
 
  void copy(Reader reader, Writer writer) {
    int c;
    while ((c = reader.getchar()) != EOF) {
      writer.putchar();
    }
  }
}
 
public Keyboard implements Reader {...}
public Printer implements Writer {...}

  最後的問題——使用SOLID

  我想SOLID原則是你的工具箱裡很有價值的工具。在設計下一個功能或者應用時他們就應該在你的腦海中。正如Bob大叔在他那不朽的帖子中總結的:

     
SRP 單一職責原則 一個類有且只有一個更改的原因。
OCP 開閉原則 能夠不更改類而擴充套件類的行為。
LSP 里氏替換原則 派生類可以替換基類被使用。
ISP 介面隔離原則 使用客戶端特定的細粒度介面。
DIP 依賴反轉原則 依賴抽象而不是具體實現。

  而且,將這些原則應用在專案中。

  原文連結: zeroturnaround 翻譯: ImportNew - 何 佳妮

相關文章