多態

——“面向對象的三大特性是什麼?”

——“封裝、繼承、多態。”

這大概是最容易回答的面試題了。但是,封裝、繼承、多態到底是什麼?它們在面向對象中起到了什麼樣的作用呢?


多態

多態(Polymorphic)其實也是一個顧名思義的詞:“多態”就是一種事物的“多”種形“態”。

↑ 例如《超能勇士》中的猩猩將軍就有三種形態:原始形態、金屬變體形態、無敵金剛形態。

“多態”這個概念在面向對象中同樣有多種形態:類的多態、方法的多態、以及實例的多態。

類的多態

類的多態最常見、也最好理解。子類繼承了父類(包括實現某個接口)後,父類就有了多種形態:它既可以是父類本身,也可以是它的子類A,還可以是它的子類B、甚至可以是子類A的子類A1……在《繼承》一文中,這種多態的例子比比皆是,這裏就不贅述了。

方法的多態

絕大多數情況下,子類繼承了父類之後都會重寫父類的方法實現。這時,同一個方法就有了多種不同的實現,這就是一種方法的多態。當然,除了重寫之外,還有一種方法的多態叫做“重載”,即通過修改方法的參數列表(參數類型、參數個數等)來爲同一個方法提供多種實現。

不過我個人不太喜歡重載,也不太願意把歸入多態之中。Java以方法名+參數列表作爲一個方法的“簽名”,而重載修改了參數列表,也就修改了方法簽名。這時,重載後的這些方法還能稱爲“同一個方法”的不同實現嗎?此外,Java會在編譯期就確定使用哪個重載方法,但只有到了運行時才能知道使用的是哪個類重寫的方法。也就是說,重載並不具備多態所應有的“在運行時改變程序功能”的作用。套用繼承章節中的一句話來說:如果重載甚至不能“like-a”多態,那還能說它“is-a”多態嗎?

例如,在下面這段代碼中,雖然i_am_a_set實際上是一個Set,並且test(Set<String>)方法也能更精確地匹配到它,但是調用test(i_am_a_set)時,還是執行了test(Collection<String>)方法。

public class OverLoadTest{
    private static void test(Set<String> set){
        System.out.println("set:"+set);
    }
    private static void test(Collection<String> collection){
        System.out.println("collection:"+collection);
    }
    public static void main(String[] args){
        Collection<String> i_am_a_set = new HashSet<>();
        // 這裏還是會調用test(Collection<String>)方法
        test(i_am_a_set);
    }
}

當然,重載不算多態只是我的一家之言,姑妄言之姑妄聽之吧。

實例的多態

實例的多態同樣是一家之言:同一個類擁有多個實例,並且每個實例中的數據各不相同,那麼這就是一種實例的多態。考慮到“對象”不僅指編譯期間的靜態的類、也包括了運行期間的動態的實例,其實實例的多態和類的多態一樣,也是同一個對象的多種形態。從這個角度來看,實例的多態也可以理解爲一種更廣義的多態。

這麼一看,我們每次new一個對象、並對它設置不同的數據,都是一種實例的多態。但是實例的多態還有更強大的作用。雖然“絕大多數情況下,子類繼承了父類之後都會重寫父類的方法實現”,但是在個別情況下,子類並不重寫父類的方法,而只是給父類中的某些字段設置一些不一樣的值。

例如在下面的例子中,爲了讓開發和QA看到不同的儀表盤,我們創建了兩個不同的子類DashBoardServcie4Dev和DashBoardServcie4Qa。它們並沒有重寫父類ServiceDashBoardAsChain的任何方法,只是通過構造函數爲父類字段list設置了不同的值:

class ServiceDashBoardAsChain implements DashBoardServcie{
    protected List<DashBoardServcie> list;
    @Override
    public void fill(DashBoard dashBoard){
        list.foreach(s->s.fill(dashBoard));
    }
}
@Service
class DashBoardServcie4Dev extends ServiceDashBoardAsChain {
    public DashBoardServcie4Dev(@Autowired DashBoardServcie service4Cpu,
                    @Autowired DashBoardServcie service4Memory){
        super();
        list = Arrays.asList(service4Cpu,service4Memory);
    }
}
@Service
class DashBoardServcie4Qa extends ServiceDashBoardAsChain {
    public DashBoardServcie4Qa(@Autowired DashBoardServcie service4Cover,
                    @Autowired DashBoardServcie service4Tests,
                    @Autowired DashBoardServcie service4Sonar){
        super();
        list = Arrays.asList(service4Tests,service4Cover,
            service4Sonar);
    }
}

把對象單純的理解爲編譯期間的靜態的類、而把運行期間的動態的實例拋諸腦後,是出現這種類的主要原因。把這種類的多態轉化成實例的多態,既能減少冗餘的類定義、降低類爆炸的風險,又能提高程序功能的靈活性和擴展性:

class ServiceDashBoardAsChain implements DashBoardServcie{
    @Setter
    private DashBoardServcie list;
    @Override
    public void fill(DashBoard dashBoard){
        list.foreach(s->s.fill(dashBoard));
    }
}
/** 有了這一個類,就可以生成DashBoardServcie4Dev、DashBoardServcie4QA、
 *  DashBoardServcie4Ops、DashBoardServcie4Pm等多個類所需的實例。
 *  用戶還能根據自己的關注點來自定義不同的面板,更加靈活。* */
@Component
class DashBoardServcieFactory{
    /** 單獨注入。其中:
     * CPU --> service4Cpu
     * MEMORY --> service4Memory
     * TEST --> service4Tests
     * COVER --> service4Cover
     * SONAR --> service4Sonar
     * */
    @Resource
    private Map<DashBoardType, DashBoardServcie> dashBoardMap;
    public DashBoardServcie build(DashBoardType... types){
        ServiceDashBoardAsChain chain = new ServiceDashBoardAsChain();
        chain.setList(Stream.of(types)
                .map(dashBoardMap::get)
                .collect(Collector.toList()));
        return chain;
    }
}

顯然,實例的多態都可以轉化爲類的多態;再把重載排除在多態之外的話,出現了方法的多態就一定會出現類的多態。所以,我們後面的討論都圍繞類的多態展開。


多態與面向對象

與封裝、繼承不同,多態不是構成面向對象的要素,而是面向對象自身的特徵。如果說封裝是父親、繼承是母親、面向對象是他們的孩子,那麼多態就是這個孩子身上無窮的生命力和無盡的可能性。

面向對象模擬着現實世界的類型體系創建了對象體系,這套對象體系也就與類型體系有着相同之處。現實世界中的“多態”俯拾皆是,我們所熟知的“生物多樣性”就是一個絕佳的例子。

地球上的生物自誕生之初,就呈現出千人千面的形態。在寒武紀生命大爆炸中出現的三葉蟲,就有諸如萊德利基蟲、球接子、褶頰蟲、鏡眼蟲、小油櫛蟲,大衛奇異蟲、齒肋蟲、裂肋蟲等不同種類;植物登上陸地之後,在石炭紀就演化出了石松類、節蕨植物、真蕨類、種子蕨和裸子植物的參天綠意;時至今日,哪怕是小小的加拉帕戈斯雀,也因身體大小、鳥喙形態的不同而分了十幾種之多。生命就是以這樣的千姿百態,適應了地球上千奇百怪的環境,佔據了從赤道到兩極、從天空到深淵、從雨林到沙漠的每一個角落;更是在歷經五次大滅絕之後,從(最殘酷時)殘存不到5%的物種開始,復甦、生髮、壯大,最終形成了現在這個千嬌百媚的世界。

↑ 左上:部分三葉蟲;右上:部分加拉帕戈斯雀;下:部分石松。

如果用計算機的語言來描述,“地球Online”的這套生態系統,就是靠着生物物種的“多態”特性,滿足了形形色色的“產品需求”。即使是在五次刪庫跑路之後,藉助着“多態”特性,這個系統也能在殘存不到5%的代碼和數據的基礎上,演化出了今天這套“地球Online 6.0”:不僅仍能滿足所有環境和生態位的需求,更是開發出了智能生物,不得不令人嘖嘖稱奇。

面向對象的對象體系在通過繼承來模擬現實中的類型體系的時候,也毫不客氣地把多態特性“順手牽羊”了。實際上,只要有了繼承、只要允許多個子類繼承同一個父類,那麼就自然而然地有了多態特性。由於有了多態特性,對象體系也擁有了“演化”能力,也就是在保持系統和抽象基本穩定的前提下,引入新的功能、結構以滿足新的需求的能力。而這種演化能力,正是系統爲滿足不同的業務需求而必須具備的生命力和可能性。

例如下圖就是我們系統中某個功能模塊的“演化”過程。

↑ 一個功能模塊的演化之路

最初,這個模塊只提供一種功能,只需要ServiceA這一個類就足夠了。後來,我們需要在原有功能的基礎上增加一項新的功能。新功能與原功能大同小異,而且原功能還要保留——調用ServiceA的地方非常多,無論開發還是測試,都不能保證覆蓋到每一個改動點。這時,多態特性就發揮了作用:我們在ServiceA的基礎上擴展出了一個子類ServiceB。所有沿用原功能、調用了ServiceA的地方無需做任何改動;所有需要使用新功能的地方調用ServiceB即可。然而,隨着對業務的深入理解,我們發現ServiceA和ServiceB看似一脈相承、實則南轅北轍。這一點在隨後的需求中就體現了出來:我們需要從ServiceB的功能中細分出一種更新的功能;它與ServiceB還是異曲同工,但與ServiceA已相去甚遠。爲了更好的描述對應這些功能的類之間的關係,我們通過抽取出BaseService類,把ServiceA和ServiceB由父子關係轉變爲兄弟關係。同時再次藉助繼承與多態,在ServiceB的基礎上擴展出子類ServiceC,用以滿足新的業務功能。

人們常說:系統架構不是設計出來的、而是演化出來的。然而,如果沒有多態特性,我們的系統只能一次又一次地“重做”,根本無法演化。


多態與抽象

從抽象的角度來看,多態是什麼呢?首先,抽象的作用在於“隱藏細節”。多態就是它要隱藏的一種細節。例如,我們系統需要根據用戶的位置信息定位到用戶所在的省、市、區縣。顯然的,這個功能可以抽象爲這樣一個LocationService接口:

public interface LocationServcie{
    /** 
     * 根據Location中的經緯度或者ip地址,定位City中的省、市、區縣代碼及名稱。
     *
     * @param loc 經度和維度要麼都有值、要麼都沒有值;不能一個有值、一個沒有值。
     *            經緯度和IP地址至少有一個有值。    
     * @return 如果定位成功,將返回省、市、區縣三級代碼及相應的名稱。    
     *         三級代碼保證非空;如果是在直轄市,三級代碼相同;如果在市區,市、區縣代碼相同。    
     *         如果定位失敗,將返回null。    
     * */
    City locate(Location loc);
}

不過,具體要怎樣定位呢?如果用戶授權我們使用定位信息,那麼我們就可以根據經緯度信息,調用某地圖API來查到地址;然後將地址轉換爲所需的代碼。如果某地圖API出了問題——無論是服務自身還是運營商網絡出了問題——我們都可以更換另一家地圖API來查詢地址。如果所有地圖API都調用失敗、或者用戶壓根就不讓我們使用定位信息,我們還可以使用IP地址進行定位——儘管IP定位並不準確,但是些許聊勝無,至少它可以“儘可能”地保證業務繼續處理下去:

↑ 定位功能模塊的對象體系

但是,對調用方來說,它只需要知道LocationService接口的相關約束:入參要傳入哪些值、出參會返回哪些值,這就夠了。至於這個模塊到底是通過經緯度定位的、還是通過IP地址定位的呢?這個模塊到底是用哪一家的地圖API定位的呢?調用方不需要知道。這就像我們去銀行取錢時,只要輸入正確的密碼、能拿到所需的鈔票就可以了。至於櫃檯後面坐着的是男是女、是老是少、是機器人還是外星人,這不重要。

↑ 不過,如果你不僅想取銀行的錢,還想娶銀行的人,那就另說了。

我們還可以換一個角度來看多態與抽象:多態不僅僅是抽象內部的細節,同時也是實現細節的“最佳實踐”。我們不妨設想一下:如果面向對象不支持多態——例如,一個接口只能有一個實現類、一個抽象類只能有一個子類、不允許非抽象類擁有子類,我們要怎樣實現一個接口呢?

仍以上面的定位功能爲例。如果LocationService接口下只允許有一個實現類,那麼,爲了提供ByMapApiXxx、ByMapApiYyy和ByIp這三種服務,這個碩果僅存的實現類只會有兩種可能的編碼方式:要麼,它對外暴露三個方法、分別提供三種服務,由調用方自己選擇和處理方法調用邏輯;要麼,它仍然只提供一個方法,但是方法內部用if-else等方式把三種調用邏輯“一網打盡”。

我們網上購物湊優惠時常常遇到極其複雜的規則:又要組戰隊、又要每日簽到、又要分享集贊……不僅又囉嗦又麻煩,而且稍不留神就會算錯折扣;好不容易算好了賬,活動方一個規則補丁,又要全部從頭再來。跟直接撒幣發紅包相比,這種所謂的“優惠”實在是費時費力又不討好。

↑ 優惠規則這麼複雜,無怪乎連“你真的會網購嗎”這種文章都一搜一大把。

如果接口使用三個方法來提供三種服務——就像下面這段代碼這樣,那就跟這種毫無誠意的優惠活動差不多:本來簡單明瞭的一件事情,由於接口把內部細節都暴露了出來,使得調用方代碼又囉嗦又容易重複,並且調用方很容易對接口邏輯產生誤解和誤用。不僅如此,接口方法一旦發生變化——尤其是新增或者下線一個定位服務——那麼所有的調用方都要修改代碼。這種“發散變化”是任何一個開發人員都不能接受的。

/** 接口和實現類定義了三個不同的方法 */
public class LcationServiceImpl implements LocationService{
    public City locateByApiXxx(Location loc){...}
    public City locateByApiYyy(Location loc){...}
    public City locateByIp(Location loc){...}
}
public class CityService{
    public void userCity(UserInfo user, Location loc){
        /* 調用方使用時就要這樣寫代碼 */
        City city = locationServcie.locateByApiXxx(loc);
        if(city == null){
            city = locationServcie.locateByApiYyy(loc);
        }
        if(city == null){
            city = locationService.locateByIp(loc);
        }
        if(city != null){
            // 略
        }
    }
}

那麼,在一個方法內用if-else等方式把多種服務“一網打盡”呢?相比提供多個方法,這種方式的確可以更好地保持接口的抽象性和穩定性。但是,這種方式就像是使用了二向箔一樣:它抹平了抽象的層級,把本可以逐層分解的業務複雜性全部堆疊到一層,人爲地推高了代碼複雜性,不僅把代碼變得難以維護,而且把原本簡單的業務也變成了水中花、霧中月,捉摸不透、脆弱不堪。

↑ “等高線圖”就是一種現實中的“二向箔”,它把三維空間中的地形壓縮成了二維平面上的線條,同時抹掉了太多細節。例如,如果要修一條從左到右貫穿上圖的鐵路,只看等高線圖,誰能說出來要鑽幾個隧道、要架幾座橋?

在我們某個系統中,所有的業務功能都是通過if-else來區分處理的。當if-else累積到一定程度之後,出現了一件非常詭異的事情:所有人都說這個系統的業務邏輯很簡單;但所有人都說不出系統中的業務邏輯是怎樣的——哪怕只是一個產品、一種用戶的完整業務都說不出來。爲什麼?因爲這些流程全都散落在系統的if-else裏:這個if裏有一段、那個else裏有一段;這種產品跟那種產品的邏輯糾纏在一起,這類用戶和那類用戶的流程混合在一起。要想把它們挑揀出來、拼湊完整,簡直比從肯德基全家桶裏拼湊出一隻完整的小公雞還要困難。

↑ 要不要挑戰一下,看能拼出幾隻小雞來?

這還只是問題的開始。由於沒有人能說清楚完整的業務邏輯,所以每當產品提出新需求的時候,也就沒有人能說清楚到底要怎麼改,只能通過“扒代碼”來估計改動範圍和開發工作量。但是,就如用歸納法永遠也找不出真理一樣,“扒代碼”永遠也無法明確地告訴你“改動範圍就這麼大”、“工作量就這麼點”。事實上,在開發過程中發現新的改動點、在測試時發現其它功能受到影響,對這個系統來說是家常便飯;相應的,延期、加班、線上bug……也就紛至沓來了。

如果使用多態呢?我曾經用多態的方式,重構過一個類似的系統。重構完成之後,只用一張表格就可以把完整的業務流程、以及不同產品不同用戶所做的特殊操作全部展示出來。在這張表格中,一個新的需求要改什麼、加什麼、刪什麼,全都一目瞭然;改動範圍和工作量也都變得清晰明確了。延期?不存在;加班?沒必要;bug?我們有槍手——“槍手,走遍天下,蚊蟲無憂”哈哈哈。

↑ 業務流程表格大概就是這個樣子的。產品丙是後來新增的需求。從這張表格裏,我們就能看出來系統需要改哪些東西了。

爲什麼使用了多態就能達到這樣的效果呢?因爲多態能夠充分利用抽象的層級特性,從而把紛繁複雜的實現細節分散在不同的抽象層級中。通過這樣的層層分解,我們一定能找到這樣兩個抽象層級:一個既能夠完整的描述業務流程,又不會陷入底層細節中、繞行“山路十八彎”後仍然“雲深不知處”;另一個則把某一類業務的細節描述得纖毫畢現,但對其它類型的業務則“事不關己高高掛起”。

有了第一個抽象層級,我們對業務流程就有了一個清晰而明確的總體認識,對產品需求有哪些改動點、有多少工作量、有哪些潛在風險,自然也就一目瞭然了。這就像遇到了張鬆的劉備一樣,掌握了蜀中的道路、地形、佈防、民情等整體情報後,對如何施行“跨有荊益”這一戰略、入蜀要途徑哪些城池關隘、哪裏可以募兵哪裏可以籌糧等問題自然也就胸有成竹了。有了這樣清晰的戰略部署,成都還不是手到擒來。

↑ 劉備入蜀路線圖。自古“蜀道之難,難於上青天”,沒有清晰的戰略部署,誰敢拿性命去“快速試錯”?

而有了第二個抽象層級,我們的業務流程和代碼模塊就可以充分地解耦合,從而降低彼此之間的牽制和掣肘,從而減少不必要的bug和開發測試工作量。


多態與高內聚低耦合

無論使用多態還是if-else,都可以把抽象下的多種服務聚合到同一個模塊內。但是,多態所提供的低耦合是其它任何方式都無法比擬的。

例如,在我們的系統中,有這樣一段代碼:

public class AuditServiceImpl implements AuditService{
    @Override
    public void audit(Apply apply){
        // 一堆公共邏輯
        // 然後根據產品類型做不同的必填項校驗
        if(apply.getProduct() == ProductA) {
            // 執行ProductA對應的校驗,略
        }else if(apply.getProduct() == ProductB)){
            // 執行ProductB對應的校驗,略
        }else{
            // 執行ProductC對應的校驗,略
        }
        // 又一堆公共邏輯
        // 又根據產品類型組裝不同的數據
        if(apply.getProduct() == ProductB) {
            // 略
        }// else-if,略
        // 還有一堆公共邏輯
        // 再次根據產品類型按不同的邏輯處理返回數據
        if(apply.getProduct() == ProductC) {
            // 略
        }// else-if,略
    }
}

這好像是我們寫業務代碼時最常見的方式:一開始只有ProductA;然後業務上增加了大同小異的ProductB,於是代碼中也在差異化的地方加上if(ProductB);接着又有了ProudctC/ProductD/ProudctE,於是代碼中這個地方加一個if(ProductC),那個地方加一個if(ProductD || ProductE),久而久之,代碼就變成了上面這個樣子。我們經常吐槽說自己系統裏的代碼是“Shit Hill”,其實很多時候,“Shit Hill”就是這麼來的。

“Shit Hill”的問題可謂罄竹難書,模塊間的強耦合就是罪魁禍首之一:從ProductA到ProductE,相關的功能代碼全都雜糅在一起,使得這幾個本應相互獨立的產品和業務之間產生了耦合性最強、也最另令人深惡痛絕的內容耦合。

Content coupling is said to occur when one module uses the code of other module, for instance a branch. This violates information hiding - a basic design concept.內容耦合是指一個模塊直接使用另一個模塊的代碼。這種耦合違反了信息隱藏這一基本的設計概念。

花園的景昕,公衆號:景昕的花園細說幾種耦合

內容耦合使得我們在爲一個產品修改代碼的時候,總會感到“戰戰兢兢,如履薄冰,如臨深淵”,因爲誰也不知道自己改的代碼會不會影響到其它產品的業務。我就曾經在這樣一個方法的第十幾行處加了一行代碼;沒想到在一百多行開外,這行代碼引發了另一個bug。

這種抓狂的感覺……誰寫bug誰知道啊。

要怎樣化解這些問題呢?多態就是一種非常好的方案。例如,我們可以用多態把上面這段代碼改寫成這樣:

abstract class AuditServcieAsSkeleton implements AuditService{
    @Override
    public void audit(Apply apply){
        // 一堆公共邏輯
        // 校驗入參
        doValid(apply);
        // 又一堆公共邏輯
        // 構建請求數據
        Request request = buildRequst(apply);
       // 再來一堆公共邏輯
       // 處理返回數據
       dealResponse(response);
    }
    protected abstract void doValid(Apply apply);
    protected abstract Request buildRequest(Apply apply);
    protected abstract void dealResopsne(Response resp);
}
class AuditServiceAsDispatcher implements AuditService{
    private Map<ProductType, AuditService> dispatcher;
    @Override
    public void audit(Apply apply){
        dispatcher.get(apply.getProductType())
                  .audit(apply);
    }
}
class AuditService4ProductA extends AuditServiceAsSkeleton{
    @Override
    protected void doValid(Apply apply){
        // 產品A的校驗邏輯
    }
    @Override
    protected Request buildRequest(Apply apply){
        // 組裝產品A所需的請求數據
    }
    @Override
    protected void dealResopsne(Response resp){
        // 按產品A的邏輯處理返回結果
    }
}
class AuditService4ProductB extends AuditServiceAsSkeleton{
    // 按產品B的需求處理;略。// 產品C、D、E的類也略。
}

藉助多態方案,ProductA/B/C/D/E的相關代碼被分散到完全獨立的幾個類中,從而把產品功能之間的內容耦合降低爲特徵耦合甚至數據耦合。這樣,無論是哪個產品要修改自己的功能、或者我們要再新增一套新的產品,都可以做到與其它產品毫無瓜葛;從而做到“代碼耦合少,bug遠離我”。

Stamp coupling occurs when modules share a composite data structure and use only parts of it, possibly different parts .特徵耦合是指多個模塊共享一個數據結構、但是隻使用了這個數據結構的一部分——可能各自使用了不同的部分。

花園的景昕,公衆號:景昕的花園細說幾種耦合

Data coupling occurs when modules share data through, for example, parameters. Each datum is an elementary piece, and these are the only data shared (e.g., passing an integer to a function that computes a square root).數據耦合是指模塊間通過傳遞數值來共享數據。傳遞的每個值都是基本數據,而且傳遞的值是就是要共享的值。

花園的景昕,公衆號:景昕的花園細說幾種耦合

不過,使用多態就難免要使用繼承;因而也難免會遇到困擾繼承的子類耦合。但這並不是多態帶來的問題,而是使用繼承所需要特別注意的。

多態

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章