關於面向對象“多態”的理解


談到多態肯定是和繼承結合在一起的,本質是子類通過覆蓋或重載(覆重)父類的方法,來使得對同一類對象同一方法的調用產生不同的結果。需要辨析的地方即:同一類對象指的是繼承層級再上一層的對象,更加泛化。

 

實際采用多態的時候有四種情況:

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

這四種情況在大多數支持多態的語言里未做很好的原生限制,在程序規模逐漸變大的時候,會給維護代碼的程序員帶來各種各樣的坑。

父類有部分public的方法是不需要,也不允許子類覆重

  對於客戶程序員來說,他們是有動機去覆重那些不需要覆重的方法的,比如需要在某個方法調用的時候做UserTrack,或者希望在方法調用之前做一些額外的事情,但是又找不到外面應該在哪兒做,於是就索性覆重一個了。這樣做缺點在於使得一個對象引入了原本不屬於它的業務邏輯。若在引入的這些額外邏輯中又對其他模塊產生依賴,那么這個對象在將來的代碼復用中就會面臨一個艱難選擇:

  • 是把這些不必要的邏輯刪干凈然后移過去?
  • 還是所以把依賴連帶着這個對象一起copy過去?

前者太累,后者太蠢。

  如果是要針對原來的對象進行功能拓展,但拓展的時候發現是需要針對原本不允許覆重的函數進行操作,那么這時候就有理由懷疑父類當初是不是沒有設計好了。

父類有一些特別的方法是必須要子類去覆重的,在父類的方法其實是個空方法

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

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

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

父類有一些方法是可選覆重的,一旦覆重,則以子類為准

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

  一般來說這類情況若正常應用,不會有什么問題,就算有問題,也是前面提到的容易使得一個對象引入原本不屬於它的業務邏輯

父類有一些方法即便被覆重,父類原方法還是要執行的

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

 


解決方案:

面向接口編程(Interface Oriented Programming, IOP)是解決這類問題比較好的一種思路。

通過IOP,能做好兩件事:

  1. 將子類與可能被子類引入的不相關邏輯剝離開來,提高了子類的可重用性,降低了遷移時可能的耦合。
  2. 接口實際上是子類頭上的金箍,規范了子類哪些必須實現,哪些可選實現。那些不在接口定義的方法列表里的父類方法,事實上就是不建議覆重的方法。
<ManagerInterface> : APIName()        定義一個ManagerInterface接口,這個接口里面含有原本需要被覆重的方法。
<Interceptor> : willRun(), didRun()    再定義一個Interceptor的接口,它用來做攔截器。

BaseManager.child<ManagerInterface>    在BaseManager里添加一個屬性child,這要求這個child必須要滿足<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()
}

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

父類有部分public的方法是不需要,也不允許子類覆重:

  由於子類必須要遵從<ManagerInterface>,架構師可以跟客戶程序員約定所有的public方法在一般情況下都是不需要覆重的。除非特殊需要,則可以覆重,其他情況都通過實現接口中定義的方法解決。由於這是接口方法,所以即便引入了原本不需要的邏輯,也能很容易將其剝離

父類有一些特別的方法是必須要子類去覆重的,在父類的方法其實是個空方法

  因為引入了child,父類不再需要擺一個空方法在那兒了,直接從child調用即可,因為child是實現了對應接口的,所以可以放心調用。空方法就消滅了。

父類有一些方法是可選覆重的,一旦覆重,則以子類為准

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

父類有一些方法即便被覆重,父類原方法還是要執行的

  由於通過接口規避了多態,那么這些其實是可以通過在接口中定義可選方法來實現的,由父類方法調用child可選方法,調用時機就可以由父類決定。這兩個方法不必重名,因此也不存在多態時,不能分辨調用時機或是否需要調用父類方法的情況。

 


 什么時候使用多態?

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

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

 

參考文獻:http://casatwy.com/tiao-chu-mian-xiang-dui-xiang-si-xiang-er-duo-tai.html

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM