Try   HackMD

JavaScript的原型繼承(Prototypal Inheritance)

tags: JavaScript Interview Preparation

:bulb: 本站筆記已同步更新到我的個人網站囉! 歡迎參觀與閱讀,體驗不同的視覺感受!

:memo: 前言

MDN文件中提到,JavaScript並非一個以class為基礎(class-based)的語言(例如Java、C++),儘管在JavaScript中有class這個關鍵字,但那只是為了開發者撰寫更直觀易懂的語法糖;事實上,JavaScript是以原型為基礎(prototype-based)的語言

:memo: 繼承(Inheritance)

什麼是繼承

繼承(Inheritance)可以說是物件導向程式設計(object-oriented programming, OOP)最重要的原則之一,繼承可以讓子類別(child class/subclass)沿用父類別(parent class/ superclass)的屬性與功能,以MDN文件的例子而言:

class Professor
    properties
        name
        teaches
    constructor
        Professor(name, teaches)
    methods
        grade(paper)
        introduceSelf()

以上例子中,定義了一個Professor類別,而這個類別中有兩個屬性(properties): nameteaches,以及兩種方法(methods):grade()introduceSelf()
類別就像是一個模板,可以創造該類型的物件,每個被創造出來的物件稱為該類別的實例(Instance),而創造實例的過程則是透過一種特殊的函式- 建構式(Constructor) 達成。

至於什麼是繼承呢,再來看另一個類別Student:

class Student
    properties
        name
        year
    constructor
        Student(name, year)
    methods
        introduceSelf()

Student這個類別中,可以發現和Professor類別擁有相同的屬性name以及相同的方法 introduceSelf(),因此我們可以定義一個Person類別作為這兩個類別的父類別,讓這兩個類別繼承Person的屬性或方法:

class Person
    properties
        name
    constructor
        Person(name)
    methods
        introduceSelf()

class Professor : extends Person
    properties
        teaches
    constructor
        Professor(name, teaches)
    methods
        grade(paper)
        introduceSelf()

class Student : extends Person
    properties
        year
    constructor
        Student(name, year)
    methods
        introduceSelf()

繼承的優點

  • 提高程式碼的重複使用性
    假如我們有一個class A,並且想再建立一個包含class A部分程式碼的class B,可以透過繼承從class A衍生class B,重複使用class A的資料與方法。
  • 避免程式碼重複
    繼承可以在多個子類別中共享程式碼,減少程式碼重複;如果兩個相關的類別擁有類似的程式碼,我們可以將這些程式碼放進父類別中。
  • 提高程式碼靈活性及延展性
    如果需要更改,可以在父類別中更改並由子類別繼承,換言之,父類別的屬性和方法所做的更改都可以直接應用在子類別上,所有公共的屬性和方法都可以直接在父類別宣告。另一方面,子類別也可以加入新的屬性或方法。
  • 提供更佳的程式碼結構與管理
    繼承使得子類別必須遵照標準的介面(interface)進行延伸,提供了方便理解的程式碼結構
  • 保留父類別的完整性
    宣告子類別並不影響父類別的原始碼,因此可以保留父類別的完整性。這也是封裝性(Encapsulation)的特性展現。
  • 隱藏數據
    父類別可以將某些數據設為私有,子類別無法更改或取用
  • 幫助達成執行環境的多型(Polymorphism)
    透過繼承,子類別可以加入不同的執行方式(implementation),覆寫(override)父類別的方法

Ref: MDN doc, Inheritance in OOPS: An Idea of Code Reusability

JavaScript的原型繼承

以下這兩段來自MDN文件的描述,快速說明了JS原型繼承的特性:

某些人認為 JavaScript 並非真正的物件導向 (Object-oriented, OO) 語言。 在「典型 OO」中,你必須定義特定的類別物件,才能定義哪些類別所要繼承的類別。JavaScript 則使用不同的系統 —「繼承」的物件並不會一併複製功能過來,而是透過原型鍊連接其所繼承的功能,亦即所謂的原型繼承 (Prototypal inheritance)。
From MDN文件

關於物件導向程式設計,可以參考這篇文章

原型鏈的頂端是物件

JavaScript 就只有一個建構子:物件。每個物件都有一個連著其他原型(prototype)的私有屬性(private property)物件。原型物件也有著自己的原型,於是原型物件就這樣鏈結,直到撞見 null 為止:null 在定義裡沒有原型、也是原型鏈(prototype chain)的最後一個鏈結。 幾乎所有 JavaScript 的物件,都是在原型鏈最頂端的物件實例。
From MDN文件

實際看看以下的程式碼:

const milk = 1 const milkProto = Object.getPrototypeOf(milk) const milkProtoProto = Object.getPrototypeOf(milkProto) const milkProtoProtoProto = Object.getPrototypeOf(milkProtoProto) console.log(milkProto) // {} console.log(milkProtoProto) // [Object: null prototype] {} console.log(milkProtoProtoProto) // null

console.log(milkProto)這一行程式碼在console中可以看到:


它的原型中包含建構式(constructor)Number()函式以及各種這個原型建構的實例可以使用的方法(method),[[Prototype]] 則可以觀察到這個Number的原型是Object,也就是原型鏈的上一層。

console.log(milkProtoProto)這一行程式碼在console中則可以看到:


__proto__: (...) 也就是再往原型鏈的上層找不到東西了,所以console.log(milkProtoProtoProto)印出的是null

:memo: 範例

說了那麼多,直接用程式碼操作:

Note: 以下例子使用class語法糖撰寫,和傳統的建構函式會略有不同,關於兩者的比較也可以參考另一篇文章

extendssuper

class Drink { constructor(name, cost) { this._name = name this._cost = cost } // getter get name() { return this._name } get cost() { return this._cost } get amount(){ return this._amount } // method amountAdded(){ this._amount++ } }

先定義一個classDrink,接著定義一個classCoffee:
(關於屬性名稱為何要使用底線,請參考這個問題)

class Coffee { constructor(name, cost, origin) { this._name = name this._cost = cost this._origin = origin } get name() { return this._name } get cost() { return this._cost } get origin() { return this._origin } }

classCoffee的屬性和方法,繼承自classDrink,可以使用extends關鍵字進行繼承,所以改寫Coffee如下:

class Coffee extends Drink { constructor(name, cost, origin) { super(name, cost); this._origin = origin; } get origin(){ return this._origin } get coffeeInfo() { return `The ${this._name} from ${this._origin} costs ${this._cost} dollars.` } }

:pushpin: Points:

  • extends關鍵字讓Coffee可以使用父類別Drink中的方法
  • super關鍵字呼叫父類別中的建構式,並可以取用父類別的屬性與方法。在以上範例中,super(name, cost)namecost兩個argument傳入父類別Drink中的建構式,並執行產生新的Coffee物件實例。
  • 值得注意的是,當使用建構式時,super關鍵字必須在this關鍵字之前使用,確保新的物件已經在父類別的建構式中建立,此時this會指向這個新建立的物件;如果沒有在this之前呼叫super,則會出現reference error,好的做法是在子類別建構式的第一行使用super關鍵字
  • originCoffee中的新屬性,所以在此處的建構式中定義它。

class語法糖中,我們可以直接把共用的方法(method)寫在class裡面;如果是用ES6以前的建構式,寫法相當於:

// amountAdded function Drink.prototype.amountAdded = function () { return this._amount++ }

或者是使用Object.assign的語法:

const amountAdded = { function () { return this._amount++ } } Object.assign(Drink.prototype, amountAdded);

另外,在繼承父類別時,class語法糖使用extends關鍵字;ES6以前則可以使用call() 函式,getter、setter則可以使用Object.defineProperty()函式
Reference: MDN docs - Object prototypes

接著,用子類別Coffee創造一個新的實例看看:

const latte = new Coffee('latte', 5, 'Brazil') console.log(latte.name) // output: latte

上述程式碼中,我們創造了一個新的Coffee實例latte,因為latte可以取用父類別Drink中的gettername,所以會回傳儲存在this._name屬性中的值,也就是latte。

接著再執行以下程式碼:

latte.amountAdded() console.log(latte.amount) // output: 1

以上程式碼做了什麼呢?

  1. Coffee繼承了父類別Drink_amount、amount getter、以及amountAdded函式
  2. 當我們建立了實例latteDrink的建構式把_amount屬性設為0
  3. 因為繼承了amountAdded函式,所以新建立的實例latte可以呼叫這個方法並執行,使儲存在_amount屬性的值+1
  4. 最後呼叫amount getter,所以會回傳儲存在this._amount屬性中的值,也就是1

如果我們再定義另一個子類別Tea,同樣繼承Drink的屬性和方法:

class Tea extends Drink { constructor(name, origin) { super(name); this._origin = origin; } get origin(){ return this._origin } }

一樣試試看是否可以調用name getter:

const blackTea =new Tea('Black Tea', 'India') console.log(blackTea.name) // output: Black Tea

成功調用name getter! 再來看看和Tea的原型和剛剛建立的Coffee是否相同:

console.log(Object.getPrototypeOf(Tea) === Object.getPrototypeOf(Coffee)) // output: true

靜態方法(Static Methods)

有時,我們希望class中具有個別實例中不可調用的方法,但可以直接從該class中調用這些方法。這些方法稱為靜態方法(Static Methods)。這些方法可以用static關鍵字調用。

延續前面的範例:

class Drink { constructor(name, cost) { this._name = name this._cost = cost this._amount = 0 } get name() { return this._name } get cost() { return this._cost } get amount(){ return this._amount } amountAdded(){ this._amount++ } static rating(){ const randomNumber = Math.floor(Math.random()*5) return randomNumber } }

直接從Drink調用rating方法:

console.log(Drink.rating()) // output: <random rating>

繼承類別,也會繼承靜態方法:

console.log(Coffee.rating()) // output: <random rating>

但是如果是該類別或繼承的子類別所建立的實例,調用該方法:

const drink = new Drink('drink', 2) console.log(drink.rating()) // TypeError: drink.rating is not a function const latte = new Coffee('latte', 5, 'Brazil') console.log(latte.rating()) // TypeError: latte.rating is not a function

則會出現錯誤,因為無法從實例上調用靜態方法。

最後再看一個例子:

Math是一個JavaScript的內建物件,它擁有多種靜態屬性與方法。比較特別的是,和大多數的全域物件(global object)不同,Math不是建構式(constructor),也就是說,無法使用new運算子來建立一個實例(instance),但是可以直接從Math調用靜態方法,例如調用Math.log()這個靜態方法:

console.log(Math.log(1)) // output: 0

參考資料

:crescent_moon:  本站內容僅為個人學習記錄,如有錯誤歡迎留言告知、交流討論!