天天看點

跳出面向對象思想(二) 多态簡述多态解決方案什麼時候用多态總結

原文

簡述

多态一般都要跟繼承結合起來說,其本質是子類通過覆寫或重載(在下文裡我會多次用到

覆寫或重載

,我打算把它簡化成

覆重

,意思到就好,不要太糾結這種名詞。)父類的方法,來使得對同一類對象同一方法的調用産生不同的結果。這裡需要辨析的地方在:同一類對象指的是繼承層級再上一層的對象,更加泛化。

舉個例子:

Animal -> Cat
Animal -> Dog

Animal.speak()  // I'm an Animal
Cat.speak()     // I'm a Cat
Dog.speak()     // I'm a Dog
           

此處

Cat

Dog

雖然不是同一種對象,但它們算是同一類對象,因為他們的父類都是

Animal

的表達可能不是很對,其實我也不知道誰更大一點,在文章中我打算用這樣的符号來表示兩者差別:

^

^^

^ 表示他們是同一類
^^ 表示他們同種同類

Animal -> Cat
Animal -> Dog

Cat kitty, kate
Dog lucky, lucy

我們可以這麼說:

    kitty ^^ kate       同種同類,他們都是貓
    kitty ^ lucy        同類不同種,他們都是Animal
    kitty !^^ lucy      因為kitty是貓,lucy是狗
    kitty ^ kate        他們當然同種啦,都是Animal
           

應該算是能夠描述清楚了吧?嗯,我們開始了。

多态

一般來說我們采用多态的場景還是很多的,有些在設計的時候就是用于繼承的父類,希望子類覆寫自己的某些方法,然後才能夠使程式正常運作下去。比如:

BaseController需要它的子類去覆寫loadView等方法來執行view的顯示邏輯
BaseApiManager需要它的子類去覆寫methodName等方法來執行具體的API請求
           

以上是我列舉的應用多态的幾個場景,在基于上面提到的需求,以及站在代碼觀感的立場,我們在實際采用多态的時候會有下面四種情況:

  1. 父類有部分public的方法是不需要,也不允許子類覆重
  2. 父類有一些特别的方法是必須要子類去覆重的,在父類的方法其實是個空方法
  3. 父類有一些方法是可選覆重的,一旦覆重,則以子類為準
  4. 父類有一些方法即便被覆重,父類原方法還是要執行的

這四種情況在大多數支援多态的語言裡面都沒有做很好的原生限制,在程式規模逐漸變大的時候,會給維護代碼的程式員帶來各種各樣的坑。

對于客戶程式員來說,他們是有動機去覆重那些不需要覆重的方法的,比如需要在某個方法調用的時候做UserTrack,或者希望在方法調用之前做一些額外的事情,但是又找不到外面應該在哪兒做,于是就索性覆重一個了。這樣做的缺點在于

使得一個對象引入了原本不屬于它的業務邏輯

。如果在引入的這些額外邏輯中又對其他子產品産生依賴,那麼這個對象在将來的代碼複用中就會面臨一個艱難的選擇:

  • 是把這些不必要的邏輯删幹淨然後移過去?
  • 還是是以把依賴連帶着這個對象一起copy過去?

前者太累,後者太蠢。

如果是要針對原來的對象進行功能拓展,但拓展的時候發現是需要針對

原本不允許覆重

的函數進行操作,那麼這時候就有理由懷疑父類當初是不是沒有設計好了。

這非常常見,由于邏輯的主要代碼在父類中,若要跑完整個邏輯,則需要調用一些特定的方法來基于不同的子類獲得不同的資料,這個特定的方法最終交由子類通過覆重來實作。如果不在父類裡面寫好這個方法吧,父類中的代碼在執行邏輯的時候就調用不到。如果寫了吧,一個空函數放在那兒十分難看。

也有的時候客戶程式員會不知道在派生之後需要覆重某個方法才能完成完整邏輯,因為空函數在那兒不會導緻warning或error,隻有在發現程式運作結果不對的時候,才會感覺哪兒有錯。如果這時候程式員發現原來是有個方法沒覆重,一定會拍桌子罵娘。

總結一下,其實就是代碼不好看,以及有可能忘記覆重。

這是大多數面向對象語言預設的行為。設計可選覆重的動機其中有一個就是可能要做攔截器,在每個父類方法調用時,先調一個willDoSomething(),然後調用完了再調一個didFinishedSomething(),由子類根據具體情況進行覆重。

一般來說這類情況如果正常應用的話,不會有什麼問題,就算有問題,也是前面提到的

容易使得一個對象引入原本不屬于它的業務邏輯

這個是經典的坑,尤其是傳遞給客戶程式員的時候是以連結庫的模式傳遞的。父類的方法是放在覆重函數的第一句調用呢還是放在最後一句調用?這是個值得深思的問題。更有甚者索性就直接忘記調用了,各種傻傻分不清楚。

解決方案

面向接口程式設計(Interface Oriented Programming, IOP)是解決這類問題比較好的一種思路。下面我給大家看看應該如何使用IOP來解決上面四種情況的困境:

(示例裡面有些表達的約定,可以在

這裡

看完整的上下文規範。)

<ManagerInterface> : APIName()                我們先定義一個ManagerInterface接口,這個接口裡面含有原本需要被覆重的方法。
<Interceptor> : willRun(), didRun()         我們再定義一個Interceptor的接口,它用來做攔截器。

BaseManager.child<ManagerInterface>         在BaseController裡面添加一個property,叫做child,這就要求這個child必須要滿足<ManagerInterface></ManagerInterface>這個接口,但是BaseManager不需要滿足<ManagerInterface>這個接口。

BaseManager.init() {

    ...

    self.child = self                       在init的時候把child設定成自己

    # 如果語言支援反射,那麼我們可以這麼寫:
    if self.child implemented <ManagerInterface> {
        self.child = self
    }
    # 如上的寫法就能夠保證我們的子類能夠基于這些接口有對應的實作

    self.interceptor = self                 # interceptor可以是自己,也可以在初始化的時候設為别的對象,這個都可以根據需求不同而決定。

    ...

}

BaseManager.run() {

    self.interceptor.willRun()

    ...

    apiName = self.child.APIName()          # 原本是self.APIName(),然後這個方法是需要子類覆重的,現在可以改為self.child.APIName()了,就不需要覆重了。
    request with apiName

    ...

    self.interceptor.didRun()

}
           

通過引入這樣面向接口程式設計的做法,就能相對好地解決上面提到的困境,下面我來解釋一下是如何解決困境的:

由于子類必須要遵從

<ManagerInterface>

,架構師可以跟客戶程式員約定

所有的public方法在一般情況下都是不需要覆重的

。除非特殊需要,則可以覆重,其他情況都通過實作接口中定義的方法解決。由于這是接口方法,是以

即便引入了原本不需要的邏輯,也能很容易将其剝離

因為引入了

child

,父類不再需要擺一個空方法在那兒了,直接從

child

調用即可,因為

child

是實作了對應接口的,是以可以放心調用。空方法就消滅了。

我們可以通過在接口中設定哪些方法是必須要實作,哪些方法是可選實作的來處理對應的問題。這本身倒不是缺陷,正是多态希望的樣子。

由于我們通過接口規避了多态,那麼這些其實是可以通過在接口中定義

可選方法

來實作的,由父類方法調用

child

可選方法

,調用時機就可以由父類決定。這兩個方法不必重名,是以也不存在多态時,不能分辨調用時機或是否需要調用父類方法的情況。

總結一下,通過IOP,我們做好了兩件事:

  1. 将子類與可能被子類引入的不相關邏輯剝離開來,提高了子類的可重用性,降低了遷移時可能的耦合。
  2. 接口實際上是子類頭上的金箍,規範了子類哪些必須實作,哪些可選實作。那些不在接口定義的方法清單裡的父類方法,事實上就是不建議覆重的方法。

什麼時候用多态

由于多态和繼承緊密地結合在了一起,我們假設父類是架構師去設計,子類由客戶程式員去實作,那麼這個問題實際上是這樣的兩個問題:

  1. 作為架構師,我何時要為多态提供接入點?
  2. 作為客戶程式員,我何時要去覆重父類方法?

這本質上需要程式員針對對象建立一個

角色

的概念。

舉個例子:當一個對象的主要業務功能是搜尋,那麼它在整個程式裡面扮演的角色是搜尋者的角色。在基于搜尋派生出的業務中,會做一些跟搜尋無關的事情,比如搜尋後進行人工權重重排清單,搜尋前進行關鍵詞分詞(假設分詞方案根據不同的派生類而不同)。那麼這時候如果采用多态的方案,就是由子類覆重父類關于重排清單的方法,覆重分詞方法。如果在編寫子類的程式員忘記這些必要的覆重或者覆重了不應該覆重的方法,就會進入上面提到的四個困境。是以這時候需要提供一套接口,規範子類去做覆重,進而避免之前提到的四種困境:

Search : { search(), split(), resort()}
采用多态的方案:
Search -> ClothSearch : { [ Search ], @split(), @resort() }

function search() {

    ...

    self.split()    # 如果子類沒有覆重這個方法而父類提供的隻是空方法,這裡就很容易出問題。如果子類在覆重的時候引入了其他不相關邏輯,那麼這個對象就顯得不夠單純,角色複雜了。

    ...

    self.resort()

    ...

}

采用IOP的方案:
<SearchManager> : {split(), resort()}
Search<SearchManager> : { search(), assistant<SearchManager> }      # 也可以是這樣:Search : { search(), assistant<SearchManager> },這麼做的話,則要求子類必須實作<SearchManager>

function search() {

    ...

    self.assistant.split()  # self.assistant可以就是self,也可以由初始化時候指定為其他對象,将來進行業務剝離的時候,隻要将assistant裡面的方法剝離或者講assistant在初始化時指定為其他對象也好。

    ...

    self.assistant.resort()

    ...

}

Search -> ClothSearch<SearchManager> : { [ Search ], split(), resort() }    # 由于子類被接口要求必須實作split()和resort()方法,因而規避了前文提到的風險,在剝離業務的時候也能非常友善。

外面使用對象時:ClothSearch.search()
           

如果示例中不同的子類對于search()方法有不同的實作,那麼這個時候就适用多态。

Search : { search() }

ClothSearch : { [Search], @search() }

此時适用多态,外面使用對象時:ClothSearch.search()
           

總結是否決定應當使用多态的兩個要素:

  • 如果引入多态之後導緻對象角色不夠單純,那就不應當引入多态,如果引入多态之後依舊是單純角色,那就可以引入多态
  • 如果要覆重的方法是角色業務的其中一個組成部分,例如split()和resort(),那麼就最好不要用多态的方案,用IOP,因為在外界調用的時候其實并不需要通過多态來滿足定制化的需求。

其實這是一個

角色

問題,越單純的角色就越容易維護。還有一個就是區分被覆重的方法是否需要被外界調用的問題。好了,現在我們回到這一節前面提出的兩個問題:何時引入接入點和何時采用覆重。針對第一個問題架構師一定要厘清楚

角色

,在保證

角色

單純的情況下可以引入多态。另外一點要考慮

被覆重的方法是否需要被外界使用

,還是隻是父類運作時需要子類通過覆重提供中間資料的。如果是

隻要子類通過覆重提供中間資料的,一律應當采用IOP而不是多态

針對第二個問題,在必須要覆重的場合下就采取覆重的方案好了,主要是可覆重可不覆重的情況下,客戶程式員主要還是要遵守:

  • 覆重的方法本身是跟邏輯密切相關的,不要在覆重方法裡做跟這個方法本意不相關的事情
  • 如果要覆重一系列的方法,那麼就要考慮角色問題和外界是否需要調用的問題,這些方法是不是這個對象的角色應當承擔的任務

比如說不要在一個原本要跑步的函數裡面去做吃飯的事情,如果真的要吃飯,父類又沒有,實在不行的時候,

就需要在覆重的方法裡面啟用IOP,在子類裡面彌補架構師的設計缺陷

。把這個不屬于跑步的事情IOP出去,負責實作對應接口的可以是self,也可以是别人。隻要不是強耦合地去覆重,這樣在代碼遷移的時候,由于IOP的存在,使得代碼接收方也可以接受并實作對應的interface,進而不影響整體功能,又能提供遷移的靈活性。

總結

多态在面向對象程式中的應用相當廣泛,隻要有繼承的地方,或多或少都會用到多态。然而多态比起繼承來,更容易被不明不白地使用,一切看起來都那麼順其自然。在客戶程式員這邊,一般是隻要多态是可行方案的一種,到最後大部分都會采用多态的方案來解決問題。

然而多态正如它名字中所暗示的,它有非常大的潛在可能引入不屬于對象初衷的邏輯,巨大的靈活性也導緻客戶程式員在面對問題的時候不太願意采用其他相對更優的方案,比如IOP。在決定是否采用多态時,我們要有一個清晰的

角色

概念,做好角色細分,不要角色混亂。該是攔截器的,就給他制定一個攔截器接口,由另一個對象(邏輯上的另一個對象,當然也可以是自己)去實作接口裡的方法集。不要讓一個對象在邏輯上既是攔截器又是業務子產品。這樣才友善未來的維護。另外也要注意被覆重方法的作用,如果隻是單純為了提供父類所需要的中間資料的,

一律都用IOP

,這是比直接采用多态更優的方案。

IOP能夠帶來的好處當然不止文中寫到的這些,它在其他場合也有非常好的應用,它最主要的好處就在于分離了定義和實作,并且能夠帶來更高的靈活性,靈活到既可以對語言過高的自由度有一個限制,也可以靈活到允許同一接口的不同實作能夠合理地組合。在架構設計方面是個非常重要的思想。

繼續閱讀