Day 29:閉包 (Closures) 進階打怪實戰
昨天介紹了基本的閉包用法,本篇就來看一些比較進階的閉包應用,或是情境比較複雜的例子。

模擬 Class 物件導向用法中的私有成員變數效果

用過其他 Class-based 物件導向語言的開發者,對於 private 用法一定不陌生。
例如以下是一個 Java 的私有成員變數範例:
1
class Person{
2
private String name;
3
public Person(String n) { this.name = n; }
4
public String getName() { return this.name; }
5
public void setName(String n) { this.name = n; }
6
public String sayHi() { return "Hi I am " + this.name; }
7
}
8
9
public class HelloWorld{
10
public static void main(String []args){
11
Person p1 = new Person("OneJar");
12
Person p2 = new Person("Tony Stark");
13
System.out.println(p1.sayHi());
14
System.out.println(p2.sayHi());
15
16
p2.setName("Steven Rogers");
17
System.out.println(p2.sayHi());
18
}
19
}
Copied!
執行結果:
1
Hi I am OneJar
2
Hi I am Tony Stark
3
Hi I am Steven Rogers
Copied!
類別 Person 的成員變數 name 被設為私有 (private) 屬性,類別外的使用者無法直接對 name 作存取,必須透過開放的函式,例如 getName()setName()sayHi()
這樣的好處是可以確保 name 的資料安全性,能對 name 作什麼程度的資料操控,取決於 Person 類別願意開放多少動作函式。
JavaScript 並非傳統 Class-based 物件導向語言,缺乏這種語法支援。
雖然可以用 new 關鍵字搭配函數建構子 (Function Constructors) 的用法,模擬類似物件導向效果,但仍缺乏私有成員變數的效果,無法限制外部直接對成員變數作存取。
例如下面這個範例:
1
function Person(n) {
2
this.name = n;
3
this.sayHi = function(){
4
return "Hi, I'm " + this.name;
5
}
6
}
7
8
var p1 = new Person("OneJar");
9
var p2 = new Person("Tony Stark");
10
console.log(p1.sayHi()); // "Hi, I'm OneJar"
11
console.log(p2.sayHi()); // "Hi, I'm Tony Stark"
12
13
p2.name = "Steven Rogers"; // (外部仍可以直接存取)
14
console.log(p2.sayHi()); // "Hi, I'm Steven Rogers"
Copied!
ES6 提供了 Class 的語法,但只是一種語法糖,讓語法看起來和傳統 Class-based 寫法相像,本質仍是傳統 JavaScript,而非傳統 Class-based 物件導向。
例如下面是 ES6 的 Class 寫法,仍舊可以直接對 name 屬性作存取:
1
class Person {
2
constructor(name) {
3
this.name = name;
4
}
5
sayHi(){
6
return `Hi I am ${this.name}`;
7
}
8
}
9
10
var p1 = new Person("OneJar");
11
var p2 = new Person("Tony Stark");
12
console.log(p1.sayHi()); // "Hi, I'm OneJar"
13
console.log(p2.sayHi()); // "Hi, I'm Tony Stark"
14
15
p2.name = "Steven Rogers"; // (外部仍可以直接存取)
16
console.log(p2.sayHi()); // "Hi, I'm Steven Rogers"
Copied!
但是如果透過閉包,就能模擬出類似私有成員的效果:
1
function createPerson(name){
2
var methods = {
3
getName: function() { return name; },
4
setName: function(n) { name = n; },
5
sayHi: function() { return `Hi I am ${name}`; }
6
}
7
return methods;
8
}
9
10
var p1 = createPerson('OneJar');
11
var p2 = createPerson('Tony Stark');
12
13
console.log(p1.sayHi()); // "Hi I am OneJar"
14
console.log(p2.sayHi()); // "Hi I am Tony Stark"
15
16
p2.setName('Steven Rogers');
17
console.log(p2.sayHi()); // "Hi I am Steven Rogers"
Copied!
  • 變數 name 可以持續存活於閉包環境,不會因函數結束而失效。
  • 可以對 name 作什麼程度的操控,取決於定義時願意開放多少動作函數。例如想修改 name,就一定要透過 setName()
  • 每個閉包引用的都是獨立的環境,因此 p1p2 不互相干擾。

閉包引用外層函數變數的混淆範例

下面是偶然看到的閉包範例,覺得非常有趣。

範例 1

1
function buildFunctions() {
2
var arr = [];
3
4
for(var i = 0; i < 3; i++) {
5
arr.push(function() {
6
console.log(i);
7
});
8
}
9
10
return arr;
11
}
12
13
var fs = buildFunctions();
14
fs[0]();
15
fs[1]();
16
fs[2]();
Copied!
fs 是一個陣列,儲存了 3 個函數,各別呼叫會印出什麼?
我第一眼看過去,直覺以為會印出 012,因為不是說每個閉包都是獨立的環境嗎?
執行結果:
1
3
2
3
3
3
Copied!
(Source: 白爛貓貼圖)
趕快出動我的銀色子彈,分析一下發生什麼事。
1. 呼叫 buildFunctions() ,宣告陣列變數:
1
function buildFunctions() {
2
var arr = [];
3
...........
4
}
5
6
var fs = buildFunctions();
7
..........
Copied!
  • 陣列在 JavaScript 裡是 Object 型別,所以 arr 變數盒子裡存放的會是一個位址,引用到陣列實際的資料位置。
2. 進入 for 迴圈,當 for 迴圈的 i = 0
1
function buildFunctions() {
2
var arr = [];
3
for(var i = 0; i < 3; i++) {
4
arr.push(function() {
5
console.log(i);
6
});
7
}
8
.................
9
}
10
11
................
Copied!
  • 宣告變數 i 作為計數器。
  • 建立一個新的函數物件,存放在匿名盒子 0x101
  • 0x101 函數物件內容的 console.log(i),會引用變數 i
  • arr[0] 會引用 0x101
3. 當 for 迴圈的 i = 1
  • 建立一個新的函數物件,存放在匿名盒子 0x102
  • 0x102 函數物件內容的 console.log(i),會引用變數 i
  • arr[1] 會引用 0x102
4. 當 for 迴圈的 i = 2
  • 建立一個新的函數物件,存放在匿名盒子 0x103
  • 0x103 函數物件內容的 console.log(i),會引用變數 i
  • arr[2] 會引用 0x103
5. 當 for 迴圈的 i = 3,離開迴圈:
  • 變數 i 的值會再被加一,所以 i 的內容是 3,然後才跳離迴圈。
6. 回傳 arr 到 Global 環境:
1
function buildFunctions() {
2
............
3
return arr;
4
}
5
6
var fs = buildFunctions();
7
.............
Copied!
  • Global 環境宣告變數 fs,承接 arr 所存的位址 0x002
  • 變數 fs0x002 建立引用關係。
7. Local 環境結束,回收用不到的盒子:
1
................
2
3
var fs = buildFunctions();
4
fs[0]();
5
fs[1]();
6
fs[2]();
Copied!
  • 0x001 的變數名稱 arr 失效,變成匿名盒子,且沒有被任何人引用,回收。
  • 0x003 的變數名稱 i 失效,變成匿名盒子,但因為還被其他人引用,因此繼續存活。
  • 0x002 原本就是匿名盒子,原本的引用者 arr 被回收,但仍被 Global 變數 fs 引用,因此繼續存活。
  • fs[0]fs[1]fs[2] 裡面的 console.log(i) 都是引用 0x003,因此印出來都是 3

範例 2

延續上面的範例,如果希望印出來是 012 呢?
可以利用 let 宣告一個 Block Scope 的變數來達到效果:
1
function buildFunctions() {
2
var arr = [];
3
4
for(var i = 0; i < 3; i++) {
5
let j = i; // 用 `let` 宣告變數 `j`
6
arr.push(function() {
7
console.log(j); // 這裡引用變數 `j`
8
});
9
}
10
11
return arr;
12
}
13
14
var fs = buildFunctions();
15
fs[0]();
16
fs[1]();
17
fs[2]();
Copied!
執行結果:
1
0
2
1
3
2
Copied!
為什麼這樣就能印出 012
1. 呼叫 buildFunctions(),宣告陣列變數:
1
function buildFunctions() {
2
var arr = [];
3
...........
4
}
5
6
var fs = buildFunctions();
7
..........
Copied!
  • 這裡和範例 1 沒有差別。
2. 進入 for 迴圈,當 for 迴圈的 i = 0,建立新的 Block Scope:
1
function buildFunctions() {
2
..................
3
for(var i = 0; i < 3; i++) {
4
let j = i;
5
...................
6
}
7
.......................
8
}
9
10
................
Copied!
  • var 宣告變數 i 作為計數器,i 屬於 Function Scope。
  • let 宣告變數 j因為用 let 宣告,會產生一個新的 Block Scope
  • j 複製一份當前 i 的值。
3. 當 for 迴圈的 i = 0,產生新的函數物件:
1
function buildFunctions() {
2
.....................
3
for(var i = 0; i < 3; i++) {
4
let j = i;
5
arr.push(function() {
6
console.log(j);
7
});
8
}
9
................
10
}
11
12
.............
Copied!
  • 建立一個新的函數物件,存放在匿名盒子 0x101
  • 0x101 函數物件內容的 console.log(j),會引用變數 j
  • arr[0] 會引用 0x101
4. 當 for 迴圈的 i = 0 結束,Block Scope 失效:
  • 0x004 的變數名稱 j 失效,變成匿名盒子,但因為還被其他人引用,因此繼續存活。
5. 當 for 迴圈的 i = 1,建立新的 Block Scope:
1
function buildFunctions() {
2
..................
3
for(var i = 0; i < 3; i++) {
4
let j = i;
5
...................
6
}
7
.......................
8
}
9
10
................
Copied!
  • 再次用 let 在新的 Block Scope 宣告變數 j,儲存當前 i 的值。
  • i = 0 時是不同的 Block Scope,所以 j 的變數資料互相無關。
6. 當 for 迴圈的 i = 1,產生新的函數物件:
1
function buildFunctions() {
2
.....................
3
for(var i = 0; i < 3; i++) {
4
let j = i;
5
arr.push(function() {
6
console.log(j);
7
});
8
}
9
................
10
}
11
12
.............
Copied!
  • 建立一個新的函數物件,存放在匿名盒子 0x102
  • 0x102 函數物件內容的 console.log(j),會引用變數 j
  • arr[1] 會引用 0x102
7. 當 for 迴圈的 i = 1 結束,Block Scope 失效:
  • 0x005 的變數名稱 j 失效,變成匿名盒子,但因為還被其他人引用,因此繼續存活。
8. 當 for 迴圈的 i = 3,離開迴圈:
  • i = 2 的情況依此類推。
  • i = 3 時,然後跳離迴圈。
9. 將 arr 回傳到 Global 環境:
1
function buildFunctions() {
2
............
3
return arr;
4
}
5
6
var fs = buildFunctions();
7
.............
Copied!
  • Global 環境宣告變數 fs,承接 arr 所存的位址 0x002
10. Local 環境結束,回收用不到的盒子:
1
................
2
3
var fs = buildFunctions();
4
fs[0]();
5
fs[1]();
6
fs[2]();
Copied!
  • 0x001 的變數名稱 arr 失效,變成匿名盒子,且沒有被任何人引用,回收。
  • 0x003 的變數名稱 i 失效,變成匿名盒子,且沒有被任何人引用,回收。
  • 0x002 是匿名盒子,被 Global 變數 fs 引用,繼續存活。
  • fs[0]fs[1]fs[2] 裡面的 console.log(j) 各自引用 0x0040x0050x006,因此印出結果是 012

範例 3

如果範例 2 不是用 let 宣告變數 j,改用 var,其他程式碼都不變:
1
function buildFunctions() {
2
var arr = [];
3
4
for(var i = 0; i < 3; i++) {
5
var j = i; // 改用 `var` 宣告
6
arr.push(function() {
7
console.log(j);
8
});
9
}
10
11
return arr;
12
}
13
14
fs2 = buildFunctions();
15
16
fs2[0]();
17
fs2[1]();
18
fs2[2]();
Copied!
會印出 012 還是 333 呢?
執行結果:
1
2
2
2
3
2
Copied!
(Source: 網路圖片)
為什麼?
這是因為變數宣告的 Hoisting 效果,會將變數宣告提到 Scope 最頂端,而用 var 宣告的變數屬於 Function Scope Level,所以變數 ij 都相當於宣告在函數一開始:
1
function buildFunctions() {
2
var arr = [];
3
var i, j; // 因為 Hoisting 效果,相當於宣告在這
4
for(i = 0; i < 3; i++) {
5
j = i;
6
arr.push(function() {
7
console.log(j);
8
});
9
}
10
11
return arr;
12
}
Copied!
所以範例 3 和 範例 1 的狀況是相近的,在函數 buildFunctions() 內只會產生一次 j 的變數盒子,三個閉包函數都是引用同一個 j 的資料盒子,所以印出來的結果都一樣。
而之所以印出 2 而非 3,是 for 迴圈的關係,j = i 在 for 迴圈內執行,但當 i = 3 時並不會進入迴圈內,因此 j 會停留在 2
這個範例原理和範例 1 類似,就不附分解示意圖 (用手畫可能不用 1 分鐘,畫成投影片超乎想像地費時……Orz)。

總結

變數的引用 (References) 相對抽象,需要自己想像變數間的牽連。
閉包概念就是建立在函數和引用的基礎上,而程式碼可能組成的情境又是千變萬化,一個疏忽可能就想錯了結果。
為了確保自己不會想錯程式邏輯,找到一個套路,類似數學公式的效果,幫助開發過程不管遇到什麼程式碼情境,都能套用同一套思考模式來導出正確的行為,也就是我暱稱的銀色子彈。
從這篇文章的範例來看,銀色子彈的效果還不錯,可以避免自己一些似是而非的直覺性邏輯。

References