天天看點

[5+1]裡氏替換原則(二)

前言

面向對象的SOLID設計原則,外加一個迪米特法則,就是我們常說的5+1設計原則。

[5+1]裡氏替換原則(二)

↑ 五個,再加一個,就是5+1個。哈哈哈。

這六個設計原則的位置有點不上不下。論原則性和理論指導意義,它們不如封裝繼承抽象或者高内聚低耦合,是以在寫代碼或者code review的時候,它們很難成為“應該這樣做”或者“不應該這樣做”的一個有說服力的理由。論靈活性和實踐操作指南,它們又不如設計模式或者架構模式,是以即使你能說出來某段代碼違反了某項原則,常常也很難明确指出錯在哪兒、要怎麼改。

是以,這裡來讨論讨論這六條設計原則的“為什麼”和“怎麼做”。順帶,作為面向對象設計思想的一環,這裡也想聊聊它們與抽象、高内聚低耦合、封裝繼承多态之間的關系。

看上一篇請點我。

裡氏替換原則

裡氏替換與面向對象

“結果導向”和“過程管控”是項目管理的常見思路。

以産品需求為例。

從結果導向的角度來看,我們隻要實作了需求中提出的業務功能就大功告成了。至于用了哪些技術、做了什麼設計,其實無關緊要。

而從過程管控的角度來看,我們不僅要對最終結果負責,還要對項目進度、方案細節、裡程碑産物等過程負責。

顯然,二者是對立統一的。片面強調某一方面、忽略另一方面的作用,都會給項目帶來不必要的問題。隻有“兩手抓”,才能“兩手都硬”。

裡氏替換原則就是這兩種項目管理思想在面向對象設計中的展現。

從結果導向的角度來看,我們隻要處理好了接口定義的入參、出參就大功告成了。至于接口内是一個“上帝實作類”、還是模闆類-實作類、或者是代理類-實作類……其實無關緊要。

而從過程管控的角度來看,我們不僅要對最終接口負責,還要對類的層級結構、代碼品質、可讀性與可維護性等過程負責。

二者在裡氏替換原則中得到了統一。

裡氏替換原則要求子類不改變父類的行為,本質上就是要求同樣的參數得到同樣的結果。但它并不在意功能的實際實作者到底是父類還是子類。這不就是“隻要求正确實作功能,并不在意代碼是老王來寫還是小李來寫”麼?不就是結果導向的思維方式麼?

同時,為了保證子類和父類都能得到同樣的結果,裡氏替換原則對“子類繼承父類”這一操作提出了很多要求。子類不能重寫父類的非抽象方法、但可以增加自己的處理方法等等,這都是過程管控的具體措施。

把結果導向與過程管控結合起來,項目管理就能做得有聲有色了。同樣的,在這兩種思維方式的幫助下,面向對象也能做得風生水起。而裡氏替換原則,正是讓面向對象變得風生水起的不二法門。

裡氏替換與抽象

我們反複提到,面向對象中的抽象必須保持穩定,不能朝令夕改。大多數語境下,我們都在談論編譯期的代碼穩定性,如保持方法簽名不變、入參出參類型不變等。裡氏替換原則提出了一種更高的穩定性标準:運作期的功能穩定性。

如果我們破壞了編譯期穩定性,例如增加一個方法參數,在代碼編譯時,Java就會給出Error警報。但是,如果我們破壞了運作期穩定性,沒有誰會對着我們的耳朵吼“這個地方有問題”——即使我們做了很多單元測試、內建測試,也很容易遺漏問題。

例如,我們的某個系統使用了SpringBatch+HIbernate來做批處理操作。為了簡化代碼和配置,有位同僚把本應放在Processor中的代碼放到了一個自定義的Writer中:

按照這位同僚的設想,盡管UpdateOverDueDaysWriter這個類并沒有顯式地更新資料庫,但是,在r.setOverDueDays(overDueDays)之後,Hibernate的事務管理器應該可以自動調用Session.flush(),進而把overDueDays的新值更新入庫。畢竟,它的父類HIbernateItemWriter就實作了這個功能嘛。是以,雖然不太符合SpringBatch的規範,但這個類的功能應該是沒問題的。

我用了兩個“應該”——“應該可以”、“應該沒問題”。事實上,這兩個“應該”全都落空了:資料庫中的overDueDays一直沒有被更新。

這是為什麼呢?

我們來看一下父類HibernateItemWriter的關鍵源代碼:

注意我加了注釋的那一行。HibernateItemWriter在這裡顯式的調用了Session.flush()方法,而不是交給Hibernate的事務管理機制去處理。雖然不太确定為什麼,不過這是一個重要的提示:Session.flush()方法不是由HIbernate事務管理器自動調用的,而需要代碼顯示調用,以保證将HIbernate Session中的資料更新到資料庫中去。

但是,我們的子類UpdateOverDueDaysWriter在重寫HIbernateItemWriter.wite()方法時,雖然沒有變更接口、方法簽名或傳回值類型,但父類方法中調用Session.flush()的代碼,卻被子類完全抛棄:子類重寫并改變了父類方法的功能,導緻資料無法更新入庫。

換句話說,子類UpdateOverDueDaysWriter在重寫HIbernateItemWriter.wite()方法時,違反了裡氏替換原則,破壞了write()方法的功能穩定性,最終導緻了功能缺失,産生了線上bug。

更令人後怕的是,我們的代碼編譯、靜态檢查、代碼審查、單元測試、QA測試、UAT測試以及線上部署都沒有發現這個問題,因為與這個批處理任務同時啟動的另一個批處理任務也更新了這個字段——後者是實實在在地更新入庫了。直到兩年後,第二個批處理任務功德圓滿、删代碼下線了,我們才發現:為什麼兩天過去了,overDueDays字段還沒被更新?

幸運的是,事發兩天我們就發現并解決了這個問題。如果線上bug發生在國慶或春節期間,其後果簡直不堪設想。

這就是破壞功能穩定性的可怕之處:我們沒有什麼辦法可以保證在發生線上故障之前發現問題。事實上,裡氏替換法則也無法解決這個問題,是以它換了一種思路:變事後修複為事前預防。我們甚至可以說,抽象的功能穩定性,與它遵守裡氏替換原則的嚴格程度是成正比的。

裡氏替換與高内聚低耦合

裡氏替換原則與高内聚、低耦合并沒有什麼很強的關聯。遵循裡氏替換原則,我們也有可能寫出低内聚、高耦合的代碼來。

不過,裡氏替換原則要求我們更加深入地審視類之間的層級關系,把代碼和功能放到更恰當的位置上去。這樣做了之後,通常我們都能得到更加高内聚低耦合的類。

例如,假定我們已有這樣一個類:

當我們需要增加一種新業務時,可以簡單地增加一個子類:

從高内聚低耦合的角度來說,這樣做雖然比if-else的方式更好一些,還是“猶有未樹也”:SomeService與OtherService之間,産生了不必要的子類耦合。如果SomeService出于自己的業務原因,修改了部分代碼,那麼OtherService也要受到影響。

這兩個類顯然違反了裡氏替換原則。子類OtherService重寫了父類已實作的方法valid()和doService()。如果我們希望給兩個方法同時增加一條校驗規則,顯然,光修改父類SomeService是無濟于事的。

遵循裡氏替換原則的指導,我們可以把類結構調整成這樣:

這樣的類層級結構更符合裡氏替換原則的要求;同時,SomeService與OtherService之間的耦合度也更低了:除了增加代碼量之外,可謂皆大歡喜。

裡氏替換與封裝繼承多态

裡氏替換原則與繼承和多态的關系毋庸多言:它可謂是繼承和多态的“最佳實踐”。但是它與封裝之間的關系,就不是那麼顯而易見了。

當說到“封裝”時,通常我們都會想到public/protected/private等可見性修飾符。其實,它們隻是封裝的工具、而非封裝本身——就像禅宗法師們說的那樣,這隻是“成佛之路”,而非“成佛之事”。

對“封裝”來說,所謂“成佛之事”就是一“封”和一“裝”:專屬于一個類的,就“封”在這個類裡;從屬于一個類的,就“裝”在這個類裡。隻要做到了這兩點,就做到了“封裝”。

那麼,裡氏替換原則把什麼東西“封”起來了呢?答案就是“父類中的非抽象方法”。即使這個非抽象方法的修飾符是public、default或者protected,即使這個非抽象方法不是final方法,裡氏替換原則也禁止子類重寫它。這難道不也是一種“封”麼?

裡氏替換原則是一個關于“封”的原則,同時也是一種“裝”的原則。它規定了子類不能重寫父類的非抽象方法,同時也就規定了:如果一個類中的非抽象方法被子類重寫了,那麼這個方法就不應該放在目前類中。我們應該定義一個新的類,以便“裝”下這個方法的抽象定義;同時,讓原有的兩個類繼承這個新的類,以便“裝”下抽象方法的兩種不同實作。

但是,對裡氏替換原則來說,無論是“封”還是“裝”,基本都隻能靠人進行規範,而難以借助文法、編譯器等工具來做限制。這大概是裡氏替換原則在實踐中較少被提及的又一個原因。

裡氏替換與其它設計原則

裡氏替換與單一職責

明白了裡氏替換原則與封裝之間的關系,其實也就厘清了裡氏替換原則與單一職責原則之間的關系:當我們把一個方法由子類提升到父類中,或者兩個類由父子類轉為兄弟類時,我們不僅遵循了裡氏替換原則,同時也遵循了單一職責原則。

仍以前面的代碼為例。當我們的子類重寫了父類中的非抽象方法時,它們的類結構如下圖所示:

[5+1]裡氏替換原則(二)

在上面這種結構中,ClassA承擔了兩種職責:它自身的業務功能,以及為ClassB定義流程模闆的職責。而ClassB同樣也承擔了兩種職責:它自身的業務功能,以及ClassA中的其它業務功能。顯然,它倆都承擔了一些不屬于自身的功能職責。因而,這兩類不僅不符合裡氏替換原則,也不符合單一職責原則。

如果我們按照裡氏替換原則的要求,把上述類結構改造成這樣:

[5+1]裡氏替換原則(二)

改造之後,ClassC隻承擔了定義流程模闆的職責,不承擔任何具體的業務功能;ClassA隻承擔了自己的業務功能,不再承擔定義流程模闆的職責;ClassB同樣隻承擔了自己的業務功能,而不再包含ClassA的業務功能。這時,我們就可以說:這三個類不僅遵循了裡氏替換原則,同樣也遵守了單一職責原則。

裡氏替換與開閉

開閉原則要求我們“對新增開放、對修改關閉”。裡氏替換原則把這一原則細化到了父子類之間:我們可以新增繼承層級,也可以新增子類方法,這就是“對新增開放”;但我們不可以修改父類中已實作的方法,這就是“對修改關閉”。

在此前讨論抽象時,我們曾經提到過:抽象是分層次的。通過繼承,我們可以把一個抽象“縱向”劃分為多個層次;借助多态,我們可以在同一個層次内把抽象“橫向”拆分為多個實作類。在這縱橫捭阖之間,如果處理不當,錯綜複雜的父子類耦合會把代碼變成一團亂麻。而借助裡氏替換原則,我們可以如抽絲剝繭般将抽象複雜度逐漸拆分、消弭于無形。

是以,雖然裡氏替換原則有點費解、有點難用,但它确實是一把神兵利器,值得我們花點時間去掌握它。

當然,處理抽象複雜度,我們也有其它辦法,例如把一個複雜抽象拆分為多個簡單抽象。不過,這就是下一章節——接口隔離原則——的内容了。是以,我們下回分解吧。

[5+1]裡氏替換原則(二)

繼續閱讀