Go 語言閉包詳解

sulinehk發表於2019-03-10

原文連結:Go 語言閉包詳解

前言

Go 語言閉包詳解
Go 語言閉包詳解

什麼是閉包?閉包是由函式和與其相關的引用環境組合而成的實體。

下面就來通過幾個例子來說明 Go 語言中的閉包以及由閉包引用產生的問題。

函式變數(函式值)

在說明閉包之前,先來了解一下什麼是函式變數

在 Go 語言中,函式被看作是第一類值,這意味著函式像變數一樣,有型別、有值,其他普通變數能做的事它也可以。

func square(x int) {
	println(x * x)
}
複製程式碼
  1. 直接呼叫:square(1)
  2. 把函式當成變數一樣賦值:s := square;接著可以呼叫這個函式變數:s(1)注意:這裡 square 後面沒有圓括號,呼叫才有。
  • 呼叫 nil 的函式變數會導致 panic。
  • 函式變數的零值是 nil,這意味著它可以跟 nil 比較,但兩個函式變數之間不能比較。

閉包

現在開始通過例子來說明閉包:

func incr() func() int {
	var x int
	return func() int {
		x++
		return x
	}
}
複製程式碼

呼叫這個函式會返回一個函式變數。

i := incr():通過把這個函式變數賦值給 ii 就成為了一個閉包

所以 i 儲存著對 x 的引用,可以想象 i 中有著一個指標指向 xi 中有 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 rangefor 底層實現上的不同。

第三個例子

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 語言的函式引數是按值傳遞的。

所以相當於在這個新的匿名函式內宣告瞭三個變數,被三個閉包函式獨立引用。原理跟第一種方法是一樣的。

這裡的解決方法可以用在大多數跟閉包引用有關的問題上,不侷限於第三個例子。

參考連結

Go 語言聖經 - 匿名函式

相關文章