原文連結:Go 語言閉包詳解
前言
什麼是閉包?閉包是由函式和與其相關的引用環境組合而成的實體。
下面就來通過幾個例子來說明 Go 語言中的閉包以及由閉包引用產生的問題。
函式變數(函式值)
在說明閉包之前,先來了解一下什麼是函式變數。
在 Go 語言中,函式被看作是第一類值,這意味著函式像變數一樣,有型別、有值,其他普通變數能做的事它也可以。
func square(x int) {
println(x * x)
}
複製程式碼
- 直接呼叫:
square(1)
- 把函式當成變數一樣賦值:
s := square
;接著可以呼叫這個函式變數:s(1)
。 注意:這裡square
後面沒有圓括號,呼叫才有。
- 呼叫
nil
的函式變數會導致 panic。 - 函式變數的零值是
nil
,這意味著它可以跟nil
比較,但兩個函式變數之間不能比較。
閉包
現在開始通過例子來說明閉包:
func incr() func() int {
var x int
return func() int {
x++
return x
}
}
複製程式碼
呼叫這個函式會返回一個函式變數。
i := incr()
:通過把這個函式變數賦值給 i
,i
就成為了一個閉包。
所以 i
儲存著對 x
的引用,可以想象 i 中有著一個指標指向 x 或 i 中有 x 的地址。
由於 i
有著指向 x
的指標,所以可以修改 x
,且保持著狀態:
println(i()) // 1
println(i()) // 2
println(i()) // 3
複製程式碼
也就是說,x
逃逸了,它的生命週期沒有隨著它的作用域結束而結束。
但是這段程式碼卻不會遞增:
println(incr()()) // 1
println(incr()()) // 1
println(incr()()) // 1
複製程式碼
這是因為這裡呼叫了三次 incr()
,返回了三個閉包,這三個閉包引用著三個不同的 x
,它們的狀態是各自獨立的。
閉包引用
現在開始通過例子來說明由閉包引用產生的問題:
x := 1
f := func() {
println(x)
}
x = 2
x = 3
f() // 3
複製程式碼
因為閉包對外層詞法域變數是引用的,所以這段程式碼會輸出 3。
可以想象 f
中儲存著 x
的地址,它使用 x
時會直接解引用,所以 x
的值改變了會導致 f
解引用得到的值也會改變。
但是,這段程式碼卻會輸出 1:
x := 1
func() {
println(x) // 1
}()
x = 2
x = 3
複製程式碼
把它轉換成這樣的形式就容易理解了:
x := 1
f := func() {
println(x)
}
f() // 1
x = 2
x = 3
複製程式碼
這是因為 f
呼叫時就已經解引用取值了,這之後的修改就與它無關了。
不過如果再次呼叫 f
還是會輸出 3,這也再一次證明了 f
中儲存著 x
的地址。
可以通過在閉包內外列印所引用變數的地址來證明:
x := 1
func() {
println(&x) // 0xc0000de790
}()
println(&x) // 0xc0000de790
複製程式碼
可以看到引用的是同一個地址。
迴圈閉包引用
接下來在三個例子中說明由迴圈內的閉包引用所產生的問題:
第一個例子
for i := 0; i < 3; i++ {
func() {
println(i) // 0, 1, 2
}()
}
複製程式碼
這段程式碼相當於:
for i := 0; i < 3; i++ {
f := func() {
println(i) // 0, 1, 2
}
f()
}
複製程式碼
每次迭代後都對 i
進行了解引用並使用得到的值且不再使用,所以這段程式碼會正常輸出。
第二個例子
正常程式碼:輸出 0, 1, 2:
var dummy [3]int
for i := 0; i < len(dummy); i++ {
println(i) // 0, 1, 2
}
複製程式碼
然而這段程式碼會輸出 3:
var dummy [3]int
var f func()
for i := 0; i < len(dummy); i++ {
f = func() {
println(i)
}
}
f() // 3
複製程式碼
前面講到閉包取引用,所以這段程式碼應該輸出 i 最後的值 2 對吧?
不對。這是因為 i
最後的值並不是 2。
把迴圈轉換成這樣的形式就容易理解了:
var dummy [3]int
var f func()
for i := 0; i < len(dummy); {
f = func() {
println(i)
}
i++
}
f() // 3
複製程式碼
i
自加到 3 才會跳出迴圈,所以迴圈結束後 i
最後的值為 3。
所以用 for range
來實現這個例子就不會這樣:
var dummy [3]int
var f func()
for i := range dummy {
f = func() {
println(i)
}
}
f() // 2
複製程式碼
這是因為 for range
和 for
底層實現上的不同。
第三個例子
var funcSlice []func()
for i := 0; i < 3; i++ {
funcSlice = append(funcSlice, func() {
println(i)
})
}
for j := 0; j < 3; j++ {
funcSlice[j]() // 3, 3, 3
}
複製程式碼
輸出序列為 3, 3, 3。
看了前面的例子之後這裡就容易理解了:
這三個函式引用的都是同一個變數(i
)的地址,所以之後 i
遞增,解引用得到的值也會遞增,所以這三個函式都會輸出 3。
新增輸出地址的程式碼可以證明:
var funcSlice []func()
for i := 0; i < 3; i++ {
println(&i) // 0xc0000ac1d0 0xc0000ac1d0 0xc0000ac1d0
funcSlice = append(funcSlice, func() {
println(&i)
})
}
for j := 0; j < 3; j++ {
funcSlice[j]() // 0xc0000ac1d0 0xc0000ac1d0 0xc0000ac1d0
}
複製程式碼
可以看到三個函式引用的都是 i
的地址。
解決方法
1. 宣告新變數:
- 宣告新變數:
j := i
,且把之後對i
的操作改為對j
操作。 - 宣告新同名變數:
i := i
。注意:這裡短宣告右邊是外層作用域的i
,左邊是新宣告的作用域在這一層的i
。原理同上。
這相當於為這三個函式各宣告一個變數,一共三個,這三個變數初始值分別對應迴圈中的 i
並且之後不會再改變。
2. 宣告新匿名函式並傳參:
var funcSlice []func()
for i := 0; i < 3; i++ {
func(i int) {
funcSlice = append(funcSlice, func() {
println(i)
})
}(i)
}
for j := 0; j < 3; j++ {
funcSlice[j]() // 0, 1, 2
}
複製程式碼
現在 println(i)
使用的 i
是通過函式引數傳遞進來的,並且 Go 語言的函式引數是按值傳遞的。
所以相當於在這個新的匿名函式內宣告瞭三個變數,被三個閉包函式獨立引用。原理跟第一種方法是一樣的。
這裡的解決方法可以用在大多數跟閉包引用有關的問題上,不侷限於第三個例子。