## 2.4 混合對象“類”
### 2.4.1 類理論
類/ 繼承描述了一種代碼的組織結構形式——一種在軟件中對真實世界中問題領域的建模方法。
面向對象編程強調的是數據和操作數據的行為本質上是互相關聯的(當然,不同的數據有不同的行為),因此好的設計就是把數據以及和它相關的行為打包(或者說封裝)起來。這在正式的計算機科學中有時被稱為數據結構。
**關于類、繼承和實例化還有多態的基礎概念認識,略。**
**1. “類”設計模式**
你可能從來沒把類作為設計模式來看待,討論得最多的是面向對象設計模式,比如迭代器模式、觀察者模式、工廠模式、單例模式,等等。從這個角度來說,我們似乎是在(低級)面向對象類的基礎上實現了所有(高級)設計模式,似乎面向對象是優秀代碼的基礎。
如果你之前接受過正規的編程教育的話,可能聽說過**過程化編程**,這種代碼只包含過程(函數)調用,沒有高層的抽象。
當然,如果你有**函數式編程**(比如Monad)的經驗就會知道類也是非常常用的一種設計模式。但是對于其他人來說,這可能是第一次知道類并不是必須的編程基礎,而是一種可選的代碼抽象。
有些語言(比如Java)并不會給你選擇的機會,類并不是可選的——萬物皆是類。其他語言(比如C/C++ 或者PHP)會提供過程化和面向類這兩種語法,開發者可以選擇其中一種風格或者混用兩種風格。
**2. JavaScript中的“類”**
在相當長的一段時間里,JavaScript 只有一些近似類的語法元素(比如new 和instanceof),不過在后來的ES6 中新增了一些元素,比如`class `關鍵字。
**但這并不意味著JavaScript中實際上存在類。**
在軟件設計中類是一種可選的模式,雖然JavaScript有近似類的語法,但是JavaScript 的機制似乎一直在阻止你使用類設計模式。在近似類的表象之下,JavaScript 的機制其實和類完全不同。語法糖和( 廣泛使用的)JavaScript“類”庫試圖掩蓋這個現實,但是你遲早會面對它:其他語言中的類和JavaScript中的“類”并不一樣。
### 2.4.2 類的機制
在許多面向類的語言中,“標準庫”會提供Stack 類,它是一種“棧”數據結構(支持壓入、彈出,等等)。Stack 類內部會有一些變量來存儲數據,同時會提供一些公有的可訪問行為(“方法”),從而讓你的代碼可以和(隱藏的)數據進行交互(比如添加、刪除數據)。
但是在這些語言中,你實際上并不是直接操作Stack(除非創建一個靜態類成員引用)。Stack 類僅僅是一個抽象的表示,它描述了所有“棧”需要做的事,但是它本身并不是一個“棧”。你必須先**實例化Stack 類**然后才能對它進行操作。
**1. 建造**
“類”和“實例”的概念來源于房屋建造。
一個類就是一張藍圖。為了獲得真正可以交互的對象,我們必須按照類來建造(也可以說實例化)一個東西,這個東西通常被稱為**實例**,有需要的話,可以直接在實例上調用方法并訪問其所有公有數據屬性。這個對象就是類中描述的所有特性的一份副本。
把類和實例對象之間的關系看作是**直接關系**而不是間接關系通常更有助于理解。類通過**復制操作**被實例化為對象形式:

箭頭的方向是從左向右、從上向下,它表示概念和物理意義上發生的復制操作。
**2. 構造函數**
類實例是由一個特殊的類方法構造的,這個方法名通常和類名相同,被稱為**構造函數**。這個方法的任務就是**初始化實例需要的所有信息(狀態)。**
舉例來說,思考下面這個關于類的偽代碼(編造出來的語法):
~~~
class CoolGuy {
specialTrick = nothing
CoolGuy( trick ) {
specialTrick = trick
}
showOff() {
output( "Here's my trick: ", specialTrick )
}
}
~~~
我們可以調用類構造函數來生成一個CoolGuy 實例:
~~~
Joe = new CoolGuy( "jumping rope" )
Joe.showOff() // 這是我的絕技:跳繩
~~~
注意,CoolGuy 類有一個CoolGuy() 構造函數,執行new CoolGuy() 時實際上調用的就是它。構造函數會返回一個對象(也就是類的一個實例),之后我們可以在這個對象上調用showOff() 方法,來輸出指定CoolGuy 的特長。
類構造函數屬于類,而且通常和類同名。此外,構造函數大多需要用new 來調,這樣語言引擎才知道你想要構造一個新的類實例。
### 2.4.3 類的繼承
在面向類的語言中,你可以先定義一個類,然后定義一個繼承前者的類。后者通常被稱為“子類”,前者通常被稱為“父類”。
思考下面關于類繼承的偽代碼(省略了構造函數):
~~~
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 類來假設一種發動機,一種點火方式,一種駕駛方法。接下來我們定義了兩類具體的交通工具:Car 和SpeedBoat。它們都從Vehicle 繼承了通用的特性并根據自身類別修改了某些特性。汽車需要四個輪子,快艇需要兩個發動機,因此它必須啟動兩個發動機的點火裝置。
**1. 多態**
Car 重寫了繼承自父類的drive() 方法,但是之后Car 調用了inherited:drive() 方法,這表明Car 可以引用繼承來的原始drive() 方法。快艇的pilot() 方法同樣引用了原始drive() 方法。
這個技術被稱為**多態或者虛擬多態**。在本例中,更恰當的說法是相對多態。
多態是一個非常廣泛的話題,“相對”只是多態的一個方面:任何方法都可以引用繼承層次中高層的方法(無論高層的方法名和當前方法名是否相同)。之所以說“相對”是因為我們并不會定義想要訪問的絕對繼承層次(或者說類),而是使用相對引用
“查找上一層”。
在許多語言中可以使用`super` 來代替本例中的inherited:, 它的含義是**“ 超類”**(superclass),表示當前類的父類/ 祖先類。
多態的另一個方面是,在繼承鏈的不同層次中一個方法名可以被多次定義,當調用方法時會自動選擇合適的定義。在之前的代碼中就有兩個這樣的例子:drive() 被定義在Vehicle 和Car 中,ignition() 被定義在Vehicle 和SpeedBoat 中。
**2. 多重繼承**
有些面向類的語言允許你繼承多個“父類”。多重繼承意味著所有父類的定義都會被復制到子類中。
從表面上來,對于類來說這似乎是一個非常有用的功能,可以把許多功能組合在一起。
然而,這個機制同時也會帶來很多復雜的問題。如果兩個父類中都定義了drive() 方法的話,子類引用的是哪個呢?難道每次都需要手動指定具體父類的drive() 方法嗎?這樣多態繼承的很多優點就存在了。
除此之外,還有一種被稱為**鉆石問題**的變種。在鉆石問題中,子類D 繼承自兩個父類(B和C),這兩個父類都繼承自A。如果A 中有drive() 方法并且B 和C 都重寫了這個方法(多態),那當D 引用drive() 時應當選擇哪個版本呢(B:drive() 還是C:drive())?

### 2.4.4 混入
在繼承或者實例化時,JavaScript 的對象機制并不會自動執行復制行為。簡單來說,JavaScript 中只有對象,并不存在可以被實例化的“類”。一個對象并不會被復制到其他對象,它們會被**關聯**起來。
由于在其他語言中類表現出來的都是復制行為,因此JavaScript 開發者也想出了一個方法來模擬類的復制行為,這個方法就是**混入**。接下來我們會看到兩種類型的混入:**顯式和隱式**。
**1. 顯示混入**
回顧一下之前提到的Vehicle 和Car。由于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!"
);
}
} );
~~~
現在Car 中就有了一份Vehicle 屬性和函數的副本了。從技術角度來說,函數實際上沒有被復制,復制的是函數引用。所以,Car 中的屬性ignition 只是從Vehicle 中復制過來的對于ignition() 函數的引用。相反,屬性engines 就是直接從Vehicle 中復制了值1。Car 已經有了drive 屬性(函數),所以這個屬性引用并沒有被mixin 重寫,從而保留了Car 中定義的同名屬性,實現了“子類”對“父類”屬性的重寫.
#### (1)再說多態
分析一下這條語句:`Vehicle.drive.call( this )`。這就是**顯式多態**。在之前的偽代碼中對應的語句是`inherited:drive()`,我們稱之為**相對多態**。
JavaScript( 在ES6 之前) 并沒有相對多態的機制。所以, 由于Car 和Vehicle 中都有drive() 函數,為了指明調用對象,必須使用絕對(而不是相對)引用。我們通過名稱顯式指定Vehicle 對象并調用它的drive() 函數。
但是如果直接執行`Vehicle.drive()`,函數調用中的this 會被綁定到Vehicle 對象而不是Car 對象,這并不是我們想要的。因此,我們會使用`.call(this)` 來確保drive() 在Car 對象的上下文中執行。
~~~
如果函數Car.drive() 的名稱標識符并沒有和Vehicle.drive() 重疊(或者說“屏蔽”)的話,就不需要實現方法多態,
因為調用mixin(..) 時會把函數Vehicle.drive() 的引用復制到Car 中,因此可以直接訪問this.drive()。
正是由于存在標識符重疊,所以必須使用更加復雜的顯式偽多態方法。
~~~
在支持相對多態的面向類的語言中,Car 和Vehicle 之間的聯系只在類定義的開頭被創建,從而只需要在這一個地方維護兩個類的聯系。
但是在JavaScript 中(由于屏蔽)使用顯式偽多態會在所有需要使用(偽)多態引用的地方創建一個函數關聯,這會極大地增加維護成本。此外,由于顯式偽多態可以模擬多重繼承,所以它會進一步增加代碼的復雜度和維護難度。
#### (2)混合復制
回顧一下之前提到的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 進行特殊化的話,就可以跳過存在性檢查。不過這種方法并不好用并且效率更低,所以不如第一種方法常用:
~~~
// 另一種混入函數,可能有重寫風險
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 );
~~~
JavaScript 中的函數無法(用標準、可靠的方法)真正地復制,所以你只能復制對共享函數對象的引用(函數就是對象)。如果你修改了共享的函數對象(比如ignition()),比如添加了一個屬性,那Vehicle 和Car 都會受到影響。
#### (3)寄生繼承
顯式混入模式的一種變體被稱為“**寄生繼承**”,它既是顯式的又是隱式的。
下面是它的工作原理:
~~~
// “傳統的JavaScript 類”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();
// 發動引擎。
// 手握方向盤!
// 全速前進!
~~~
首先我們復制一份Vehicle 父類(對象)的定義,然后混入子類(對象)的定義(如果需要的話保留到父類的特殊引用),然后用這個復合對象構建實例。
**2. 隱式混入**
~~~
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 (count 不是共享狀態)
~~~
通過在構造函數調用或者方法調用中使用`Something.cool.call( this )`,我們實際上“借用”了函數`Something.cool() `并在Another 的上下文中調用了它。最終的結果是Something.cool() 中的賦值操作都會應用在Another 對象上而不是Something 對象上。
雖然這類技術利用了this 的重新綁定功能,但是Something.cool.call( this ) 仍然無法變成相對(而且更靈活的)引用,所以使用時千萬要小心。通常來說,盡量避免使用這樣的結構,以保證代碼的整潔和可維護性。
- 前言
- 第一章 JavaScript簡介
- 第三章 基本概念
- 3.1-3.3 語法、關鍵字和變量
- 3.4 數據類型
- 3.5-3.6 操作符、流控制語句(暫略)
- 3.7函數
- 第四章 變量的值、作用域與內存問題
- 第五章 引用類型
- 5.1 Object類型
- 5.2 Array類型
- 5.3 Date類型
- 5.4 基本包裝類型
- 5.5 單體內置對象
- 第六章 面向對象的程序設計
- 6.1 理解對象
- 6.2 創建對象
- 6.3 繼承
- 第七章 函數
- 7.1 函數概述
- 7.2 閉包
- 7.3 私有變量
- 第八章 BOM
- 8.1 window對象
- 8.2 location對象
- 8.3 navigator、screen與history對象
- 第九章 DOM
- 9.1 節點層次
- 9.2 DOM操作技術
- 9.3 DOM擴展
- 9.4 DOM2和DOM3
- 第十章 事件
- 10.1 事件流
- 10.2 事件處理程序
- 10.3 事件對象
- 10.4 事件類型
- 第十一章 JSON
- 11.1-11.2 語法與序列化選項
- 第十二章 正則表達式
- 12.1 創建正則表達式
- 12.2-12.3 模式匹配與RegExp對象
- 第十三章 Ajax
- 13.1 XMLHttpRequest對象
- 你不知道的JavaScript
- 一、作用域與閉包
- 1.1 作用域
- 1.2 詞法作用域
- 1.3 函數作用域與塊作用域
- 1.4 提升
- 1.5 作用域閉包
- 二、this與對象原型
- 2.1 關于this
- 2.2 全面解析this
- 2.3 對象
- 2.4 混合對象“類”
- 2.5 原型
- 2.6 行為委托
- 三、類型與語法
- 3.1 類型
- 3.2 值
- 3.3 原生函數