面向對象設計原則『SOLID』在開發中的應用

本文詳細分析了面向對象設計五大原則 S(單一職責原則『SRP』)、O(開放-封閉原則 『OCP』)、L(Liskov 替換原則『LSP』)、I(接口隔離原則『ISP』)、D(依賴倒置原則『DIP』),並假以實例輔之。git

本文同時發表於個人我的博客github

Overview


軟件設計五大原則『SOLID』以及23種經典設計模式自成型以來已有些年頭,目前在實際開發中對待它們有兩種較爲極端的態度:敬而遠之、嗤之以鼻。 顯然,筆者用了『極端』二字代表並不贊同這樣的觀點。 SOLID 以及經典設計模式是前人在長期的軟件開發中總結出來的寶貴實踐經驗,值得咱們學習和借鑑。固然,這並不意味着咱們要時刻把它們掛在嘴邊,以彰顯咱們的『內力』,也並不意味着就要把它們做爲『最高律令』、『不可逾越的紅線』早早地就套用在軟件開發的過程當中(這無疑將增長開發的複雜性)。算法

應將其做爲解決問題的方案。編程

此時,有必要再談談『敏捷開發』: 在移動互聯網時代你們都是以『小步快跑、快速迭代、快速試錯』的節奏與時間賽跑、搶佔流量。『敏捷開發』於是被時常說起,遺憾的是其大多數時候也僅是停留在嘴邊。 在移動互聯網時代,筆者認爲敏捷開發的核心有兩點:設計模式

  • 不作過分設計,始終盡力保持代碼簡潔、易理解、好維護(不用一開始就套用各類原則、設計模式,徒增複雜);
  • 擁抱變化,不管是因需求仍是其餘緣由引發變化致使現有代碼結構不能知足須要時,要積極地對代碼進行重構,始終保持良好的代碼結構,對代碼的腐朽保持零容忍(出現問題後可借鑑 SOLID、設計模式等去解決問題)。

從上述兩點能夠看出:敏捷開發是一個持續的過程,而非一個心血來潮的事件。微信

ps:重構不必定是翻天覆地的大改,重命名變量、分解複雜方法等等都是重構。函數

本文將以 SOLID 五大設計原則爲主線,輔以設計模式爲解決方案,談談 QQ 閱讀、iOS 系統 API 在代碼設計上的得失(失主要是對 QQ 閱讀個別代碼的反思)。佈局

單一職責原則『SRP』


SRP 很是好理解,與『內聚性』表達的是一樣的關注點。 SRP 在 SOLID 五大原則中能夠說是最簡單、最基礎的原則。然而在實際開發中,對 SRP 的把握又是最難的。單一職責,到底什麼是職責?單一的粒度如何?總之很差把握,就像生活中的各類適量『煮飯時適量加點水、作菜時適量放點鹽』(常常讓人抓狂 v_v)。 Bob 大叔在《敏捷軟件開發》一書中將職責定義爲:變化的緣由,單一職責即爲:僅有一個引發實體(模塊、類、方法等)變化的緣由。在把握單一職責時,這不失爲一個很好的抓手,經過觀察、思考設計的實體是否有一個以上的變化緣由來判斷其職責是否單一。學習

後文爲敘述方便,如無特別說明,實體指模塊、類、方法等功能代碼塊。動畫

筆者認爲 SRP 做爲最基礎的設計原則,主要有兩點收益:

  • 下降實體的複雜度,提高可維護性;
  • 提升實體的可複用性,當一個實體中耦合了多個職責時,其可複用性必然受到影響。即便多處複用了,其中一個職責的變化對複用其餘職責的實體也會形成意想不到的影響,這不是咱們想看到的。這也是 Bob 大叔將職責定義爲『變化』的緣由。

例1 UIView 與 CALayer

在 UIView 的層級結構中,咱們知道每一個 View 背後都有一個 CALayer 與之對應。 其中,UIView 的主要職責是處理用戶交互,CALayer 則是佈局、渲染以及動畫等。 Apple 之因此要設計 UIView 與 CALayer 兩套體系,就是爲了使它們的職責更加單一,能更好的複用。 在 iOS 與 Mac OS 上,用戶交互處理方式有本質的區別,然而在佈局、渲染、動畫等方面又是一致的。所以,經過將上述職責分離,CALayer 能夠很好地在 iOS 與 Mac OS 間複用,而用戶交互的處理則各自獨立,因而有了 UIKit、AppKit。

例2 View 與 ViewModel

例1中的 View 與 Layer 屬於系統實現層面,在應用層面 UIView 的職責是明確的、單一的:UI 佈局。然而在實際開發中有大量展現相關的業務邏輯寫到了 View 裏面,嚴重影響了 View 的可複用性。究其緣由,在非 MVVM 模式下,展現邏輯只能放在 Controller 中,勢必形成 Controller 過於臃腫。因而,在 QQ 閱讀中咱們提出以 View-ViewModel 模式構建 UI 組件,將展現邏輯放到 ViewModel 中,View 僅處理佈局邏輯。目前看效果良好,View 的邏輯更加清晰、可複用性獲得很大提升。詳細信息請參看『自定義 UI 組件庫』一文。

開放-封閉原則 『OCP』


『惟有變化纔是永恆』,對於軟件開發來講更是如此,一個模塊、類、方法等實體幾乎不可能在第一個版本開發出來後就一直保持不變。所以,變化是開發人員必需要面對的問題(可謂愛之恨之)。 OCP 就是用於指導咱們如何應對變化。 OCP 的含義是:『對擴展開放,對修改封閉』。 具體說,實體的功能能夠不斷擴展(變化),但實體的源碼不容許修改。 看似十分矛盾!就像『東西能夠隨便買,但錢不容許花』。 仔細分析,OCP 的重點是擴展新功能,也就是擴展新功能時能夠添加新代碼,但不能修改已有代碼。由於對已有代碼的修改帶來的影響是難於預料的,若是修改致使鏈鎖反應,後果更是災難性的。 如何作到?

關鍵在抽象

『面向接口編程,而非實現編程』這是咱們常常掛在嘴邊的話。 面向接口編程,也就是說依賴的是抽象接口,爲的就是能夠靈活的替換接口背後的實現。這不正是 OCP 須要的嗎!

實現方案

在23種經典設計模式中『Template Method 模式』以及『Strategy 模式』均可以很好地實現 OCP,其中 Template Method 模式的實現依賴於繼承,Strategy 模式使用的委託(接口)。

Template Method 模式

Template Method 模式類圖如上圖所示(來自 GoF 的《Design patterns》)。 Template Method 模式在抽象基類中定義 TemplateMethod方法,但該方法並不作實際工做,只是調用其它方法( PrimitiveOperation...,C++中須是虛函數)來完成具體的工做。 可見, TemplateMethod方法只是定義了一個任務或算法的骨架、執行步驟。 所以,能夠經過派生新的子類,並實現 PrimitiveOperation...方法來擴展功能。

Strategy 模式

Strategy 模式類圖如上圖所示(來自 GoF 的《Design patterns》)。 Strategy 模式是典型的面向接口編程,經過接口使得業務層(使用方)與實現細節徹底解耦,從而能夠很方便地經過擴展實現來擴展新功能,而無須對業務層進行修改。 縱觀 Template Method 與 Strategy 模式,前者經過繼承並重寫方法(C++中的虛函數)來擴展新功能,後者經過新增實現了特定接口的類開添加新功能。 二者無謂優劣,不一樣的場景使用不一樣的方案。可是,繼承會增長複雜度,這是共識,在使用 Template Method 模式時須要考慮到這點。

例1 QQ 閱讀登陸模塊

QQ 閱讀起初只有 QQ 一種登陸方式,忽然有一天 Apple 爸爸說不得強制用戶必須登陸才能使用 App。無奈之下,咱們添加了遊客登陸模式。

上圖就是增長遊客登陸後的結構簡圖。QQ 登陸、遊客登陸看似相安無事。 但, 衆多業務模塊直接與兩種登陸方式交互,嚴重破壞了 OCP。 後果如何? 後果是嚴重的!後面若是要增長其餘登陸方式,全部與登陸態有關的模塊全都要改一遍!

問題出在哪裏?筆者認爲最初業務層直接與 QQ 登陸交互並沒有大礙,關鍵是在添加遊客登陸時須要察覺到其中的問題,並當即作出重構,而不是在現有代碼基礎上糊亂堆疊代碼。

果不其然,沒多久產品要求添加微信登陸。因而趁機對登陸作了一次完全的重構。

重構過程當中,咱們添加了『鑑權中心』模塊 QRAuthenticatonCenter統一處理登陸相關的問題,同時使用了 Strategy 模式將各類登陸方式的實現細節與 QRAuthenticatonCenter以及業務層隔離開來。 不久以後,咱們又添加了起點登陸、QQ 登陸也由原來騰訊內部的 Wlogin 登陸方式切換到統一互聯登陸。 針對這兩個變更,業務層無任何修改, QRAuthenticatonCenter也只是添加了初始化 QRYWAuthenticatorQROpenQQAuthenticator的代碼。變更的主要工做就是按照 QRAuthenticatorDelegate接口分別去實現 QRYWAuthenticator以及 QROpenQQAuthenticator

經過 Abstract Factory 模式,可使得在添加新登陸方式時QRAuthenticatonCenter也無需修改,但筆者認爲在該場景下其帶來的收益不足以彌補其複雜性,即弊大於利,故棄之。

上述可見,經過 Strategy 模式重構後的登陸模塊實現了 OCP,也在後續迭代變動過程當中充分享受了其帶來的收益。

例2 QQ 閱讀引擎模塊

QQ 閱讀的 txt 引擎是整個工程裏面最核心,也是最古老的一個模塊。 起初,引擎裏面有兩種類型的段落:文字、空段落,並經過一個int型變量type加以表示。 隨着迭代,愈來愈多非內容自己的交互性元素加入閱讀頁,如:做者的話、大神說等等。目前type的值已擴展到十5、六類之多,每添加一種新類型都要在最核心的引擎裏面修改1、二十處,可謂如覆薄冰。 這就是一個嚴重違反 OCP,併產生嚴重後果的例子。 找到了問題所在,重構方案也就變得明瞭:經過 Strategy 模式,將每種類型段落的邏輯抽取成一個類,並遵照相同的接口,txt 引擎依賴抽象接口,使之遵照 OCP。

Liskov 替換原則『LSP』


LSP:子類型必須可以替換其基類型。 直白點,就是任何使用基類類型的地方(如調用方法時的入參)都能替換成其子類類型,而不會出現意想不到的錯誤。 看完 LSP 的定義,不由要問:其有何用? 爲了回答這個問題,不防從反面思考一下:若不遵照 LSP 如何? 以方法參數爲例:若方法 M 有一個類型爲類 B 的參數,若是類 B 的子類沒有遵照 LSP,在調用方法 M 時傳入了一個類 B 的子類,M 會出錯。此時,爲了避免出錯,方法 M 勢必要對 B 的子類做特殊處理(if...else...)。 熟悉的味道!這是否是違反了 OCP!

引自《敏捷軟件開發》:對於 LSP 的違反每每會致使以明顯違反 OCP 的方式使用運行時類型識別『RTTI』。

例1 正方形與長方形

Bob 大叔在《敏捷軟件開發》中有一個關於正方形和長方形的例子。 『正方形是一種特殊的長方形』,這可謂是常識。所以,讓正方形類Square繼承自長方形類Rectangle再合理不過。 然而在對待長度、寬度上,正方形與長方形彷佛不那麼一致: 正方形的長、寬必須相等,所以Square類必須重寫其基類RectanglesetWidthsetHeight方法來保證每次調用這兩個方法後正方形的長寬依然相等。這看上去彷佛也並沒有不妥,然而在下面這個方法中就有問題了:

void g(Rectangle &r) {
    r.setWidth(5);
    r.setHeight(4);
    assert(r.area() == 20);
}
複製代碼

函數g對於長方形的認知徹底正確,然而若調用函數g時傳入的是個Square類型的引用,就出錯了! 很明顯,SquareRectangle間的繼承關係違反了 LSP。

引自《敏捷軟件開發》:LSP 讓咱們得出一個很是重要的結論:一個模型,若是孤立地看,並不具備真正意義上的有效性。模型的有效性只能經過它的客戶程序來表現。 在考慮一個特定設計是否恰當時,不能徹底孤立地來看這個解決方案。必需要根據該設計的使用者所作出的合理假設來審視它。

所以,是否違反 LSP,在很大程度上取決於客戶程序。

SquareRectangle間的繼承之因此會違反 LSP,是由於在設置長、寬的行爲上它們間不具有"IS-A"關係。

引自《敏捷軟件開發》:從行爲方式的角度來看,Square不是Rectangle,對象的行爲方式纔是軟件真正所關注的問題。LSP 清楚地指出,OOD 中 IS-A 關係是就行爲方式而言的,行爲方式是能夠進行合理假設的,是客戶程序所依賴的。

LSP 與多態

討論 LSP 的前提就是多態,不然無從談起。 然而,多態本質上就是子類的方法覆蓋基類的虛函數。這與 LSP 要求的子類能夠替換基類是否矛盾?由於經過基類指針最終調用的是子類的方法。 答案自是不矛盾,相反 LSP 可以更好地指導咱們如何使用繼承。 爲了知足 LSP,子類只能對基類的功能進行擴展,而不能『篡改』。 這不正是『繼承』的本質內涵嗎!

所以,LSP至少有三點做用:

  • 實現 OCP 的重要保障之一;
  • 下降繼承帶來的複雜度,繼承只能擴展基類的功能,而非『篡改』(能夠無差異的對待基類及其全部子類);
  • 在決定使用繼承前,能夠更好地判別二者是否真具備"IS-A"的關係。

啓發式判斷規則與改進方案

LSP 有時是很微妙的,在開發過程當中每每難於察覺。 Bob 大叔提出兩個啓發式規則供你們參考:

  • 派生類存在退化函數,以下述代碼基類Base中的方法f是有功能的,但到其子類Derivedf退化爲空方法,這每每預示違反了 LSP,值得警戒:
public class Base {
    public void f() { /*some code*/ }
}

public calss Derived : Base {
    public void f() {}
}
複製代碼
  • 從派生類中拋出異常,即從派生類的方法中拋出了基類不會拋出的異常,這每每是調用方未曾預料的。

違反 LSP 說明繼承已經不適合了,此時能夠將這對『父子』中公共的代碼提取出來。 以後要麼讓他們成爲『兄弟』,都從提取的代碼派生、要麼以組合的方式集成提取的代碼。

接口隔離原則『ISP』


ISP:不該迫使客戶程序依賴於它們不須要的接口。即,客戶程序依賴的類中不該該含有其不須要的方法,從而下降系統的複雜度,減小類之間的耦合。 相反,若某客戶程序依賴的類含有大量其不須要的方法,而這些方法又是其餘客戶程序所需的,當這些方法因需求須要變化時或須要添加新方法時,勢必會殃及不須要這些方法的客戶程序,從而增長系統的耦合度。 怎麼解決? 固然是『隔離、拆分』接口了! 在支持接口/協議的語言(如Objective-C)中,很好處理,將類的公共方法分解到多個接口中; 而在像 C++ 這樣不支持接口的語言中,可經過多繼承、委託等方式分解接口。

例1 UITableView 之 DataSource、Delegate

iOS 開發對 UITableView 恐是再熟悉不過了,其提供了兩套接口:UITableViewDataSourceUITableViewDelegate。 從場景上說,這兩套接口都是爲 UITableView 提供服務的。 之因此要把它們分開,就是爲了能夠將爲 UITableView 提供數據、處理用戶交互的職責拆分到不一樣的類中。

例2 QQ 閱讀登陸接口

在『OCP』一節,簡要介紹了 QQ 閱讀的登陸模塊,咱們知道具體的登陸細節由QRQQAuthenticatorQRWechatAuthenticator以及QRGuestAuthenticator等處理。這些Authenticator都實現了QRAuthenticatorDelegate接口:

@protocol QRAuthenticatorDelegate <NSObject>

// 主動登陸
- (void)authenticateWithCompletion:(QRAuthenticateCompletion)completion;

// 續期
- (void)refreshTokenWithCompletion:(QRAuthenticateCompletion)completion;
...
@end
複製代碼

然而,對於 QQ 登陸,在沒有安裝 QQ 時,須要QRQQAuthenticator做特殊處理。 因爲這樣的特殊處理只是 QQ 登陸須要,所以把對應的接口放到QRAuthenticatorDelegate中是不合適的。 最終,咱們將其定義爲獨立的接口QRQQManuallyAuthenticationDelegate

@protocol QRQQManuallyAuthenticationDelegate <NSObject>

- (void)manuallyAuthenticateWithAccount:(Account *)account;
- (void)checkVerifyCode:(NSString *)verifyCode account:(Account *)account;

@end
複製代碼

並讓QRQQAuthenticator實現這兩個接口:

@interface QRQQAuthenticator : NSObject<QRAuthenticatorDelegate, QRQQManuallyAuthenticationDelegate>
@end
複製代碼

QRWechatAuthenticatorQRGuestAuthenticator等只需實現QRAuthenticatorDelegate便可。

對於 ISP 你們可能會有疑問:根據 SRP,類的職責應該是單一的,爲什麼須要實現多個接口? 在現實中,確實存在從接口層面內聚性較低的類。如,例2中的QRQQAuthenticator類,正常的登陸、續期須要處理,手動登陸一樣須要處理,在接口上就不具有高內聚的特徵。 ISP 就是用於在此狀況下指導如何拆分接口。

依賴倒置原則『DIP』


在開發中,較大的模塊通常會由幾位同窗協同開發,分工通常會按分層的方式進行。 此時,常常會聽到負責低層模塊的同窗向負責高層模塊的同窗說:『我給你提供了這這幾個方法,代碼已提交,你看一下。』 從 DIP 的角度看,犯了兩個錯誤! 其一,在制定雙方接口上低層模塊起了主導做用;其二,二者間缺乏抽象。 DIP:

  • 高層模塊不該該依賴於低層模塊,兩者都應該依賴於抽象;
  • 抽象不該該依賴於細節,細節應該依賴於抽象。

依賴倒置原則其中的『倒置』強調的就是高層模塊與低層模塊間的關係:高層模塊做爲需求方提出需求(提出接口),低層模塊去實現高層模塊提出的需求(接口)。

爲什麼?

  • 高層模塊不該知道低層模塊的細節;
  • 如果由低層模塊制定接口,極可能情不自禁地將實現細節曝露在接口中,這是咱們不但願看到的。

例1 分頁加載

列表類應用場景模板化一文中,咱們提到『大多數 App 的大多數應用場景都是列表類的』,分頁加載是列表類應用場景的標配。 那麼在制定接口時,若由低層模塊(Model)負責,極可能會將分頁的細節曝露在接口中:

- (void)requestMoreDataWithPageStamp:(NSInteger)pageStamp completion:(void (^)(NSError *, id))completion;
複製代碼

很明顯,pageStamp是 Model 與服務端交互的細節,是高層模塊不關心,也不該關心的問題。 如果由高層模塊(Controller)提出需求(接口),接口可能會是這樣:

- (void)requestMoreDataWithCompletion:(void (^)(NSError *, id))completion;
複製代碼

固然,這個例子較簡單,稍有經驗的開發人員也不會在接口中曝露pageStamp信息。 但,由低層模塊制定接口會曝露細節的問題值得關注。

例2 經過抽象解耦高、低層模塊

同時,DIP 提出高層模塊與低層模塊不能直接有依賴關係,它們都應依賴於抽象(接口)。 如此可以使得高層模塊與低層模塊解耦,促使高層模塊具備更好的可複用性。

上圖是在 『列表類應用場景模板化』一文中介紹的列表類模塊的類圖。 其中,Controller 與 Manager 、Controller 與 Module 間都是面向接口編程(依賴於抽象)。 在 QQ 閱讀中,書籍分爲 txt 和精排兩種格式,它們都支持批量下載。在展現、用戶交互上二者並沒有太大區別,但背後的業務邏輯卻大不相同。 所以,批量下載的 Controller 能夠複用,但 Manager 不可。 經過 DIP 能夠很方便的隔離 Controller 與 Manager,使批量下載的 Controller 在兩種格式間複用。

DIP 能夠說是 SOLID 中實現成本最小的原則,但其帶來的收益卻十分可觀,所以,DIP 應該是咱們自始至終都應遵照的原則。

小結


綜觀 S、O、L、I、D 五大原則,本質上它們都是幫助咱們下降軟件系統的複雜度。只不過,各自關注的維度不一樣:

  • SRP:要求軟件實體(模塊、類、方法)只有單一的職責,下降實體的複雜度,提升實體的內聚性;
  • OCP:要求軟件實體對擴展開放、對修改封閉,使得軟件系統在擴展功能時,減小對系統已有部分的影響;
  • LSP:對繼承關係提出要求,子類須可替換基類,下降繼承帶來的複雜度以及減小誤用繼承的可能;
  • ISP:將複雜接口拆分開來,避免強迫高層模塊依賴於其不須要的接口,減小沒必要要的耦合;
  • DIP:避免由低層模塊制定接口時無心曝露低層細節,經過抽象解耦高層與低層模塊。

對於 SOLID 以及其餘的各類設計原則、模式,無須每天掛在嘴邊,而是在遇到問題時,能經過它們解決問題。

參考資料:

《敏捷軟件開發——原則、模式與實踐》

《Design Patterns: Elements of Reusable Object-Oriented Software》

《重構——改善既有代碼的設計》

相關文章
相關標籤/搜索