第四章: 混合(淆)“類”的對象

特別說明,為便于查閱,文章轉自https://github.com/getify/You-Dont-Know-JS

接著我們上一章對對象的探索,我們很自然的將注意力轉移到“面向對象(OO)編程”,與“類(class)”。我們先將“面向類”作為設計模式來看看,之后我們再考察“類”的機制:“實例化(instantiation)”, “繼承(inheritance)”與“(相對)多態(relative polymorphism)”。

我們將會看到,這些概念并不是非常自然地映射到 JS 的對象機制上,以及許多 JavaScript 開發者為了克服這些挑戰所做的努力(mixins等)。

注意: 這一章花了相當一部分時間(前一半!)在著重解釋“面向對象編程”理論上。在后半部分討論“Mixins(混合)”時,我們最終會將這些理論與真實且實際的 JavaScript 代碼聯系起來。但是這里首先要蹚過許多概念和假想代碼,所以可別跟丟了 —— 堅持下去!

類理論

“類/繼承”描述了一種特定的代碼組織和結構形式 —— 一種在我們的軟件中對真實世界的建模方法。

OO 或者面向類的編程強調數據和操作它的行為之間有固有的聯系(當然,依數據的類型和性質不同而不同?。?,所以合理的設計是將數據和行為打包在一起(也稱為封裝)。這有時在正式的計算機科學中稱為“數據結構”。

比如,表示一個單詞或短語的一系列字符通常稱為“string(字符串)”。這些字符就是數據。但你幾乎從來不關心數據,你總是想對數據 做事情, 所以可以 數據實施的行為(計算它的長度,在末尾添加數據,檢索,等等)都被設計成為 String 類的方法。

任何給定的字符串都是這個類的一個實例,這個類是一個整齊的集合包裝:字符數據和我們可以對它進行操作的功能。

類還隱含著對一個特定數據結構的一種 分類 方法。其做法是將一個給定的結構考慮為一個更加泛化的基礎定義的具體種類。

讓我們通過一個最常被引用的例子來探索這種分類處理。一輛 可以被描述為一“類”更泛化的東西 —— 載具 —— 的具體實現。

我們在軟件中通過定義 Vehicle 類和 Car 類來模型化這種關系。

Vehicle 的定義可能會包含像動力(引擎等),載人能力等等,這些都是行為。我們在 Vehicle 中定義的都是所有(或大多數)不同類型的載具(飛機、火車、機動車)都共同擁有的東西。

在我們的軟件中為每一種不同類型的載具一次又一次地重定義“載人能力”這個基本性質可能沒有道理。反而,我們在 Vehicle 中把這個能力定義一次,之后當我們定義 Car 時,我們簡單地指出它從基本的 Vehicle 定義中“繼承”(或“擴展”)。于是 Car 的定義就被稱為特化了更一般的 Vehicle 定義。

VehicleCar 用方法的形式集約地定義了行為,另一方面一個實例中的數據就像一個唯一的車牌號一樣屬于一輛具體的車。

這樣,類,繼承,和實例化就誕生了。

另一個關于類的關鍵概念是“多態(polymorphism)”,它描述這樣的想法:一個來自于父類的泛化行為可以被子類覆蓋,從而使它更加具體。實際上,相對多態允許我們在覆蓋行為中引用基礎行為。

類理論強烈建議父類和子類對相同的行為共享同樣的方法名,以便于子類(差異化地)覆蓋父類。我們即將看到,在你的 JavaScript 代碼中這么做會導致種種困難和脆弱的代碼。

"類"設計模式

你可能從沒把類當做一種“設計模式”考慮過,因為最常見的是關于流行的“面向對象設計模式”的討論,比如“迭代器(Iterator)”、“觀察者(Observer)”、“工廠(Factory)”、“單例(Singleton)”等等。當以這種方式表現時,幾乎可以假定 OO 的類是我們實現所有(高級)設計模式的底層機制,好像對所有代碼來說 OO 是一個給定的基礎。

取決于你在編程方面接受過的正規教育的水平,你可能聽說過“過程式編程(procedural programming)”:一種不用任何高級抽象,僅僅由過程(也就是函數)調用其他函數構成的描述代碼的方式。你可能被告知過,類是一個將過程式風格的“面條代碼”轉換為結構良好,組織良好代碼的 恰當 的方法。

當然,如果你有“函數式編程(functional programming)”的經驗,你可能知道類只是幾種常見設計模式中的一種。但是對于其他人來說,這可能是第一次你問自己,類是否真的是代碼的根本基礎,或者它們是在代碼頂層上的選擇性抽象。

有些語言(比如 Java)不給你選擇,所以這根本沒什么 選擇性 —— 一切都是類。其他語言如 C/C++ 或 PHP 同時給你過程式和面向類的語法,在使用哪種風格合適或混合風格上,留給開發者更多選擇。

JavaScript 的“類”

在這個問題上 JavaScript 屬于哪一邊?JS 擁有 一些 像類的語法元素(比如 newinstanceof)有一陣子了,而且在最近的 ES6 中,還有一些追加的東西,比如 class 關鍵字(見附錄A)。

但這意味著 JavaScript 實際上 擁有 類嗎?簡單明了:沒有。

由于類是一種設計模式,你 可以,用相當的努力(正如我們將在本章剩下的部分看到的),近似實現很多經典類的功能。JS 在通過提供看起來像類的語法,來努力滿足用類進行設計的極其廣泛的 渴望

雖然我們好像有了看起來像類的語法,但是 JavaScript 機制好像在抵抗你使用 類設計模式,因為在底層,這些你正在上面工作的機制運行的十分不同。語法糖和(極其廣泛被使用的)JS “Class”庫費了很大力氣來把這些真實情況對你隱藏起來,但你遲早會面對現實:你在其他語言中遇到的 和你在 JS 中模擬的“類”不同。

總而言之,類是軟件設計中的一種可選模式,你可以選擇在 JavaScript 中使用或不使用它。因為許多開發者都對面向類的軟件設計情有獨鐘,我們將在本章剩下的部分中探索一下,為了使用 JS 提供的東西維護類的幻覺要付出什么代價,和我們經歷的痛苦。

類機制

在許多面向類語言中,“標準庫”都提供一個叫“?!保▔簵#瑥棾龅龋┑臄祿Y構,用一個 Stack 類表示。這個類擁有一組變量來存儲數據,還擁有一組可公開訪問的行為(“方法”),這些行為使你的代碼有能力與(隱藏的)數據互動(添加或移除數據等等)。

但是在這樣的語言中,你不是直接在 Stack 上操作(除非制造一個 靜態的 類成員引用,但這超出了我們要討論的范圍)。Stack 類僅僅是 任何 的“棧”都會做的事情的一個抽象解釋,但它本身不是一個“?!?。為了得到一個可以對之進行操作的實在的數據結構,你必須 實例化 這個 Stack 類。

建筑物

傳統的"類(class)"和"實例(instance)"的比擬源自于建筑物的建造。

一個建筑師會規劃出一棟建筑的所有性質:多寬,多高,在哪里有多少窗戶,甚至墻壁和天花板用什么材料。在這個時候,她并不關心建筑物將會被建造在 哪里,她也不關心有 多少 這棟建筑的拷貝將被建造。

同時她也不關心這棟建筑的內容 —— 家具、墻紙、吊扇等等 —— 她僅關心建筑物含有何種結構。

她生產的建筑學上的藍圖僅僅是建筑物的“方案”。它們不實際構成我們可以實在進入其中并坐下的建筑物。為了這個任務我們需要一個建筑工人。建筑工人會拿走方案并精確地依照它們 建造 這棟建筑物。在真正的意義上,他是在將方案中意圖的性質 拷貝 到物理建筑物中。

一旦完成,這棟建筑就是藍圖方案的一個物理實例,一個很可能實質完美的 拷貝。然后建筑工人就可以移動到隔壁將它再重做一遍,建造另一個 拷貝。

建筑物與藍圖間的關系是間接的。你可以檢視藍圖來了解建筑物是如何構造的,但對于直接考察建筑物的每一部分,僅有藍圖是不夠的。如果你想打開一扇門,你不得不走進建筑物自身 —— 藍圖僅僅是為了用來 表示 門的位置而在紙上畫的線條。

一個類就是一個藍圖。為了實際得到一個對象并與之互動,我們必須從類中建造(也就是實例化)某些東西。這種“構建”的最終結果是一個對象,通常稱為一個“實例”,我們可以按需要直接調用它的方法,訪問它的公共數據屬性。

這個對象是所有在類中被描述的特性的 拷貝。

你不太可能會指望走進一棟建筑之后發現,一份用于規劃這棟建筑物的藍圖被裱起來掛在墻上,雖然藍圖可能在辦公室的公共記錄的文件中。相似地,你一般不會使用對象實例來直接訪問和操作類,但是對于判定對象實例來自于 哪個類 至少是可能的。

與考慮對象實例與它源自的類的任何間接關系相比,考慮類和對象實例的直接關系更有用。一個類通過拷貝操作被實例化為對象的形式。

如你所見,箭頭由左向右,從上至下,這表示著概念上和物理上發生的拷貝操作。

構造器(Constructor)

類的實例由類的一種特殊方法構建,這個方法的名稱通常與類名相同,稱為 “構造器(constructor)”。這個方法的具體工作,就是初始化實例所需的所有信息(狀態)。

比如,考慮下面這個類的假想代碼(語法是自創的):

class CoolGuy {
    specialTrick = nothing

    CoolGuy( trick ) {
        specialTrick = trick
    }

    showOff() {
        output( "Here's my trick: ", specialTrick )
    }
}

為了 制造 一個 CoolGuy 實例,我們需要調用類的構造器:

Joe = new CoolGuy( "jumping rope" )

Joe.showOff() // Here's my trick: jumping rope

注意,CoolGuy 類有一個構造器 CoolGuy(),它實際上就是在我們說 new CoolGuy(..) 時調用的。我們從這個構造器拿回一個對象(類的一個實例),我們可以調用 showOff() 方法,來打印這個特定的 CoolGuy 的特殊才藝。

顯然,跳繩使Joe看起來很酷。

類的構造器 屬于 那個類,幾乎總是和類同名。同時,構造器大多數情況下總是需要用 new 來調用,以便使語言的引擎知道你想要構建一個 新的 類的實例。

類繼承

在面向類的語言中,你不僅可以定義一個能夠初始化它自己的類,你還可以定義另外一個類 繼承 自第一個類。

這第二個類通常被稱為“子類”,而第一個類被稱為“父類”。這些名詞顯然來自于親子關系的比擬,雖然這種比擬有些扭曲,就像你馬上要看到的。

當一個家長擁有一個和他有血緣關系的孩子時,家長的遺傳性質會被拷貝到孩子身上。明顯地,在大多數生物繁殖系統中,雙親都平等地貢獻基因進行混合。但是為了這個比擬的目的,我們假設只有一個親人。

一旦孩子出現,他或她就從親人那里分離出來。這個孩子受其親人的繼承因素的嚴重影響,但是獨一無二。如果這個孩子擁有紅色的頭發,這并不意味這他的親人的頭發 曾經 是紅色,或者會自動 變成 紅色。

以相似的方式,一旦一個子類被定義,它就分離且區別于父類。子類含有一份從父類那里得來的行為的初始拷貝,但它可以覆蓋這些繼承的行為,甚至是定義新行為。

重要的是,要記住我們是在討論父 和子 ,而不是物理上的東西。這就是這個親子比擬讓人糊涂的地方,因為我們實際上應當說父類就是親人的 DNA,而子類就是孩子的 DNA。我們不得不從兩套 DNA 制造出(也就是“初始化”)人,用得到的物理上存在的人來與之進行談話。

讓我們把生物學上的親子放在一邊,通過一個稍稍不同的角度來看看繼承:不同種類型的載具。這是用來理解繼承的最經典(也是爭議不斷的)的比擬。

讓我們重新審視本章前面的 VehicleCar 的討論??紤]下面表達繼承的類的假想代碼:

class Vehicle {
    engines = 1

    ignition() {
        output( "Turning on my engine." )
    }

    drive() {
        ignition()
        output( "Steering and moving forward!" )
    }
}

class Car inherits Vehicle {
    wheels = 4

    drive() {
        inherited:drive()
        output( "Rolling on all ", wheels, " wheels!" )
    }
}

class SpeedBoat inherits Vehicle {
    engines = 2

    ignition() {
        output( "Turning on my ", engines, " engines." )
    }

    pilot() {
        inherited:drive()
        output( "Speeding through the water with ease!" )
    }
}

注意: 為了簡潔明了,這些類的構造器被省略了。

我們定義 Vehicle 類,假定它有一個引擎,有一個打開打火器的方法,和一個行駛的方法。但你永遠也不會制造一個泛化的“載具”,所以在這里它只是一個概念的抽象。

然后我們定義了兩種具體的載具:CarSpeedBoat。它們都繼承 Vehicle 的泛化性質,但之后它們都對這些性質進行了恰當的特化。一輛車有4個輪子,一艘快艇有兩個引擎,意味著它需要在打火時需要特別注意要啟動兩個引擎。

多態(Polymorphism)

Car 定義了自己的 drive() 方法,它覆蓋了從 Vehicle 繼承來的同名方法。但是,Cardrive() 方法調用了 inherited:drive(),這表示 Car 可以引用它繼承的,覆蓋之前的原版 drive()。SpeedBoatpilot() 方法也引用了它繼承的 drive() 拷貝。

這種技術稱為“多態(polymorphism)”,或“虛擬多態(virtual polymorphism)”。對我們當前的情況更具體一些,我們稱之為“相對多態(relative polymorphism)”。

多態這個話題比我們可以在這里談到的內容要寬泛的多,但我們當前的“相對”意味著一個特殊層面:任何方法都可以引用位于繼承層級上更高一層的其他(同名或不同名的)方法。我們說“相對”,因為我們不絕對定義我們想訪問繼承的哪一層(也就是類),而實質上用“向上一層”來相對地引用。

在許多語言中,在這個例子中出現 inherited: 的地方使用了 super 關鍵字,它基于這樣的想法:一個“超類(super class)”是當前類的父親/祖先。

多態的另一個方面是,一個方法名可以在繼承鏈的不同層級上有多種定義,而且在解析哪個方法在被調用時,這些定義可以適當地被自動選擇。

在我們上面的例子中,我們看到這種行為發生了兩次:drive()VehicleCar 中定義, 而 ignition()VehicleSpeedBoat 中定義。

注意: 另一個傳統面向類語言通過 super 給你的能力,是從子類的構造器中直接訪問父類構造器。這很大程度上是對的,因為對真正的類來說,構造器屬于這個類。然而在 JS 中,這是相反的 —— 實際上認為“類”屬于構造器(Foo.prototype... 類型引用)更恰當。因為在 JS 中,父子關系僅存在于它們各自的構造器的兩個.prototype 對象間,構造器本身不直接關聯,而且沒有簡單的方法從一個中相對引用另一個(參見附錄A,看看 ES6 中用 super “解決”此問題的 class)。

可以從 ignition() 中具體看出多態的一個有趣的含義。在 pilot() 內部,一個相對多態引用指向了(被繼承的)Vehicle 版本的 drive()。而這個 drive() 僅僅通過名稱(不是相對引用)來引用 ignition() 方法。

語言的引擎會使用哪一個版本的 ignition()?是 Vehicle 的還是 SpeedBoat 的?它會使用 SpeedBoat 版本的 ignition()。 如果你 初始化 Vehicle 類自身,并且調用它的 drive(),那么語言引擎將會使用 Vehicleignition() 定義。

換句話說,ignition() 方法的定義,根據你引用的實例是哪個類(繼承層級)而 多態(改變)。

這看起來過于深入學術細節了。不過為了好好地與 JavaScript 的 [[Prototype]] 機制的類似行為進行對比,理解這些細節還是很重要的。

如果類是繼承而來的,對這些類本身(不是由它們創建的對象!)有一個方法可以 相對地 引用它們繼承的對象,這個相對引用通常稱為 super。

記得剛才這幅圖:

注意對于實例化(a1、a2、b1、和 b2) 繼承(Bar),箭頭如何表示拷貝操作。

從概念上講,看起來子類 Bar 可以使用相對多態引用(也就是 super)來訪問它的父類 Foo 的行為。然而在現實中,子類不過是被給與了一份它從父類繼承來的行為的拷貝而已。如果子類“覆蓋”一個它繼承的方法,原版的方法和覆蓋版的方法實際上都是存在的,所以它們都是可以訪問的。

不要讓多態把你搞糊涂,使你認為子類是鏈接到父類上的。子類得到一份它需要從父類繼承的東西的拷貝。類繼承意味著拷貝。

多重繼承(Multiple Inheritance)

能回想起我們早先提到的親子和 DNA 嗎?我們說過這個比擬有些奇怪,因為生物學上大多數后代來自于雙親。如果類可以繼承自其他兩個類,那么這個親子比擬會更合適一些。

有些面向類的語言允許你指定一個以上的“父類”來進行“繼承”。多重繼承意味著每個父類的定義都被拷貝到子類中。

表面上看來,這是對面向類的一個強大的加成,給我們能力去將更多功能組合在一起。然而,這無疑會產生一些復雜的問題。如果兩個父類都提供了名為 drive() 的方法,在子類中的 drive() 引用將會解析為哪個版本?你會總是不得不手動指明哪個父類的 drive() 是你想要的,從而失去一些多態繼承的優雅之處嗎?

還有另外一個所謂的“鉆石問題”:子類“D”繼承自兩個父類(“B”和“C”),它們兩個又繼承自共通的父類“A”。如果“A”提供了方法 drive(),而“B”和“C”都覆蓋(多態地)了這個方法,那么當“D”引用 drive() 時,它應當使用那個版本呢(B:drive() 還是 C:drive())?

事情會比我們這樣窺豹一斑能看到的復雜得多。我們在這里將它們提出來,只是便于我們可以將它和 JavaScript 機制的工作方式比較。

JavaScript 更簡單:它不為“多重繼承”提供原生機制。許多人認為這是好事,因為省去的復雜性要比“減少”的功能多得多。但是這并不能阻擋開發者們用各種方法來模擬它,我們接下來就看看。

混合(Mixin)

當你“繼承”或是“實例化”時,JavaScript 的對象機制不會 自動地 執行拷貝行為。很簡單,在 JavaScript 中沒有“類”可以拿來實例化,只有對象。而且對象也不會被拷貝到另一個對象中,而是被 鏈接在一起(詳見第五章)。

因為在其他語言中觀察到的類的行為意味著拷貝,讓我們來看看 JS 開發者如何在 JavaScript 中 模擬 這種 缺失 的類的拷貝行為:mixins(混合)。我們會看到兩種“mixin”:明確的(explicit)隱含的(implicit)。

明確的 Mixin(Explicit Mixins)

讓我們再次回顧前面的 VehicleCar 的例子。因為 JavaScript 不會自動地將行為從 Vehicle 拷貝到 Car,我們可以建造一個工具來手動拷貝。這樣的工具經常被許多庫/框架稱為 extend(..),但為了便于說明,我們在這里叫它 mixin(..)。

// 大幅簡化的 `mixin(..)` 示例:
function mixin( sourceObj, targetObj ) {
    for (var key in sourceObj) {
        // 僅拷貝非既存內容
        if (!(key in targetObj)) {
            targetObj[key] = sourceObj[key];
        }
    }

    return targetObj;
}

var Vehicle = {
    engines: 1,

    ignition: function() {
        console.log( "Turning on my engine." );
    },

    drive: function() {
        this.ignition();
        console.log( "Steering and moving forward!" );
    }
};

var Car = mixin( Vehicle, {
    wheels: 4,

    drive: function() {
        Vehicle.drive.call( this );
        console.log( "Rolling on all " + this.wheels + " wheels!" );
    }
} );

注意: 重要的細節:我們談論的不再是類,因為在 JavaScript 中沒有類。VehicleCar 分別只是我們實施拷貝的源和目標對象。

Car 現在擁有了一份從 Vehicle 得到的屬性和函數的拷貝。技術上講,函數實際上沒有被復制,而是指向函數的 引用 被復制了。所以,Car 現在有一個稱為 ignition 的屬性,它是一個 ignition() 函數引用的拷貝;而且它還有一個稱為 engines 的屬性,持有從 Vehicle 拷貝來的值 1。

Car已經 有了 drive 屬性(函數),所以這個屬性引用沒有被覆蓋(參見上面 mixin(..)if 語句)。

重溫"多態(Polymorphism)"

我們來考察一下這個語句:Vehicle.drive.call( this )。我將之稱為“顯式假想多態(explicit pseudo-polymorphism)”?;叵胍幌拢覀兦耙欢渭傧氪a的這一行是我們稱之為“相對多態(relative polymorphism)”的 inherited:drive()。

JavaScript 沒有能力實現相對多態(ES6 之前,見附錄A)。所以,因為 CarVehicle 都有一個名為 drive() 的函數,為了在它們之間區別調用,我們必須使用絕對(不是相對)引用。我們明確地用名稱指出 Vehicle 對象,然后在它上面調用 drive() 函數。

但如果我們說 Vehicle.drive(),那么這個函數調用的 this 綁定將會是 Vehicle 對象,而不是 Car 對象(見第二章),那不是我們想要的。所以,我們使用 .call( this )(見第二章)來保證 drive()Car 對象的環境中被執行。

注意: 如果 Car.drive() 的函數名稱標識符沒有與 Vehicle.drive() 的重疊(也就是“遮蔽(shadowed)”;見第五章),我們就不會有機會演示“方法多態(method polymorphism)”。因為那樣的話,一個指向 Vehicle.drive() 的引用會被 mixin(..) 調用拷貝,而我們可以使用 this.drive() 直接訪問它。被選用的標識符重疊 遮蔽 就是為什么我們不得不使用更復雜的 顯式假想多態(explicit pseudo-polymorphism) 的原因。

在擁有相對多態的面向類的語言中,CarVehicle 間的連接在類定義的頂端被建立一次,那里是維護這種關系的唯一場所。

但是由于 JavaScript 的特殊性,顯式假想多態(因為遮蔽?。?在每一個你需要這種(假想)多態引用的函數中 建立了一種脆弱的手動/顯式鏈接。這可能會顯著地增加維護成本。而且,雖然顯式假想多態可以模擬“多重繼承”的行為,但這只會增加復雜性和代碼脆弱性。

這種方法的結果通常是更加復雜,更難讀懂,而且 更難維護的代碼。應當盡可能地避免使用顯式假想多態,因為在大部分層面上它的代價要高于利益。

混合拷貝(Mixing Copies)

回憶一下上面的 mixin(..) 工具:

// 大幅簡化的 `mixin()` 示例:
function mixin( sourceObj, targetObj ) {
    for (var key in sourceObj) {
        // 僅拷貝不存在的屬性
        if (!(key in targetObj)) {
            targetObj[key] = sourceObj[key];
        }
    }

    return targetObj;
}

現在,我們考察一下 mixin(..) 如何工作。它迭代 sourceObj(在我們的例子中是 Vehicle)的所有屬性,如果在 targetObj(在我們的例子中是 Car)中沒有名稱與之匹配的屬性,它就進行拷貝。因為我們是在初始對象存在的情況下進行拷貝,所以我們要小心不要將目標屬性覆蓋掉。

如果在指明 Car 的具體內容之前,我們先進行拷貝,那么我們就可以省略對 targetObj 檢查,但是這樣做有些笨拙且低效,所以通常不優先選用:

// 另一種 mixin,對覆蓋不太“安全”
function mixin( sourceObj, targetObj ) {
    for (var key in sourceObj) {
        targetObj[key] = sourceObj[key];
    }

    return targetObj;
}

var Vehicle = {
    // ...
};

// 首先,創建一個空對象
// 將 Vehicle 的內容拷貝進去
var Car = mixin( Vehicle, { } );

// 現在拷貝 Car 的具體內容
mixin( {
    wheels: 4,

    drive: function() {
        // ...
    }
}, Car );

不論哪種方法,我們都明確地將 Vehicle 中的非重疊內容拷貝到 Car 中?!癿ixin”這個名稱來自于解釋這個任務的另一種方法:Car 混入 Vehicle 的內容,就像你吧巧克力碎片混入你最喜歡的曲奇餅面團。

這個拷貝操作的結果,是 Car 將會獨立于 Vehicle 運行。如果你在 Car 上添加屬性,它不會影響到 Vehicle,反之亦然。

注意: 這里有幾個小細節被忽略了。仍然有一些微妙的方法使兩個對象在拷貝完成后還能互相“影響”對方,比如它們共享一個共通對象(比如數組)的引用。

由于兩個對象還共享它們的共通函數的引用,這意味著 即便手動將函數從一個對象拷貝(也就是混入)到另一個對象中,也不能 實際上模擬 發生在面向類的語言中的從類到實例的真正的復制。

JavaScript 函數不能真正意義上地(以標準,可靠的方式)被復制,所以你最終得到的是同一個共享的函數對象(函數是對象;見第三章)的 被復制的引用。舉例來說,如果你在一個共享的函數對象(比如 ignition())上添加屬性來修改它,VehicleCar 都會通過這個共享的引用而受“影響”。

在 JavaScript 中明確的 mixin 是一種不錯的機制。但是它們顯得言過其實。和將一個屬性定義兩次相比,將屬性從一個對象拷貝到另一個對象并不會產生多少 實際的 好處。而且由于我們剛才提到的函數對象引用的微妙之處,這顯得尤為正確。

如果你明確地將兩個或更多對象混入你的目標對象,你可以 某種程度上模擬 “多重繼承”的行為,但是在將方法或屬性從多于一個源對象那里拷貝過來時,沒有直接的辦法可以解決名稱的沖突。有些開發者/庫使用“延遲綁定(late binding)”和其他詭異的替代方法來解決問題,但從根本上講,這些“技巧” 通常 得不償失(而且低效?。?。

要小心的是,僅在明確的 mixin 能夠實際提高代碼可讀性時使用它,而如果你發現它使代碼變得更很難追溯,或在對象間建立了不必要或笨重的依賴性時,要避免使用這種模式。

如果正確使用 mixin 使你的問題變得比以前 困難,那么你可能應當停止使用 mixin。事實上,如果你不得不使用復雜的庫/工具來處理這些細節,那么這可能標志著你正走在更困難,也許沒必要的道路上。在第六章中,我們將試著提取一種更簡單的方法來實現我們期望的結果,同時免去這些周折。

寄生繼承(Parasitic Inheritance)

明確的 mixin 模式的一個變種,在某種意義上是明確的而在某種意義上是隱含的,稱為“寄生繼承(Parasitic Inheritance)”,它主要是由 Douglas Crockford 推廣的。

這是它如何工作:

// “傳統的 JS 類” `Vehicle`
function Vehicle() {
    this.engines = 1;
}
Vehicle.prototype.ignition = function() {
    console.log( "Turning on my engine." );
};
Vehicle.prototype.drive = function() {
    this.ignition();
    console.log( "Steering and moving forward!" );
};

// “寄生類” `Car`
function Car() {
    // 首先, `car` 是一個 `Vehicle`
    var car = new Vehicle();

    // 現在, 我們修改 `car` 使它特化
    car.wheels = 4;

    // 保存一個 `Vehicle::drive()` 的引用
    var vehDrive = car.drive;

    // 覆蓋 `Vehicle::drive()`
    car.drive = function() {
        vehDrive.call( this );
        console.log( "Rolling on all " + this.wheels + " wheels!" );
    };

    return car;
}

var myCar = new Car();

myCar.drive();
// Turning on my engine.
// Steering and moving forward!
// Rolling on all 4 wheels!

如你所見,我們一開始從“父類”(對象)Vehicle 制造了一個定義的拷貝,之后將我們的“子類”(對象)定義混入其中(按照需要保留父類的引用),最后將組合好的對象 car 作為子類實例傳遞出去。

注意: 當我們調用 new Car() 時,一個新對象被創建并被 Carthis 所引用(見第二章)。但是由于我們沒有使用這個對象,而是返回我們自己的 car 對象,所以這個初始化創建的對象就被丟棄了。因此,Car() 可以不用 new 關鍵字調用,就可以實現和上面代碼相同的功能,而且還可以省去對象的創建和回收。

隱含的 Mixin(Implicit Mixins)

隱含的 mixin 和前面解釋的 顯式假想多態 是緊密相關的。所以它們需要注意相同的事項。

考慮這段代碼:

var Something = {
    cool: function() {
        this.greeting = "Hello World";
        this.count = this.count ? this.count + 1 : 1;
    }
};

Something.cool();
Something.greeting; // "Hello World"
Something.count; // 1

var Another = {
    cool: function() {
        // 隱式地將 `Something` 混入 `Another`
        Something.cool.call( this );
    }
};

Another.cool();
Another.greeting; // "Hello World"
Another.count; // 1 (不會和 `Something` 共享狀態)

Something.cool.call( this ) 既可以在“構造器”調用中使用(最常見的情況),也可以在方法調用中使用(如這里所示),我們實質上“借用”了 Something.cool() 函數并在 Another 環境下,而非 Something 環境下調用它(通過 this 綁定,見第二章)。結果是,Something.cool() 中進行的賦值被實施到了 Another 對象而非 Something 對象。

那么,這就是說我們將 Something 的行為“混入”了 Another。

雖然這種技術看起來有效利用了 this 再綁定的功能,也就是生硬地調用 Something.cool.call( this ),但是這種調用不能被作為相對(也更靈活的)引用,所以你應當 提高警惕。一般來說,應當盡量避免使用這種結構 以保持代碼干凈而且易于維護。

復習

類是一種設計模式。許多語言提供語法來啟用自然而然的面向類的軟件設計。JS 也有相似的語法,但是它的行為和你在其他語言中熟悉的工作原理 有很大的不同。

類意味著拷貝。

當一個傳統的類被實例化時,就發生了類的行為向實例中拷貝。當類被繼承時,也發生父類的行為向子類的拷貝。

多態(在繼承鏈的不同層級上擁有同名的不同函數)也許看起來意味著一個從子類回到父類的相對引用鏈接,但是它仍然只是拷貝行為的結果。

JavaScript 不會自動地 (像類那樣)在對象間創建拷貝。

mixin 模式常用于在 某種程度上 模擬類的拷貝行為,但是這通常導致像顯式假想多態那樣(OtherObj.methodName.call(this, ...))難看而且脆弱的語法,這樣的語法又常導致更難懂和更難維護的代碼。

明確的 mixin 和類 拷貝 又不完全相同,因為對象(和函數?。﹥H僅是共享的引用被復制,不是對象/函數自身被復制。不注意這樣的微小之處通常是各種陷阱的根源。

一般來講,在 JS 中模擬類通常會比解決當前 真正 的問題埋下更多的坑。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容