這個 bug 很經典,每個學 JavaScript 的人都踩過:
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 你預期印 0, 1, 2
}, 100);
}
// 實際印出:3, 3, 3你想印 0, 1, 2,卻印出三個 3。
這不是 setTimeout 的問題,是 var 的 scope 和 closure 怎麼互動的問題。理解這個,才能解釋為什麼換成 let 就修好了。
Scope 是什麼
Scope 決定一個名字(變數、函式)在哪個範圍內有效。超出那個範圍,你用那個名字會得到錯誤或意外的值。
Lexical scope(靜態 scope):大多數現代語言的選擇。名字在哪裡定義,它的 scope 就在那裡,和執行時誰呼叫無關。
x = 10
def outer():
x = 20
def inner():
print(x) # 印 20,找的是 lexical 上最近的 x
inner()
outer()Python、JavaScript、Go、Ruby——都是 lexical scope。
Dynamic scope:名字的 scope 取決於執行時的 call stack,不是定義的位置。Perl 的 local、Emacs Lisp 的預設行為是這樣。Dynamic scope 在現代後端語言裡幾乎不存在了,但它解釋了為什麼 lexical scope 是顯而易見的選擇——dynamic scope 讓你沒辦法只看函式定義就知道它在用哪個 x。
各語言的 Scope 差異
Block scope vs Function scope
這是製造最多 bug 的差異:
// var:function scope,不是 block scope
function example() {
if (true) {
var x = 10;
}
console.log(x); // 10,x 在整個 function 都有效
}
// let/const:block scope
function example2() {
if (true) {
let y = 10;
}
console.log(y); // ReferenceError,y 只在 if block 裡有效
}Python 沒有 block scope——if、for、while 裡定義的變數,在整個函式都有效:
for i in range(3):
x = i
print(x) # 2,不會報錯,x 從 for 裡「洩漏」出來Go、Java、C# 有 block scope——{ 和 } 之間定義的變數,出了 block 就不見了。
Hoisting(JavaScript 特有的反直覺行為)
JavaScript 的 var 宣告會被「提升」到函式頂部,但值不會:
console.log(x); // undefined,不是 ReferenceError
var x = 5;
console.log(x); // 5JavaScript 引擎實際執行的是:
var x; // 宣告提升到頂部
console.log(x); // undefined
x = 5; // 值的賦值留在原地
console.log(x); // 5let 和 const 有 Temporal Dead Zone(TDZ)——在宣告之前存取會直接 ReferenceError,不會 undefined。這個行為更合理,讓錯誤更早爆出來。
Closure 是什麼
Closure 需要語言支援「函式可以被當作值傳遞」(first-class function)。C 沒有 closure,Java 8 之前沒有,Fortran 也沒有。後端主流語言——Go、Python、JavaScript、Java 8+、Rust——都有。
Closure 是「一個函式,加上它定義時的 lexical environment」。
當你在一個函式裡定義另一個函式,內部函式能「記住」外部函式的變數,即使外部函式已經執行完畢:
def make_counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
print(counter()) # 3
# make_counter 已經執行完了,但 count 還活著count 本來應該在 make_counter 執行完後消失,但因為 increment 關閉了(closed over)它,count 繼續存活。
回到開頭的 bug
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i);
}, 100);
}var i 是 function scope,整個 for 迴圈只有一個 i。三個 callback 全部 close over 同一個 i。100ms 後 for 跑完,i 是 3,三個 callback 印的都是 3。
修法一:改成 let,let 是 block scope,每次迴圈有自己的 i:
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 0, 1, 2
}, 100);
}修法二(ES5 時代的做法):用 IIFE 建一個新的 scope:
for (var i = 0; i < 3; i++) {
(function(j) {
setTimeout(function() {
console.log(j); // 0, 1, 2
}, 100);
})(i);
}Go 的 Closure 陷阱
Go 也有 closure,也有類似的 loop variable 問題(1.22 之前):
// Go 1.21 以前
funcs := make([]func(), 3)
for i := 0; i < 3; i++ {
funcs[i] = func() {
fmt.Println(i) // 三個都印 3
}
}Go 1.22 之後,每次迴圈的 i 是獨立的,這個行為改了。這是一個語言演進修正 closure 語意的真實案例。
Closure 的實際應用場景
Closure 不只是理論——它在後端開發裡有幾個具體的用途:
封裝狀態(不用 class)
def make_rate_limiter(max_requests_per_second):
requests = []
def is_allowed():
now = time.time()
requests[:] = [r for r in requests if now - r < 1.0]
if len(requests) >= max_requests_per_second:
return False
requests.append(now)
return True
return is_allowed
limiter = make_rate_limiter(10)Middleware / Decorator 的實作基礎
func WithLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("Request: %s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r) // next 被 closure 捕獲
})
}每個語言的 middleware 模式,底層都是 closure——把下一個 handler 關進去,包上一層邏輯,返回新的 handler。