January 12, 2016 · JavaScript

Inheritance with Constructor in JavaScript

JavaScript 一項十分著名的特點是:JavaScript 中沒有 class,只有 object。因而,對於熟悉那些 class-based OOPL(如:C++、Java、Python)的開發者來說,JavaScript 這種以 prototype 為核心的「繼承(inheritance)」似乎並不是那麼直覺。

即使在 ECMAScript 6(ES6)正式引入了 class 語法,「好像」是有個 prototype 以外的做法。然而,ES6 新增的 class 本質上比較接近 syntactic sugar,其背後也是藉由 prototype 來實現。因此,瞭解如何在 JavaScript 利用 prototype 實作繼承仍然十分重要。

其實 JavaScript 本質的繼承機制是十分單純的,一切都是牽扯到 constructor 才導致理解上的困難。因此,本文將從最單純的 prototype 繼承機制開始講起,接著解釋引入 constructor 之後的繼承方式,並在文末討論為什麼「這可能不是個好點子」。

本文假設你對 JavaScript 的 constructor、prototype 與 prototype chain 等名詞有一定程度的瞭解。如果你對這些概念還不熟悉,建議先去看看 Prototype and Constructor in JavaScript 這篇文章。

Prototype Chain and Inheritance

在 JavaScript 中,prototype 是為其它 objects 提供共享 properties 的 object。而 object 的 prototype 可以透過 Object.create() 在建立的同時指定:

var rectPrototype = {  
        area: function() {
            return this.length * this.width;
        }
    },
    rect = Object.create(rectPrototype);

rect.length = 3;  
rect.width = 5;  
console.log(Object.getPrototypeOf(rect) === rectPrototype); // true  
console.log(rect.area()); // 15  

objects 會由 prototype 的關聯形成一個 prototype chain。而對 object 存取 property 時,會從 object 自身開始,沿著 prototype chain 向上尋找。

讓我們換個方式思考。這意味著:JavaScript 的 object 本質上是基於其 prototype object,並在此之上擴充(extend)或覆寫(override)properties。也就是說,object 繼承自其 prototype。而這也是 JavaScript 最本質的繼承機制:delegation(object 將「共享的行為」delegate 給其 prototype),也有人稱之為 differential inheritance(object 僅描述其與 prototype 不同的部份)。

這之中不存在 class,只有 object 與 object(prototype)之間的關聯。

Inheritance with Constructor

然而,這個機制一旦牽扯到 constructor,似乎又不是那麼容易理解了。

Constructor of Shape Objects

先來看看這個例子:

var Shape = function(x, y) {  
    this.x = x;
    this.y = y;
};

Shape.prototype.move = function(x, y) {  
    this.x += x;
    this.y += y;
};

這裡的 constructor Shape 可以用以建立 shape objects。每個 shape object 都能存取自身的 properties xy,以及定義在其 prototype(Shape.prototype)的 method move()

var obj = new Shape(-6, 0);  
console.log('(' + obj.x + ', ' + obj.y + ')'); // (-6, 0)  
obj.move(2, 4);  
console.log('(' + obj.x + ', ' + obj.y + ')'); // (-4, 4)  

Rectangle is a Shape

現在,我們希望有個能夠生成 rectangle objects 的 constructor Rect。每個 rectangle object 擁有 properties lengthwidth,也會透過其 prototype 共享 method area()

此外,我們也希望每個 rectangle object 同時也是一個 shape object:這表示每個 rectangle object 自身也擁有 properties xy,並能夠共享 method move()

最後,constructor Rect 不僅需要接收自身的 parameters lengthwidth,也要接收傳遞給 constructor Shapexy

符合上述要求的 Rect 使用起來大概像這樣:

var rect = new Rect(-6, 0, 3, 5);  
console.log(rect.length + ' * ' + rect.width + ' = ' + rect.area()); // 3 * 5 = 15

console.log('(' + rect.x + ', ' + rect.y + ')'); // (-6, 0)  
rect.move(2, 4);  
console.log('(' + rect.x + ', ' + rect.y + ')'); // (-4, 4)  

Invoking "Super Constructor"

首先,我們需要在 Rect() 中,利用 Shape() 為生成的 object 建立 xy 這兩個 properties。這能夠輕易地透過 Function.prototype.call() 達成:

var Rect = function(x, y, length, width) {  
        Shape.call(this, x, y);
        this.length = length;
        this.width = width;
    },
    rect = new Rect(-6, 0, 3, 5);

console.log('(' + rect.x + ', ' + rect.y + ')'); // (-6, 0)  
console.log(rect.length + ' * ' + rect.width); // 3 * 5  

Inheriting and Extending Properties

另一方面,shape objects 之間共享的 properties(move())都定義在其 prototype Shape.prototype 上。而我們希望 rectangle objects 同時也能共享這些 properties。

因此,我們要讓 Shape.prototype 作為 Rect.prototype(rectangle objects 的 prototype,因為 rectangle objects 皆由 constructor Rect 所生)的 prototype。這樣既可以讓 rectangle objects 透過 prototype chain 存取到 Shape.prototype 的 properties,也能將僅有 rectangle objects 共享的 properties(area())定義在 Rect.prototype 中。

如同先前所述,這可以透過 Object.create() 來達成:

Rect.prototype = Object.create(Shape.prototype);  

由於在預設情況下,constructor 的 prototype property 的 constructor property 都恰為 constructor 自身。而在這裡 Rect.prototype 被覆寫了,所以我們還需要手動指定這個 property,確保它的值與預設的情況相同:

Rect.prototype.constructor = Rect;  

然後再在 Rect.prototype 定義僅有 rectangle objects 共享的 properties:

Rect.prototype.area = function() {  
    return this.length * this.width;
};

Final Result

完整的程式如下:

var Rect = function(x, y, length, width) {  
        Shape.call(this, x, y);
        this.length = length;
        this.width = width;
    },
    rect;

Rect.prototype = Object.create(Shape.prototype);  
Rect.prototype.constructor = Rect;  
Rect.prototype.area = function() {  
    return this.length * this.width;
};

rect = new Rect(-6, 0, 3, 5);  
console.log(rect.length + ' * ' + rect.width + ' = ' + rect.area()); // 3 * 5 = 15

console.log('(' + rect.x + ', ' + rect.y + ')'); // (-6, 0)  
rect.move(2, 4);  
console.log('(' + rect.x + ', ' + rect.y + ')'); // (-4, 4)  

現在,rect 的 prototype chain 如下所示:

rect → Rect.prototype → Shape.prototype → ...  

Discussion

相較於僅使用 Object.create() 建立的直接繼承關係,引入 constructor 的繼承實作解釋起來就複雜了許多。我想這是因為,JavaScript 有意在不「真正」引入 class 的前提下,讓語言本身設計得 「classical」一點(這裡的「classical」並非「傳統」或「古典」之意,指的是 OOP 裡的「類別」),從而引進了 new operator、constructor、與 prototype property(我找不到充分的文獻證明這件事,僅是猜想)。

於是,當我們寫下:

var obj = new Constructor(...);  

constructor 的角色就如同 class 一般,但其本質上的意義卻是不同的。

以上面的 Rect 的例子來說,直覺上會認為是「Rect 繼承自 Shape」。然而,實際的繼承卻是發生在 Rect.prototypeShape.prototype 之間。

類似的情況也可以從 instanceof operator 的設計看得出來:

console.log(rect instanceof Rect); // true  
console.log(rect instanceof Shape); // true  

instanceof 實際判斷的是 constructor 的 prototype property 是否在 object 的 prototype chain 上。其意義非但不等於「object 是否由此 constructor 所生」,更遑論這兩者之間「是否存在 instance of 的關聯」。

而我想這之中的落差,造就了 inheritance with constructor(或者說是 pseudo-classical inheritance)在理解上的困難。縱使其意圖令 class-based OOPL 的開發者能夠從 class 的觀點來實作繼承,但結果顯然適得其反。

正如 Douglas CrockfordJSMinJSLint 開發者、提出 JSON 格式、且為《JavaScript: The Good Parts》一書作者)於 Prototypal Inheritance in JavaScript 一文中所述:

JavaScript's constructor pattern did not appeal to the classical crowd. It also obscured JavaScript's true prototypal nature. As a result, there are very few programmers who know how to use the language effectively.

JavaScript 的 constructor pattern 非但沒有討好 class 派,其亦遮掩了 JavaScript 純正的 prototype 本質。因此,只有非常少數的程式設計師瞭解該如何有效地運用這個語言。

謹記:JavaScript 沒有 class,只有 object。其繼承的本質僅是 object 與其 prototype 之間的關聯。而 pseudo-classical inheritance 不僅難以理解,且違背本質。一般情況下,回歸最單純的 prototypal inheritance 或許是比較好的選擇。

Further Reading

  • LinkedIn
  • Tumblr
  • Reddit
  • Google+
  • Pinterest
  • Pocket
Comments powered by Disqus