1. 程式人生 > >面向對象的JavaScript --- 多態

面向對象的JavaScript --- 多態

cut 之前 告訴 bstr 來看 相對 art 請求 往裏面

面向對象的JavaScript --- 多態


多態

“多態”一詞源於希臘文 polymorphism,拆開來看是poly(復數)+ morph(形態)+ism,從字面上我們可以理解為復數形態。

多態的實際含義是:同一操作作用於不同的對象上面,可以產生不同的解釋和不同的執行結果。換句話說,給不同的對象發送同一個消息的時候,這些對象會根據這個消息分別給出不同的反饋。從字面上來理解多態不太容易,下面我們來舉例說明一下。

? 主人家裏養了兩只動物,分別是一只鴨和一只雞,當主人向它們發出“叫”的命令時,鴨會“嘎嘎嘎”地叫,而雞會“咯咯咯”地叫。這兩只動物都會以自己的方式來發出叫聲。它們同樣“都是動物,並且可以發出叫聲”,但根據主人的指令,它們會各自發出不同的叫聲。

其實,其中就蘊含了多態的思想。

一段多態的JavaScript代碼

var makeSound = function( animal ){
    if ( animal instanceof Duck ){
        console.log( ‘嘎嘎嘎‘ );
    }else if ( animal instanceof Chicken ){
        console.log( ‘咯咯咯‘ );
    }
};

var Duck = function(){};
var Chicken = function(){};

makeSound( new Duck() ); // 嘎嘎嘎
makeSound( new Chicken() ); // 咯咯咯

這段代碼確實體現了“多態性”,當我們分別向鴨和雞發出“叫喚”的消息時,它們根據此消息作出了各自不同的反應。

但這樣的“多態性”是無法令人滿意的,如果後來又增加了一只動物,比如狗,顯然狗的叫聲是“汪汪汪”,此時我們必須得改動 makeSound 函數,才能讓狗也發出叫聲。修改代碼總是危險的,修改的地方越多,程序出錯的可能性就越大,而且當動物的種類越來越多時, makeSound 有可能變成一個巨大的函數。

多態背後的思想是將“做什麽”和“誰去做以及怎樣去做”分離開來,也就是將“不變的事物”與“可能改變的事物”分離開來。在這個故事中,動物都會叫,這是不變的,但是不同類型的動物具體怎麽叫是可變的。把不變的部分隔離出來,把可變的部分封裝起來,這給予了我們擴展程序的能力,程序看起來是可生長的,也是符合開放---封閉原則的,相對於修改代碼來說,僅僅增加代碼就能完成同樣的功能,這顯然優雅和安全得多。

對象的多態性

下面是改寫後的代碼,首先我們把不變的部分隔離出來,那就是所有的動物都會發出叫聲:

var makeSound = function( animal ){
    animal.sound();
};

然後把可變的部分各自封裝起來,我們剛才談到的多態性實際上指的是對象的多態性:

var Duck = function(){}
Duck.prototype.sound = function(){
    console.log( ‘嘎嘎嘎‘ );
};

var Chicken = function(){}
Chicken.prototype.sound = function(){
    console.log( ‘咯咯咯‘ );
};

makeSound( new Duck() ); // 嘎嘎嘎
makeSound( new Chicken() ); // 咯咯咯

如果有一天又增加了一只狗,這時候只要簡單地追加一些代碼就可以了,而不用改動以前的makeSound 函數,如下所示:

var Dog = function(){}
Dog.prototype.sound = function(){
    console.log( ‘汪汪汪‘ );
};

makeSound( new Dog() ); // 汪汪汪

類型檢查和多態

類型檢查是在表現出對象多態性之前的一個繞不開的話題,但JavaScript是一門不必進行類型檢查的動態類型語言,為了真正了解多態的目的,我們需要轉一個彎, 從一門靜態類型語言說起。靜態類型語言在編譯時會進行類型匹配檢查。以Java為例,由於在代碼編譯時要進行嚴格的類型檢查,所以不能給變量賦予不同類型的值,這種類型檢查有時候會讓代碼顯得僵硬,代碼如下:

String str;
str = "abc"; // 沒有問題
str = 2; // 報錯

現在我們嘗試把上面讓鴨子和雞叫的例子換成 Java 代碼:

public class Duck { // 鴨子類
    public void makeSound(){
        System.out.println( "嘎嘎嘎" );
    }
}

public class Chicken { // 雞類
    public void makeSound(){
        System.out.println( "咯咯咯" );
    }
}

public class AnimalSound {
    public void makeSound( Duck duck ){ // (1)
        duck.makeSound();
    }
}

public class Test {
    public static void main( String args[] ){
        AnimalSound animalSound = new AnimalSound();
        Duck duck = new Duck();
        animalSound.makeSound( duck ); // 輸出:嘎嘎嘎
    }
}

我們已經順利地讓鴨子可以發出叫聲,但如果現在想讓雞也叫喚起來,我們發現這是一件不可能實現的事情。因為(1)處 AnimalSound 類的 makeSound 方法,被我們規定為只能接受 Duck 類型的參數。

public class Test {
    public static void main( String args[] ){
        AnimalSound animalSound = new AnimalSound();
        Chicken chicken = new Chicken();
        animalSound.makeSound( chicken ); // 報錯,只能接受 Duck 類型的參數
    }
}

在享受靜態語言類型檢查帶來的安全性的同時,我們亦會感覺被束縛住了手腳。

為了解決這一問題,靜態類型的面向對象語言通常被設計為可以向上轉型:當給一個類變量賦值時,這個變量的類型既可以使用這個類本身,也可以使用這個類的超類。這就像我們在描述天上的一只麻雀或者一只喜鵲時,通常說“一只麻雀在飛”或者“一只喜鵲在飛”。但如果想忽略它們的具體類型,那麽也可以說“一只鳥在飛”。

同理,當 Duck 對象和 Chicken對象的類型都被隱藏在超類型Animal身後,Duck對象和Chicken對象就能被交換使用,這是讓對象表現出多態性的必經之路,而多態性的表現正是實現眾多設計模式的目標。

使用繼承得到多態效果

使用繼承來得到多態效果,是讓對象表現出多態性的最常用手段。繼承通常包括實現繼承和接口繼承。我們討論實現繼承。

我們先創建一個 Animal 抽象類,再分別讓Duck和Chicken都繼承自Animal抽象類,下述代碼中(1)處和(2)處的賦值語句顯然是成立的,因為鴨子和雞也是動物:

public abstract class Animal {
    abstract void makeSound(); // 抽象方法
}
public class Chicken extends Animal{
    public void makeSound(){
        System.out.println( "咯咯咯" );
    }
}
public class Duck extends Animal{
    public void makeSound(){
        System.out.println( "嘎嘎嘎" );
    }
}
Animal duck = new Duck(); // (1)
Animal chicken = new Chicken(); // (2)

// 現在剩下的就是讓 AnimalSound 類的 makeSound 方法接受 Animal 類型的參數
// 而不是具體的Duck 類型或者 Chicken 類型

public class AnimalSound{
    public void makeSound( Animal animal ){ // 接受 Animal 類型的參數
        animal.makeSound();
    }
}
public class Test {
    public static void main( String args[] ){
        AnimalSound animalSound= new AnimalSound ();
        
        Animal duck = new Duck();
        Animal chicken = new Chicken();
        
        animalSound.makeSound( duck ); // 輸出嘎嘎嘎
        animalSound.makeSound( chicken ); // 輸出咯咯咯
    }
}

JavaScript的多態

從前面我們得知,多態的思想實際上是把“做什麽”和“誰去做”分離開來,要實現這一點,歸根結底先要消除類型之間的耦合關系。如果類型之間的耦合關系沒有被消除,那麽我們在makeSound方法中指定了發出叫聲的對象是某個類型,它就不可能再被替換為另外一個類型。在 Java 中,可以通過向上轉型來實現多態。

而 JavaScript 的變量類型在運行期是可變的。一個JavaScript對象,既可以表示Duck類型的對象,又可以表示 Chicken 類型的對象,這意味著 JavaScript 對象的多態性是與生俱來的。

這種與生俱來的多態性並不難解釋。JavaScript作為一門動態類型語言,它在編譯時沒有類型檢查的過程,既沒有檢查創建的對象類型,又沒有檢查傳遞的參數類型。我們既可以往makeSound函數裏傳遞duck對象當作參數,也可以傳遞 chicken 對象當作參數。

由此可見,某一種動物能否發出叫聲,只取決於它有沒有makeSound方法,而不取決於它是否是某種類型的對象,這裏不存在任何程度上的“類型耦合”。在JavaScript中,並不需要諸如向上轉型之類的技術來取得多態的效果。

多態在面向對象程序設計中的作用

有許多人認為,多態是面向對象編程語言中最重要的技術。但我們目前還很難看出這一點,畢竟大部分人都不關心雞是怎麽叫的,也不想知道鴨是怎麽叫的。讓雞和鴨在同一個消息之下發出不同的叫聲,這跟程序員有什麽關系呢?

Martin Fowler 在《重構:改善既有代碼的設計》裏寫到:

? 在電影的拍攝現場,當導演喊出“action”時,主角開始背臺詞,照明師負責打燈光,後面的群眾演員假裝中槍倒地,道具師往鏡頭裏撒上雪花。在得到同一個消息時,每個對象都知道自己應該做什麽。如果不利用對象的多態性,而是用面向過程的方式來編寫這一段代碼,那麽相當於在電影開始拍攝之後,導演每次都要走到每個人的面前,確認它們的職業分工(類型),然後告訴他們要做什麽。如果映射到程序中,那麽程序中將充斥著條件分支語句。

利用對象的多態性,導演在發布消息時,就不必考慮各個對象接到消息後應該做什麽。對象應該做什麽並不是臨時決定的,而是已經事先約定和排練完畢的。每個對象應該做什麽,已經成為了該對象的一個方法,被安裝在對象的內部,每個對象負責它們自己的行為。所以這些對象可以根據同一個消息,有條不紊地分別進行各自的工作。

將行為分布在各個對象中,並讓這些對象各自負責自己的行為,這正是面向對象設計的優點。

再看一個現實開發中遇到的例子,這個例子的思想和動物叫聲的故事非常相似。假設我們要編寫一個地圖應用,現在有兩家可選的地圖API提供商供我們接入自己的應用。目前我們選擇的是谷歌地圖,谷歌地圖的 API 中提供了 show 方法,負責在頁面上展示整個地圖。示例代碼如下:

var googleMap = {
    show: function(){
        console.log( ‘開始渲染谷歌地圖‘ );
    }
};

var renderMap = function(){
    googleMap.show();
};
renderMap(); // 輸出:開始渲染谷歌地圖

後來因為某些原因,要把谷歌地圖換成百度地圖,為了讓 renderMap 函數保持一定的彈性,我們用一些條件分支來讓 renderMap 函數同時支持谷歌地圖和百度地圖:

var googleMap = {
    show: function(){
        console.log( ‘開始渲染谷歌地圖‘ );
    }
};

var baiduMap = {
    show: function(){
        console.log( ‘開始渲染百度地圖‘ );
    }
};

var renderMap = function( type ){
    if ( type === ‘google‘ ){
        googleMap.show();
    }else if ( type === ‘baidu‘ ){
        baiduMap.show();
    }
};

renderMap( ‘google‘ ); // 輸出:開始渲染谷歌地圖
renderMap( ‘baidu‘ ); // 輸出:開始渲染百度地圖

可以看到,雖然 renderMap 函數目前保持了一定的彈性,但這種彈性是很脆弱的,一旦需要替換成搜搜地圖,那無疑必須得改動 renderMap 函數,繼續往裏面堆砌條件分支語句。

我們還是先把程序中相同的部分抽象出來,那就是顯示某個地圖:

var renderMap = function( map ){
    if ( map.show instanceof Function ){
        map.show();
    }
};

var googleMap = {
    show: function(){
        console.log( ‘開始渲染谷歌地圖‘ );
    }
};
var baiduMap = {
    show: function(){
        console.log( ‘開始渲染百度地圖‘ );
    }
};

renderMap( googleMap ); // 輸出:開始渲染谷歌地圖
renderMap( baiduMap ); // 輸出:開始渲染百度地圖

現在來找找這段代碼中的多態性。當我們向谷歌地圖對象和百度地圖對象分別發出“展示地圖”的消息時,會分別調用它們的 show 方法,就會產生各自不同的執行結果。
對象的多態性提示我們,“做什麽”和“怎麽去做”是可以分開的,即使以後增加了搜搜地圖, renderMap 函數仍然不需要做任何改變,如下所示:

var sosoMap = {
    show: function(){
        console.log( ‘開始渲染搜搜地圖‘ );
    }
};

renderMap( sosoMap ); // 輸出:開始渲染搜搜地圖

在這個例子中,我們假設每個地圖API提供展示地圖的方法名都是show,在實際開發中也許不會如此順利,這時候可以借助適配器模式來解決問題。

設計模式與多態

GoF 所著的《設計模式》一書的副書名是“可復用面向對象軟件的基礎”。該書完全是從面向對象設計的角度出發的,通過對封裝、繼承、多態、組合等技術的反復使用,提煉出一些可重復使用的面向對象設計技巧。而多態在其中又是重中之重,絕大部分設計模式的實現都離不開多態性的思想。

拿命令模式來說,請求被封裝在一些命令對象中,這使得命令的調用者和命令的接收者可以完全解耦開來,當調用命令的 execute 方法時,不同的命令會做不同的事情,從而會產生不同的執行結果。而做這些事情的過程是早已被封裝在命令對象內部的,作為調用命令的客戶,根本不必去關心命令執行的具體過程。

在組合模式中,多態性使得客戶可以完全忽略組合對象和葉節點對象之前的區別,這正是組合模式最大的作用所在。對組合對象和葉節點對象發出同一個消息的時候,它們會各自做自己應該做的事情,組合對象把消息繼續轉發給下面的葉節點對象,葉節點對象則會對這些消息作出真實的反饋。

在策略模式中, Context 並沒有執行算法的能力,而是把這個職責委托給了某個策略對象。每個策略對象負責的算法已被各自封裝在對象內部。當我們對這些策略對象發出“計算”的消息時,它們會返回各自不同的計算結果。

在 JavaScript 這種將函數作為一等對象的語言中,函數本身也是對象,函數用來封裝行為並且能夠被四處傳遞。當我們對一些函數發出“調用”的消息時,這些函數會返回不同的執行結果,這是“多態性”的一種體現,也是很多設計模式在 JavaScript 中可以用高階函數來代替實現的原因。

面向對象的JavaScript --- 多態