原型鏈的繼承機制及其背後的程式設計哲學

尹三黎發表於2020-09-12

    首先,最重要的一句話:在 js 的世界裡,不存在類繼承,只有物件繼承。

    在 js 誕生之初是沒有類這個概念的,只有用來建立物件的建構函式,而函式本身只是一種特殊的物件。即便後來出現了 class,也沒有改變本質。js 的 class 和 c++ / java 裡面的 class 有本質區別。js 的 class 幾乎只是建構函式的語法糖,下面兩種寫法時等價的:

class Person {
    constructor(name) {
        this.name = name
    }

    getName() {
        return this.name
    }
}
function Person(name) {
    this.name = name
}

Person.prototype.getName = function() {
    return this.name
}

    實際上第二種寫法更加本質。定義一個類,其實沒有類,其實是定義了兩個物件:一個建構函式 Person 加一個原型物件 Person.prototype。當用 new Person('張三') 創造出“張三”這個物件時,“張三”的原型自動指向 Person.prototype,這樣它就擁有了 getName()。

 

prototype vs [[prototype]],Object vs Function

 

    原型鏈之所以非常讓人迷惑,就是因為有這兩對東西。

    第一對,prototype 不是 [[prototype]],它們是兩個不同的指標。[[prototype]] 是物件中真正指向原型的指標,一般是不可見的,需要通過 Object.getPrototypeOf() 獲取,但在一些瀏覽器中可以用 __proto__ 取到。prototype 就是在定義建構函式的時候用到的那個 prototype,它是建構函式的一個屬性,指向一個物件,當 new 出來例項時,該物件會成為例項的原型,也就是說,例項的 [[prototype]] 會指向建構函式的 prototype。在上面的例子中,“張三” 的 [[prototype]] = Person.prototype。

    第二對,Object 和 Function 分別是物件和函式的最原始的建構函式,但是 Object instanceof Function 的結果是 true,Function instanceof Object 也是 true。好了,到底是先有雞還是先有蛋呢?誰才是最終的那個造物主呢?

    這兩對困惑其實是一體兩面,背後是同一個東西,也就是下面這張圖:

 

 

    這個圖中間有一條虛線,劃分為上下兩個部分。上面部分是在 js 程式碼執行之前,就由系統初始化好,存在於全域性當中的。下面部分是之後,使用者寫的 js 程式碼建立的物件。

    上面部分有兩個非常特殊的物件,暫且將其稱作 AoO (ancestor of object,物件的祖先)和 AoF(ancestor of function, 函式的祖先)。這兩個物件就像亞當和夏娃,在一切 js 程式碼執行之前就被創造出來,承擔所有物件祖先的角色。

    其中,AoO 裡面定義了一些非常通用的,所有物件都會繼承到的方法,典型如 toString()。AoO 的 [[prototype]] 指向 null。

    AoF 裡面定義了所有函式會繼承到的方法,典型如 apply()。AoF 的 [[prototype]] 指向 AoO。

    然後是 Function,它的特別之處在於它可以建立函式,而且它的 prototype 和 [[prototype]] 都指向 AoF。

    再來是 Object,Object 負責建立物件,所以它的 prototype 指向 AoO,這樣所有它 new 出來的例項才會繼承 AoO。但是有意思的,Object 的 [[prototype]] 指向 AoF,這使得 Object 看起來好像是由 Function new 出來的,但實際上不是。這是系統刻意這樣安排,因為,Object 也是一個函式,理論上,Object 應該是 Function 的一個例項。因此,Object instanceof Function 為 true。而 Function instanceof Object 也為 true,因為 AoO 也在 Function 的原型鏈上,只不過中間隔了一層 AoF。

    所以,Object 和 Function 互為彼此的例項,並不是因為它們互相建立出了對方,而是系統刻意這樣安排它們的原型鏈,從而達到這樣一種效果。

    之後就到了虛線下面的部分。當執行下面的程式碼:

class Person {
    constructor(name) {
        this.name = name
    }

    getName() {
        return this.name
    }
}

    時,就建立了 Person 和 Person.prototype。即使不定義 Person.prototype,Person.prototype 也會預設存在。之後再 new,就出現了“張三”、“李四”等等。

    接著,再定義一個子類:

class Woman extends Person {
constructor(name) {
super(name)
this.gender = 'female'
}

getGender() {
return this.gender
}
}

    就創造出了 Woman 建構函式和 Woman.prototype,並且 Woman 繼承自 Person,Woman.prototype 繼承自 Person.prototype。然後 Woman 的例項繼承自 Woman.prototype。至此,形成了兩條互相平行的原型鏈:

    1,王五 -> Woman.prototype -> Person.prototype -> AoO

    2,Woman -> Person -> AoF

    最終 AoF -> AoO 進行匯合,萬物歸宗,全部都繼承自 AoO。

 

基因造人 vs 模板造人

 

    c++ / java 的 class 和物件的關係相當於基因和人的關係。class 是基因,由基因產生出來的人,一輩子都擺脫不了這個身份。張三是黃種人,那他永遠都是黃種人。黃種人是張三不可分割的個人特徵,寫在臉上,非常明顯。

    而 js 造人,是像女媧造人一樣,參照著某一個模板把人捏出來。建構函式就是這個模板。張三出生在中國,但是中國人並沒有明顯地寫在他臉上,他一生中可能移民幾次,先後變成了美國人、日本人,最後又變回中國人,都有可能。這是因為,可以通過 Object.setPrototypeOf() 來修改物件的原型,從而導致張三的身份是可變的。

    可變型別還可以產生另一個效果:可以先創造出物件,再來設計物件的型別。先用一個空的建構函式創造出許多物件,然後根據需要,往建構函式的 prototype 中新增方法。

 

鴨式辨型

 

    既然 js 中的型別這麼變化無常,那麼在用 js 程式設計的時候就要屏棄傳統的型別思維。鴨式辨型的意思是:“像鴨子一樣走路並且嘎嘎叫的動物就是鴨子”。雖然它可能實際上是一隻奇怪的雞。這頗有一種英雄莫問出處,只看長相的意味。鴨式辨型在 ts 中得到了進一步的支援和發展。ts 的 interface 也很特別,咋看起來它好像和傳統語言的 interface 是一樣的,但其實其背後的設計思路完全不同。ts 的 interface 是一種對結構的描述,編譯器根據這個描述來做型別檢查。於是,它並不要求物件顯示地實現 interface,只要能通過檢查就行。而且,本著“結構描述”這個定位,ts 的 interface 做得比傳統的 interface 更強大,比如它還可以描述傳入的物件必須是可以通過陣列下標的方式去訪問的,或者描述傳入的物件必須是一個 class 的 constructor 等等。

 

純物件的世界

 

    模板造人和鴨式辨型,其實折射出 js 底層是一個沒有類的世界。在一個沒有類的世界裡,一切都是自由的。在提供了巨大的靈活性的同時,也導致了不可控的問題。靈活性是一把雙刃劍,高手拿到這把劍削鐵如泥,而普通人拿到這把劍則可能傷到自己。而且太靈活也給工程化增加障礙,這也是 ts 出現的原因之一。但是,儘管有這些問題,js 依然是非常有特色的語言,畢竟類的存在意義就是建立例項,而絕大多數時候,人們其實只需要建立一個例項,卻不得不為了這個例項去定義類。定義了類,就必須管理這些類的繼承關係,同時型別檢查也是基於類。這樣就變成了面向型別程式設計,而真正重要的其實是物件。執行時存在的是物件,完成工作的是物件。人們面向型別程式設計,其實白白增添了很多思維負載。

相關文章