- 貫穿設計模式:用一個電商項目詳解設計模式
- 偉山育琪
- 2292字
- 2024-12-28 11:44:55
1.2.3 里氏替換原則
里氏替換原則(Liskov Substitution Principle,LSP)最初由Barbara Liskov在1987年的一次學術會議中提出,里氏替換原則是一種針對子類和父類關系的設計原則。在我們工作和學習過程中,會經常接觸到子類與父類,里氏替換原則早已被我們潛移默化地使用了,并不是什么新鮮事,有基礎的讀者可以快速瀏覽或跳過本小節內容。下面我們對該原則進行更加細致的說明。
(1)子類需要實現父類中所有的抽象方法(為實現“替換”做好準備)。
看到這里的讀者可能會微微一笑,子類不實現父類的抽象方法,連開發工具(如IDEA、Eclipse等)都不同意,開發工具會自動提示我們進行抽象方法的覆寫。那么,為實現“替換”做好準備如何理解呢?
我們先來分別看看ArrayList、LinkedList和AbstractSequentialList的類結構。

我們可以看到,ArrayList和LinkedList都屬于List接口的子類,都屬于AbstractList抽象類的子類(雖然LinkedList中間還有一個AbstractSequentialList的父類,但在整個繼承鏈上依然是AbstractList的子類)?,F在我們基于ArrayList和LinkedList書寫以下代碼來展示“替換”的精妙之處。

通過以上代碼,我們看到了,addElement方法的第一個參數雖然為父類List類型,但可以支持傳入任何List類型的子類型。這并不是什么新的伎倆,我們剛剛接觸JavaSE對象多態的學習中,就可能已經這樣做了,只不過沒有那么高級的修飾(里氏替換原則)詞罷了。
(2)子類可以加入自己的特有方法及屬性。
龍生九子,九子各不同,但都是龍族的血脈。如果子類沒有自己的特色,與父類完全一樣,那我們何必多此一舉進行繼承呢?這部分的知識是非常容易理解的,LinkedList中有addFirst方法而ArrayList卻沒有,這就是LinkedList作為子類的一個獨特屬性。
總以JDK源碼進行說明多少會有些乏味,這次,我們以Spring源碼中BeanFactory為例進行說明。

BeanFactory作為最頂端(最基礎)的父類接口,僅僅包含了對Bean的獲取、類型獲取及判斷等相關方法,隨著我們對Spring的使用越來越深入,這些方法肯定是不能夠滿足我們日常使用的。那么我們來看看作為BeanFactory子類,Spring中Bean加載的核心類——DefaultListableBeanFactory中的方法,如圖1-1所示。

圖1-1
我們可以看到,DefaultListableBeanFactory中有了更加細節的方法,比如方法isPrimary(String,Object),它關注Bean上的@Primary注解;再比如getPriority(String,Object)方法,它關注Bean上的@Priority注解。作為子類的DefaultListableBeanFactory有了自己獨有的方法,為使用者提供了更加廣闊的平臺(很遺憾這不是一本解讀Spring源碼的書籍,所以不能過多展開Spring源碼的講解,我相信未來會有機會為大家進行Spring源碼的講解)。
(3)關于子類覆蓋父類已實現方法(父類非抽象方法)的討論。
相信部分讀者在一些博客論壇上看到過這樣一句話:“子類覆蓋父類已實現方法,可以放大方法入參的類型”。筆者認為,這句話失之偏頗,并不是對里氏替換原則的正確解讀。為了避免讀者被此觀點誤導,請允許我首先以非源碼的示例對此觀點進行描述,因為筆者翻閱了大量源碼,沒有找到能夠印證此觀點的源碼示例。
一些資料上進行了以下代碼示例。

我們可以看到,子類SubClass的process方法放大了參數類型,采用List接口為入參類型;父類BaseClass的process方法入參為ArrayList類型。當我們運行main函數時,發現無論是父類執行方法還是子類執行方法,得到的結果都是打印了“BaseClass take process!”。按照這個觀點來說,子類對象可以完美地“替換”父類對象,而不會導致結果的改變。乍一看,好像很有道理,沒什么問題,可是大家有沒有想過以下問題。
· 我們new SubClass()對象是為了什么?不就是希望使用SubClass中的方法嗎?然而因為參數的放大,即便是使用SubClass對象調用方法,還是依然會執行父類的邏輯,這叫“替換”嗎?
· 我們在SubClass對象中創建同樣的方法是為了什么?不就是為了能夠與父類BaseClass的方法執行邏輯有所不同嗎?然而因為參數的放大,SubClass中的方法無法執行,總是執行父類BaseClass的方法,這叫“替換”嗎?
· 我們為什么要在子類里放大參數類型?正確的父子關系,不都是應該父類采用范圍更大的類型(泛型T),然后子類定義確切的類型嗎?難道僅僅是為了展現所謂的“替換”思想刻意為之嗎?既然使用子類對象調用方法,就是為了使用子類的方法,而不是為了達到所謂的“替換”,進而埋沒了子類的方法。而現實開發之中,我們真正創建對象的時候,也都是創建的子類對象,創建List對象,大部分會選用子類new ArrayList(),而不是創建父類對象。
以上的代碼示例,僅僅是一個方法“重載”(方法名稱一致,返回值一致,方法入參不一致)的障眼法,為什么兩次打印結果一致的根本原因是方法入參都是new ArrayList()呢?在JVM中,“重載”是“靜態分派”的經典實現,方法參數的“靜態類型”在編譯期已經確定了,由于“靜態類型”在編譯期可知,所以在編譯階段,Javac編譯器就會根據參數的“靜態類型”決定了使用哪個重載版本,所謂的子類參數類型放大而演示出來的效果,無非就是借助了JVM的“靜態分派—重載”。
那我們應該如何覆寫父類中已經實現的方法呢?很簡單,使用@Override,保持方法名稱、返回值和方法參數一致即可。就好比我們在覆寫Object類中的hashCode方法,代碼如下:

此外,我依然想為讀者引入Spring源碼的示例,一是為了再次印證如何正確地覆寫父類方法,二是為了盡最大可能為讀者提供更多的擴展內容。我們來看看Spring容器對BeanDefinition注冊的設計。
首先定義了父類interface BeanDefinitionRegistry,并添加抽象方法void registerBeanDefinition供子類實現,代碼如下:

再來看看Spring容器相關子類GenericApplicationContext對這個方法的實現,直接采用@Override注解進行,對方法整體結構沒有任何修改。除此之外,GenericApplicationContext作為Spring的ApplicationContext相關類,也完美地完成了DefaultListableBeanFactory的初始化工作,通過無參構造進行初始化,成為了連接Bean工廠DefaultListableBeanFactory和BeanDefinition注冊的容器(容器也稱為上下文),代碼如下:

到這里依然沒有結束,上邊的源碼中展現了GenericApplicationContext容器類對Bean工廠的初始化,所有的BeanDefinition最終都會注冊到Bean工廠,那么我們來看看Bean工廠DefaultListableBeanFactory的源碼,依然是使用@Override注解,不改變方法結構,覆寫registerBeanDefinition方法,完成最終BeanDefinition的register,代碼如下:

無論從實際開發角度還是從源碼角度進行印證,正確的子類覆蓋父類非抽象方法的途徑就是在不改變整體方法結構的前提下直接進行@Override。所謂的子類方法擴大方法入參類型,根本沒有任何落地的實際意義。