Go 中閉包的底層原理是?
1. 什么是閉包?
一個函數內引用了外部的局部變量,這種現象,就稱之為閉包。
例如下面的這段代碼中,adder 函數返回了一個匿名函數,而該匿名函數中引用了 adder 函數中的局部變量 sum ,那這個函數就是一個閉包。
- package main
- import "fmt"
- func adder() func(int) int {
- sum := 0
- return func(x int) int {
- sum += x
- return sum
- }
- }
而這個閉包中引用的外部局部變量并不會隨著 adder 函數的返回而被從棧上銷毀。
我們嘗試著調用這個函數,發現每一次調用,sum 的值都會保留在 閉包函數中以待使用。
- func main() {
- valueFunc:= adder()
- fmt.Println(valueFunc(2)) // output: 2
- fmt.Println(valueFunc(2)) // output: 4
- }
2. 復雜的閉包場景
寫一個閉包是比較容易的事,但單單會寫簡單的閉包函數,還遠遠不夠,如果不搞清楚閉包真正的原理,那很容易在一些復雜的閉包場景中對函數的執行邏輯進行誤判。
別的不說,就拿下來這個例子來說吧?
你覺得它會打印什么呢?
是 6 還是 11 呢?
- import "fmt"
- func func1() (i int) {
- i = 10
- defer func() {
- i += 1
- }()
- return 5
- }
- func main() {
- closure := func1()
- fmt.Println(closure)
- }
3. 閉包的底層原理?
還是以最上面的例子來分析
- package main
- import "fmt"
- func adder() func(int) int {
- sum := 0
- return func(x int) int {
- sum += x
- return sum
- }
- }
- func main() {
- valueFunc:= adder()
- fmt.Println(valueFunc(2)) // output: 2
- }
我們先對它進行逃逸分析,很容易發現 sum 作為 adder 函數局部變量,并不是分配在棧上,而是分配在堆上的。
這就解決了第一個疑惑:為什么 adder 函數返回后, sum 不會隨之銷毀?
- $ go build -gcflags="-m -m -l" demo.go
- # command-line-arguments
- ./demo.go:8:3: adder.func1 capturing by ref: sum (addr=true assign=true width=8)
- ./demo.go:7:9: func literal escapes to heap:
- ./demo.go:7:9: flow: ~r0 = &{storage for func literal}:
- ./demo.go:7:9: from func literal (spill) at ./demo.go:7:9
- ./demo.go:7:9: from return func literal (return) at ./demo.go:7:2
- ./demo.go:6:2: sum escapes to heap:
- ./demo.go:6:2: flow: {storage for func literal} = &sum:
- ./demo.go:6:2: from func literal (captured by a closure) at ./demo.go:7:9
- ./demo.go:6:2: from sum (reference) at ./demo.go:8:3
- ./demo.go:6:2: moved to heap: sum
- ./demo.go:7:9: func literal escapes to heap
- ./demo.go:15:23: valueFunc(2) escapes to heap:
- ./demo.go:15:23: flow: {storage for ... argument} = &{storage for valueFunc(2)}:
- ./demo.go:15:23: from valueFunc(2) (spill) at ./demo.go:15:23
- ./demo.go:15:23: flow: {heap} = {storage for ... argument}:
- ./demo.go:15:23: from ... argument (spill) at ./demo.go:15:13
- ./demo.go:15:23: from fmt.Println(valueFunc(2)) (call parameter) at ./demo.go:15:13
- ./demo.go:15:13: ... argument does not escape
- ./demo.go:15:23: valueFunc(2) escapes to heap
可另一個問題,又浮現出來了,就算它不會銷毀,那閉包函數若是存儲的若是 sum 拷貝后的值,那每次調用閉包函數,里面的 sum 應該都是一樣的,調用兩次都應該返回 2,而不是可以累加記錄。
因此,可以大膽猜測,閉包函數的結構體里存儲的是 sum 的指針。
為了驗證這一猜想,只能上匯編了。
通過執行下面的命令,可以輸出對應的匯編代碼
- go build -gcflags="-S" demo.go
輸出的內容相當之多,我提取出下面最關鍵的一行代碼,它定義了閉包函數的結構體。
其中 F 是函數的指針,但這不是重點,重點是 sum 存儲的確實是指針,驗證了我們的猜。
- type.noalg.struct { F uintptr; "".sum *int }(SB), CX
4. 迷題揭曉
有了上面第三節的背景知識,那對于第二節給出的這道題,想必你也有答案了。
首先,由于 i 在函數定義的返回值上聲明,因此根據 go 的 caller-save 模式, i 變量會存儲在 main 函數的棧空間。
然后,func1 的 return 重新把 5 賦值給了 i ,此時 i = 5
由于閉包函數存儲了這個變量 i 的指針。
因此最后,在 defer 中對 i 進行自增,是直接更新到 i 的指針上,此時 i = 5+1,所以最終打印出來的結果是 6
- import "fmt"
- func func1() (i int) {
- i = 10
- defer func() {
- i += 1
- }()
- return 5
- }
- func main() {
- closure := func1()
- fmt.Println(closure)
- }
5. 再度變題
上面那題聽懂了的話,再來看看下面這道題。
func1 的返回值我們不寫變量名 i 了,然后原先返回具體字面量,現在改成變量 i ,就是這兩小小小的改動,會導致運行結果大大不同,你可以思考一下結果。
- import "fmt"
- func func1() (int) {
- i := 10
- defer func() {
- i += 1
- }()
- return i
- }
- func main() {
- closure := func1()
- fmt.Println(closure)
- }
如果你在返回值里寫了變量名,那么該變量會存儲 main 的棧空間里,而如果你不寫,那 i 只能存儲在 func1 的棧空間里,與此同時,return 的值,不會作用于原變量 i 上,而是會存儲在該函數在另一塊棧內存里。
因此你在 defer 中對原 i 進行自增,并不會作用到 func1 的返回值上。
所以打印的結果,只能是 10。
你答對了嗎?
6. 最后一個問題
不知道你有沒有發現,在第一節示例中的 sum 是存儲在堆內存中的,而后面幾個示例都是存儲在棧內存里。
這是為什么呢?
仔細對比,不難發現,示例一返回的是閉包函數,閉包函數在 adder 返回后還要在其他地方繼續使用,在這種情況下,為了保證閉包函數的正常運行,無論閉包函數在哪里,i 都不能回收,所以 Go 編譯器會智能地將其分配在堆上。
而后面的其他示例,都只是涉及了閉包的特性,并不是直接把閉包函數返回,因此完全可以將其分配在棧上,非常的合理。
是不是很簡單呢?