這個 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——ifforwhile 裡定義的變數,在整個函式都有效:

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);  // 5

JavaScript 引擎實際執行的是:

var x;           // 宣告提升到頂部
console.log(x);  // undefined
x = 5;           // 值的賦值留在原地
console.log(x);  // 5

letconst 有 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 跑完,i3,三個 callback 印的都是 3

修法一:改成 letlet 是 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。

下一篇:記憶體管理模型