動態組合物件取代繼承

實務上常會發現需要兩個物件的 method,但礙於 ECMAScript 只能 單一繼承 於 prototype,我們無法同時繼承兩個物件;但透過 Mixin,我們可實現類似 多重繼承 的功能。

Version


ECMAScript 2015

Definition


Mixin

將物件中所有 method 複製到其他物件,讓該物件馬上擁有新的 method

實務上我們可能會想要 code reuse 其他物件的 method,直覺會想到 繼承,但:

  1. ECMAScript 只能單一繼承於 prototype,若我想要 code reuse 到兩個以上的物件呢?
  2. 根據 里氏替換原則父類別能被子類別取代,也就是我們該以 多型 為前提使用繼承,而不該以 code reuse 使用繼承

但實務上的確有 code reuse 的需求,既然不能用 繼承,我們該用什麼呢 ?

答案就是:Mixin

Object Mixin


Mixin1.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const CircleMixin = {
area: function () {
return Math.PI * this.radius * this.radius;
},
};

const LogMixin = {
startLog: function () {
console.log("Start log...");
},

stopLog: function () {
console.log("End log...");
}
};

const Button = function (radius) {
this.radius = radius;
};

// class Button {
// constructor(radius) {
// this.radius = radius;
// }
// }

Object.assign(Button.prototype, CircleMixin, LogMixin);
// Button.prototype = {...Button.prototype, ...CircleMixin, ...LogMixin};

const button = new Button(5);
button.startLog();
console.log(button.area());
button.stopLog();

// Start log...
// 78.53981633974483
// End log...

第 1 行

1
2
3
4
5
const CircleMixin = {
area: function () {
return Math.PI * this.radius * this.radius;
},
};

宣告 CircleMixin,其本質為 object,擁有 area() method。

第 7 行

1
2
3
4
5
6
7
8
9
const LogMixin = {
startLog: function () {
console.log("Start log...");
},

stopLog: function () {
console.log("End log...");
}
};

宣告 LogMixin,其本質亦為 object,擁有 startLog()stopLog() method。

17 行

1
2
3
4
5
6
7
8
9
const Button = function (radius) {
this.radius = radius;
};

// class Button {
// constructor(radius) {
// this.radius = radius;
// }
// }

宣告 Button constructor function,也可使用 ECMAScript 2015 的 classconstructor

Class 與 construcor function 本質相同,只是 syntax sugar

27 行

1
Object.assign(Button.prototype, CircleMixin, LogMixin);

如今我們希望 Button class 同時有 CircleMixinarea(),又有 LogMixinstartLog()stopLog()

若使用繼承,ECMAScript 無法同時繼承 CircleMixinLogMixin

Button 無論繼承 CircleMixinLogMixin 都違反 里氏替換原則,因為 Button 並非 CircleMixinLogMixin 多型體系下的成員。

但透過 Object.assign(),我們能輕易將 CircleMixinLogMixin 的所有 method 複製到 Button.prototype,讓 Button class 瞬間有了新的 method。

28 行

1
// Button.prototype = {...Button.prototype, ...CircleMixin, ...LogMixin};

亦可使用 ECMAScript 2015 的 object spread operator,將所有物件 property 展開,重新合併重新的物件給 Button.prototype

30 行

1
2
3
4
const button = new Button(5);
button.startLog();
console.log(button.area());
button.stopLog();

經過 Mixin 之後,button 物件就有了 startLog()area()stopLog() 三個 method,重點還是來自於不同的 Mixin 物件。

我們可發現 Mixin 為 object,以 Object Composition 方式組合出新功能,與 GoF 所謂的 多用組合,少用繼承 想法不謀而合,同時也解決了 單一繼承里氏替換原則 所面臨的挑戰

Class Mixin


Mixin2.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const CircleMixin = base => class extends base {
area() {
return Math.PI * this.radius * this.radius;
}
};

const LogMixin = base => class extends base {
startLog() {
console.log("Start log...");
}

stopLog() {
console.log("End log...");
}
};

class Base {}

class Button extends LogMixin(CircleMixin(Base)) {
constructor(radius) {
super();
this.radius = radius;
}
}

const button = new Button(5);
button.startLog();
console.log(button.area());
button.stopLog();

// Start log...
// 78.53981633974483
// End log...

第 1 行

1
2
3
4
5
const CircleMixin = base => class extends base {
area() {
return Math.PI * this.radius * this.radius;
}
};

宣告 CircleMixin,其本質為 function,回傳擁有 area() 的 class。

第 7 行

1
2
3
4
5
6
7
8
9
const LogMixin = base => class extends base {
startLog() {
console.log("Start log...");
}

stopLog() {
console.log("End log...");
}
};

宣告 LoginMixin,其本質為 function,回傳擁有 startLog()stopLog() 的 class。

17 行

1
2
3
4
5
6
7
8
class Base {}

class Button extends LogMixin(CircleMixin(Base)) {
constructor(radius) {
super();
this.radius = radius;
}
}

extends 透過 CircleMixin()LogMixin(),達成類似 多重繼承 的效果。

語法上雖然看似 繼承,實則為 Class Composition,與 Object Mixin 差異在於:

  • Object Mixin 是建立 object 後再 組合 object
  • Class Mixin 是先組合 class 再建立 object

26 行

1
2
3
4
const button = new Button(5);
button.startLog();
console.log(button.area());
button.stopLog();

對 client 而言,使用 Object Mixin 與 Class Mixin ,用起來都一樣。

Functional Mixin


Mixin3.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const CircleMixin = function () {
this.area = function () {
return Math.PI * this.radius * this.radius;
};
};

const LogMixin = function () {
this.startLog = function () {
console.log("Start log...");
};

this.stopLog = function () {
console.log("End log...");
};
};

const Button = function (radius) {
this.radius = radius;
};

// class Button {
// constructor(radius) {
// this.radius = radius;
// }
// }

CircleMixin.call(Button.prototype);
LogMixin.call(Button.prototype);

const button = new Button(5);
button.startLog();
console.log(button.area());
button.stopLog();

// Start log...
// 78.53981633974483
// End log...

第 1 行

1
2
3
4
5
const CircleMixin = function () {
this.area = function () {
return Math.PI * this.radius * this.radius;
};
};

CircleMixin 由 object 改成 function。

使用 this.area 宣告物件的 method。

第 7 行

1
2
3
4
5
6
7
8
9
const LogMixin = function () {
this.startLog = function () {
console.log("Start log...");
};

this.stopLog = function () {
console.log("End log...");
};
};

LoginMixin 由 object 改成 function。

使用 this.startLogthis.stopLog 宣告物件的 method。

17 行

1
2
3
4
5
6
7
8
9
const Button = function (radius) {
this.radius = radius;
};

// class Button {
// constructor(radius) {
// this.radius = radius;
// }
// }

宣告 Button constructor function,也可使用 ECMAScript 2015 的 classconstructor

27 行

1
2
CircleMixin.call(Button.prototype);
LogMixin.call(Button.prototype);

CircleMixin()LoginMixin() 中都使用了 this,在 ECMAScript 中,最重要的就是 this 到底是誰 ?

我們使用 Function.call()this 指向 Button.prototype,因此 area()startLog()stopLog() 就自然成為 Button 的 method。

30 行

1
2
3
4
const button = new Button(5);
button.startLog();
console.log(button.area());
button.stopLog();

對 client 而言,無論使用 Object Mixin 、Class Mixin 或 Functional Mixin,用起來都一樣。

Functional Mixin 使用了 this,因此必須搭配 Function.call() 指定 this 為何物件

Summary


Q:Mixin 的價值何在?

  1. 解決 單一繼承 所面臨的難題
  2. 解決 里氏替換原則繼承 用在 多型 的限制

Q:Mixin 實務上該用在哪裡 ?

  1. 單純為了 code reuse,且之間並沒有 多型 的關係,因此不適合使用 繼承
  2. 需要實現 Composition

Conclusion


  • 有別於 繼承,Mixin 提供了以 Composition 為基礎的解決方案
  • Functional Mixin 需要有 thiscall 觀念,門檻較高

Sample Code


完整的範例可以在我的 GitHub 上找到

Reference


Angus Croll, A fresh look at JavaScript Mixins
MDN web docs, Classes

2018-05-06