在真正講述閉包之前,我們先鋪墊一點知識點:
【相關推薦:Go影片教學】
1.1 前提知識鋪墊
函數式程式設計是一種程式設計範式,看待問題的一種方式,每一個函數都是為了用小函數組織成為更大的函數,函數的參數也是函數,函數回傳的也是函數。我們常見的程式設計範式有:
函數式程式設計可以認為是物件導向程式設計的對立面,一般只有一些程式語言會強調一種特定的程式設計方式,大多數的語言都是多範式語言,可以支援多種不同的程式設計方式,例如JavaScript ,Go 等。
函數式程式設計是一種思考方式,將電腦運算視為函數的計算,是一種寫程式碼的方法論,其實我應該聊函數式程式設計,然後再聊到閉包,因為閉包本身就是函數式程式設計裡面的一個特點之一。
在函數式程式設計中,函數是頭等物件,意思是說一個函數,既可以作為其它函數的輸入參數值,也可以從函數中傳回值,被修改或被指派給一個變數。 (維基百科)
一般純函數程式語言是不允許直接使用程式狀態以及可變物件的,函數式程式設計本身就是要避免使用共享狀態,可變狀態,盡可能避免產生副作用。
函數式程式設計一般有以下特點:
函數是第一等公民:函數的地位放在第一位,可以當作參數,可以賦值,可以傳遞,可以當做回傳值。
沒有副作用:函數要保持純粹獨立,不能修改外部變數的值,不修改外部狀態。
引用透明:函數運行不依賴外部變數或狀態,相同的輸入參數,任何情況,所得到的回傳值都應該是一樣的。
#作用域(scope),程式設計概念,通常來說,一段程式碼中所用到的名字並不總是有效/可用的,而限定這個名字的可用性的代碼範圍就是這個名字的作用域。
簡單易懂的說,函數作用域是指函數可以運作的範圍。函數有點像盒子,一層套一層,作用域我們可以理解為是個封閉的盒子,也就是函數的局部變量,只能在盒子內部使用,成為獨立作用域。
函數內的局部變數,出了函數就跳出了作用域,找不到該變數。 (裡層函數可以使用外層函數的局部變量,因為外層函數的作用域包括了裡層函數),例如下面的innerTmep
出了函數作用域就找不到該變量,但是outerTemp
在內層函數裡面還是可以使用。
不管是任何語言,基本上存在一定的記憶體回收機制,也就是回收用不到的記憶體空間,回收的機制一般和上面說的函數的作用域是相關的,局部變數出了其作用域,就有可能被回收,如果還被引用著,那麼就不會被回收。
所謂作用域繼承,就是前面說的小盒子可以繼承外層大盒子的作用域,在小盒子可以直接取出大盒子的東西,但是大盒子不能取出小盒子的東西,除非發生了逃逸(逃逸可以理解為小盒子的東西給出了引用,大盒子拿到就可以使用)。一般而言,變數的作用域有以下兩種:
全域作用域:作用於任何地方
函數內部宣告/定義的變數叫做局部變數,作用域僅限於函數內部
1.2 閉包的定義
「多數情況下我們並不是先理解後定義,而是先定義後理解“,先下定義,讀不懂沒關係:
閉包(closure)是一句話表達:一個函數以及其捆綁的周邊環境狀態(lexical environment,詞法環境)的引用的組合。換而言之,閉包讓開發者可以從內部函數存取外部函數的作用域。閉包會隨著函數的建立而同時建立。
以上定義找不到Go語言這幾個字眼,聰明的同學一定知道,閉包是和語言無關的,不是JavaScript 特有的,也不是Go 特有的,而是函數式程式語言的獨特的,是的,你沒有看錯,任何支援函數式程式設計的語言都支援閉包,Go 和JavaScript 就是其中之二, 目前Java 目前版本也是支援閉包的,但有些人可能認為不是完美的閉包,詳細情況文中討論。
1.3 閉包的寫法
下面是一段閉包的程式碼:
import "fmt" func main() { sumFunc := lazySum([]int{1, 2, 3, 4, 5}) fmt.Println("等待一会") fmt.Println("结果:", sumFunc()) } func lazySum(arr []int) func() int { fmt.Println("先获取函数,不求结果") var sum = func() int { fmt.Println("求结果...") result := 0 for _, v := range arr { result = result + v } return result } return sum }
輸出的結果:
先获取函数,不求结果 等待一会 求结果... 结果: 15
可以看出,裡面的sum()
方法可以引用外部函數lazySum()
的參數以及局部變量,在lazySum()
返回函數sum()
的時候,相關的參數和變數都保存在傳回的函數中,可以之後再進行調用。
上面的函數或許還可以更進一步,體現出捆綁函數和周圍的狀態,我們加上一個次數count
:
import "fmt" func main() { sumFunc := lazySum([]int{1, 2, 3, 4, 5}) fmt.Println("等待一会") fmt.Println("结果:", sumFunc()) fmt.Println("结果:", sumFunc()) fmt.Println("结果:", sumFunc()) }func lazySum(arr []int) func() int { fmt.Println("先获取函数,不求结果") count := 0 var sum = func() int { count++ fmt.Println("第", count, "次求结果...") result := 0 for _, v := range arr { result = result + v } return result } return sum }
上面程式碼輸出什麼呢?次數count
會不會發生變化,count
明顯是外層函數的局部變量,但是在記憶體函數引用(捆綁),內層函數被暴露出去了,執行結果如下:
先获取函数,不求结果 等待一会 第 1 次求结果... 结果: 15 第 2 次求结果... 结果: 15 第 3 次求结果... 结果: 15
結果是count
其實每次都會變化,這種情況總結一下:
此時有人可能有疑問了,前面是lazySum()
被創建了1 次,執行了3 次,但是如果是3 次執行都是不同的創建,會是怎麼樣呢?實驗一下:
import "fmt" func main() { sumFunc := lazySum([]int{1, 2, 3, 4, 5}) fmt.Println("等待一会") fmt.Println("结果:", sumFunc()) sumFunc1 := lazySum([]int{1, 2, 3, 4, 5}) fmt.Println("等待一会") fmt.Println("结果:", sumFunc1()) sumFunc2 := lazySum([]int{1, 2, 3, 4, 5}) fmt.Println("等待一会") fmt.Println("结果:", sumFunc2()) }func lazySum(arr []int) func() int { fmt.Println("先获取函数,不求结果") count := 0 var sum = func() int { count++ fmt.Println("第", count, "次求结果...") result := 0 for _, v := range arr { result = result + v } return result } return sum }
執行的結果如下,每次執行都是第1 次:
先获取函数,不求结果 等待一会 第 1 次求结果... 结果: 15 先获取函数,不求结果 等待一会 第 1 次求结果... 结果: 15 先获取函数,不求结果 等待一会 第 1 次求结果... 结果: 15
從以上的執行結果可以看出:
閉套件被創建的時候,引用的外部變數count
就已經被創建了1 份,也就是各自呼叫是沒有關係的。
繼續拋出一個問題,**如果一個函數回傳了兩個函數,這是一個閉包還是兩個閉包呢? **下面我們實作一下:
一次傳回兩個函數,一個用來計算加和的結果,一個計算乘積:
import "fmt" func main() { sumFunc, productSFunc := lazyCalculate([]int{1, 2, 3, 4, 5}) fmt.Println("等待一会") fmt.Println("结果:", sumFunc()) fmt.Println("结果:", productSFunc()) }func lazyCalculate(arr []int) (func() int, func() int) { fmt.Println("先获取函数,不求结果") count := 0 var sum = func() int { count++ fmt.Println("第", count, "次求加和...") result := 0 for _, v := range arr { result = result + v } return result } var product = func() int { count++ fmt.Println("第", count, "次求乘积...") result := 0 for _, v := range arr { result = result * v } return result } return sum, product }
運行結果如下:
先获取函数,不求结果 等待一会 第 1 次求加和... 结果: 15 第 2 次求乘积... 结果: 0
從上面結果可以看出,閉包是函數返回函數的時候,不管多少個返回值(函數),都是一次閉包,如果返回的函數有使用外部函數變量,則會綁定到一起,相互影響:
閉包綁定了周圍的狀態,我理解此時的函數就擁有了狀態,讓函數具有了物件所有的能力,函數具有了狀態。
上面的例子,我們閉包中用到的都是數值,如果我們傳遞指針,會是怎麼樣的呢?
import "fmt" func main() { i := 0 testFunc := test(&i) testFunc() fmt.Printf("outer i = %d\n", i) }func test(i *int) func() { *i = *i + 1 fmt.Printf("test inner i = %d\n", *i) return func() { *i = *i + 1 fmt.Printf("func inner i = %d\n", *i) } }
運行結果如下:
test inner i = 1 func inner i = 2 outer i = 2
可以看出如果是指標的話,閉包裡面修改了指針對應的位址的值,也會影響閉包外面的值。這個其實很容易理解,Go 裡面沒有引用傳遞,只有值傳遞,那我們傳遞指標的時候,也是值傳遞,這裡的值是指標的數值(可以理解為位址值)。
當我們函數的參數是指標的時候,參數會拷貝一份這個指標位址,當做參數進行傳遞,因為本質還是位址,所以內部修改的時候,仍然可以對外部產生影響。
閉包裡面的數據其實位址也是一樣的,下面的實驗可以證明:
func main() { i := 0 testFunc := test(&i) testFunc() fmt.Printf("outer i address %v\n", &i) } func test(i *int) func() { *i = *i + 1 fmt.Printf("test inner i address %v\n", i) return func() { *i = *i + 1 fmt.Printf("func inner i address %v\n", i) } }
輸出如下, 因此可以推斷出,閉包如果引用外部環境的指標數據,只是會拷貝一份指標位址數據,而不是拷貝一份真正的數據(==先留個問題:拷貝的時機是什麼時候呢==):
test inner i address 0xc0003fab98 func inner i address 0xc0003fab98 outer i address 0xc0003fab98
上面的例子彷彿都在告訴我們,閉包創建的時候,資料就已經拷貝了,但真的是這樣麼?
下面是繼續前面的實驗:
func main() { i := 0 testFunc := test(&i) i = i + 100 fmt.Printf("outer i before testFunc %d\n", i) testFunc() fmt.Printf("outer i after testFunc %d\n", i) }func test(i *int) func() { *i = *i + 1 fmt.Printf("test inner i = %d\n", *i) return func() { *i = *i + 1 fmt.Printf("func inner i = %d\n", *i) } }
我們在創建閉包之後,把數據改了,之後執行閉包,答案肯定是真實影響閉包的執行,因為它們都是指針,都是指向同一份資料:
test inner i = 1 outer i before testFunc 101 func inner i = 102 outer i after testFunc 102
假設我們換個寫法,讓閉包外部環境中的變數在宣告閉包函數的之後,進行修改:
import "fmt" func main() { sumFunc := lazySum([]int{1, 2, 3, 4, 5}) fmt.Println("等待一会") fmt.Println("结果:", sumFunc()) } func lazySum(arr []int) func() int { fmt.Println("先获取函数,不求结果") count := 0 var sum = func() int { fmt.Println("第", count, "次求结果...") result := 0 for _, v := range arr { result = result + v } return result } count = count + 100 return sum }
實際執行結果,count
會是修改後的值:
等待一会 第 100 次求结果... 结果: 15
這也證明了,實際上閉包並不會在宣告var sum = func() int {.. .}
這句話之後,就將外部環境的count
綁定到閉包中,而是在函數返回閉包函數的時候,才綁定的,這就是延遲綁定。
如果还没看明白没关系,我们再来一个例子:
func main() { funcs := testFunc(100) for _, v := range funcs { v() } } func testFunc(x int) []func() { var funcs []func() values := []int{1, 2, 3} for _, val := range values { funcs = append(funcs, func() { fmt.Printf("testFunc val = %d\n", x+val) }) } return funcs }
上面的例子,我们闭包返回的是函数数组,本意我们想入每一个 val
都不一样,但是实际上 val
都是一个值,==也就是执行到return funcs
的时候(或者真正执行闭包函数的时候)才绑定的 val
值==(关于这一点,后面还有个Demo可以证明),此时 val
的值是最后一个 3
,最终输出结果都是 103
:
testFunc val = 103 testFunc val = 103 testFunc val = 103
以上两个例子,都是闭包延迟绑定的问题导致,这也可以说是 feature,到这里可能不少同学还是对闭包绑定外部变量的时机有疑惑,到底是返回闭包函数的时候绑定的呢?还是真正执行闭包函数的时候才绑定的呢?
下面的例子可以有效的解答:
import ( "fmt" "time" ) func main() { sumFunc := lazySum([]int{1, 2, 3, 4, 5}) fmt.Println("等待一会") fmt.Println("结果:", sumFunc()) time.Sleep(time.Duration(3) * time.Second) fmt.Println("结果:", sumFunc()) } func lazySum(arr []int) func() int { fmt.Println("先获取函数,不求结果") count := 0 var sum = func() int { count++ fmt.Println("第", count, "次求结果...") result := 0 for _, v := range arr { result = result + v } return result } go func() { time.Sleep(time.Duration(1) * time.Second) count = count + 100 fmt.Println("go func 修改后的变量 count:", count) }() return sum }
输出结果如下:
先获取函数,不求结果 等待一会 第 1 次求结果... 结果: 15 go func 修改后的变量 count: 101 第 102 次求结果... 结果: 15
第二次执行闭包函数的时候,明显 count
被里面的 go func()
修改了,也就是调用的时候,才真正的获取最新的外部环境,但是在声明的时候,就会把环境预留保存下来。
其实本质上,Go Routine的匿名函数的延迟绑定就是闭包的延迟绑定,上面的例子中,go func(){}
获取到的就是最新的值,而不是原始值0
。
总结一下上面的验证点:
2.1 好处
纯函数没有状态,而闭包则是让函数轻松拥有了状态。但是凡事都有两面性,一旦拥有状态,多次调用,可能会出现不一样的结果,就像是前面测试的 case 中一样。那么问题来了:
Q:如果不支持闭包的话,我们想要函数拥有状态,需要怎么做呢?
A: 需要使用全局变量,让所有函数共享同一份变量。
但是我们都知道全局变量有以下的一些特点(在不同的场景,优点会变成缺点):
闭包可以一定程度优化这个问题:
除了以上的好处,像在 JavaScript 中,没有原生支持私有方法,可以靠闭包来模拟私有方法,因为闭包都有自己的词法环境。
2.2 坏处
函数拥有状态,如果处理不当,会导致闭包中的变量被误改,但这是编码者应该考虑的问题,是预期中的场景。
闭包中如果随意创建,引用被持有,则无法销毁,同时闭包内的局部变量也无法销毁,过度使用闭包会占有更多的内存,导致性能下降。一般而言,能共享一份闭包(共享闭包局部变量数据),不需要多次创建闭包函数,是比较优雅的方式。
从上面的实验中,我们可以知道,闭包实际上就是外部环境的逃逸,跟随着闭包函数一起暴露出去。
我们用以下的程序进行分析:
import "fmt" func testFunc(i int) func() int { i = i * 2 testFunc := func() int { i++ return i } i = i * 2 return testFunc } func main() { test := testFunc(1) fmt.Println(test()) }
执行结果如下:
5
先看看逃逸分析,用下面的命令行可以查看:
go build --gcflags=-m main.go
可以看到 变量 i
被移到堆中,也就是本来是局部变量,但是发生逃逸之后,从栈里面放到堆里面,同样的 test()
函数由于是闭包函数,也逃逸到堆上。
下面我们用命令行来看看汇编代码:
go tool compile -N -l -S main.go
生成代码比较长,我截取一部分:
"".testFunc STEXT size=218 args=0x8 locals=0x38 funcid=0x0 align=0x0 0x0000 00000 (main.go:5) TEXT "".testFunc(SB), ABIInternal, $56-8 0x0000 00000 (main.go:5) CMPQ SP, 16(R14) 0x0004 00004 (main.go:5) PCDATA $0, $-2 0x0004 00004 (main.go:5) JLS 198 0x000a 00010 (main.go:5) PCDATA $0, $-1 0x000a 00010 (main.go:5) SUBQ $56, SP 0x000e 00014 (main.go:5) MOVQ BP, 48(SP) 0x0013 00019 (main.go:5) LEAQ 48(SP), BP 0x0018 00024 (main.go:5) FUNCDATA $0, gclocals·69c1753bd5f81501d95132d08af04464(SB) 0x0018 00024 (main.go:5) FUNCDATA $1, gclocals·d571c0f6cf0af59df28f76498f639cf2(SB) 0x0018 00024 (main.go:5) FUNCDATA $5, "".testFunc.arginfo1(SB) 0x0018 00024 (main.go:5) MOVQ AX, "".i+64(SP) 0x001d 00029 (main.go:5) MOVQ $0, "".~r0+16(SP) 0x0026 00038 (main.go:5) LEAQ type.int(SB), AX 0x002d 00045 (main.go:5) PCDATA $1, $0 0x002d 00045 (main.go:5) CALL runtime.newobject(SB) 0x0032 00050 (main.go:5) MOVQ AX, "".&i+40(SP) 0x0037 00055 (main.go:5) MOVQ "".i+64(SP), CX 0x003c 00060 (main.go:5) MOVQ CX, (AX) 0x003f 00063 (main.go:6) MOVQ "".&i+40(SP), CX 0x0044 00068 (main.go:6) MOVQ "".&i+40(SP), DX 0x0049 00073 (main.go:6) MOVQ (DX), DX 0x004c 00076 (main.go:6) SHLQ $1, DX 0x004f 00079 (main.go:6) MOVQ DX, (CX) 0x0052 00082 (main.go:7) LEAQ type.noalg.struct { F uintptr; "".i *int }(SB), AX 0x0059 00089 (main.go:7) PCDATA $1, $1 0x0059 00089 (main.go:7) CALL runtime.newobject(SB) 0x005e 00094 (main.go:7) MOVQ AX, ""..autotmp_3+32(SP) 0x0063 00099 (main.go:7) LEAQ "".testFunc.func1(SB), CX 0x006a 00106 (main.go:7) MOVQ CX, (AX) 0x006d 00109 (main.go:7) MOVQ ""..autotmp_3+32(SP), CX 0x0072 00114 (main.go:7) TESTB AL, (CX) 0x0074 00116 (main.go:7) MOVQ "".&i+40(SP), DX 0x0079 00121 (main.go:7) LEAQ 8(CX), DI 0x007d 00125 (main.go:7) PCDATA $0, $-2 0x007d 00125 (main.go:7) CMPL runtime.writeBarrier(SB), $0 0x0084 00132 (main.go:7) JEQ 136 0x0086 00134 (main.go:7) JMP 142 0x0088 00136 (main.go:7) MOVQ DX, 8(CX) 0x008c 00140 (main.go:7) JMP 149 0x008e 00142 (main.go:7) CALL runtime.gcWriteBarrierDX(SB) 0x0093 00147 (main.go:7) JMP 149 0x0095 00149 (main.go:7) PCDATA $0, $-1 0x0095 00149 (main.go:7) MOVQ ""..autotmp_3+32(SP), CX 0x009a 00154 (main.go:7) MOVQ CX, "".testFunc+24(SP) 0x009f 00159 (main.go:11) MOVQ "".&i+40(SP), CX 0x00a4 00164 (main.go:11) MOVQ "".&i+40(SP), DX 0x00a9 00169 (main.go:11) MOVQ (DX), DX 0x00ac 00172 (main.go:11) SHLQ $1, DX 0x00af 00175 (main.go:11) MOVQ DX, (CX) 0x00b2 00178 (main.go:12) MOVQ "".testFunc+24(SP), AX 0x00b7 00183 (main.go:12) MOVQ AX, "".~r0+16(SP) 0x00bc 00188 (main.go:12) MOVQ 48(SP), BP 0x00c1 00193 (main.go:12) ADDQ $56, SP 0x00c5 00197 (main.go:12) RET 0x00c6 00198 (main.go:12) NOP 0x00c6 00198 (main.go:5) PCDATA $1, $-1 0x00c6 00198 (main.go:5) PCDATA $0, $-2 0x00c6 00198 (main.go:5) MOVQ AX, 8(SP) 0x00cb 00203 (main.go:5) CALL runtime.morestack_noctxt(SB) 0x00d0 00208 (main.go:5) MOVQ 8(SP), AX 0x00d5 00213 (main.go:5) PCDATA $0, $-1 0x00d5 00213 (main.go:5) JMP 0
可以看到闭包函数实际上底层也是用结构体new
创建出来的:
使用的就是堆上面的 i
:
#也就是回傳函數的時候,實際上傳回結構體,結構體裡面記錄了函數的參考環境。
4.1 Java 位元不支援閉包?
網路上有很多種看法,但實際上Java 雖然暫時不支援返回函數作為返參,但是Java 本質上還是實現了閉包的概念的,所使用的方式是內部類的形式,因為是內部類,所以相當於自帶了一個引用環境,算是一種不完整的閉包。
目前有一定限制,例如是final
宣告的,或是明確定義的值,才可以進行傳遞:
Stack Overflow上有相關答案:stackoverflow.com/questions/5…
#4.2 函數式程式設計的前景如何?
以下是Wiki的內容:
函數式程式設計長期以來在學術界流行,但幾乎沒有工業應用。造成這種局面的主要原因是函數式程式設計常被認為嚴重耗費CPU和記憶體資源[18] ,這是由於在早期實作函數式程式語言時並沒有考慮過效率問題,而且面向函數式程式設計特性,如保證參考透明性等,要求獨特的資料結構和演算法。 [19]
然而,最近幾種函數式程式語言已經在商業或工業系統中使用[20],例如:
- Erlang,它由瑞典公司愛立信在20世紀80年代後期開發,最初用於實現容錯電信系統。此後,它已在Nortel、Facebook、Électricité de France和WhatsApp等公司作為流行語言創建一系列應用程式。 [21][22]
- Scheme,它被用作早期Apple Macintosh電腦上的幾個應用程式的基礎,並且最近已應用於諸如訓練模擬軟體和望遠鏡控制等方向。
- OCaml,它於1990年代中期推出,已經在金融分析,驅動程式驗證,工業機器人程式設計和嵌入式軟體靜態分析等領域得到了商業應用。
- Haskell,它雖然最初是作為一種研究語言,也已被一系列公司應用於航空航天系統,硬體設計和網路程式設計等領域。
其他在工業中使用的函數式程式語言包括多範式的Scala[23]、F#,還有Wolfram語言、Common Lisp、Standard ML和Clojure等。
從我個人的看法,不看好純函數編程,但是函數式編程的思想,我相信以後幾乎每門高級編程需要都會具備,特別期待 Java 擁抱函數式編程。從我自己了解的語言來看,像 Go,JavaScript 中的函數式程式設計的特性,都讓開發者深愛不已(當然,如果寫出了bug,就是深惡痛疾)。
最近突然火了一波的原因,也是因為世界不停的發展,記憶也越來越大,這個因素的限制幾乎要解放了。
我相信,世界是絢麗多彩的,要是一種事物統治世界,絕無可能,更多的是百家爭鳴,程式語言或者程式設計範式也一樣,後續可能有集大成者,最終最終歷史會篩選出最終符合人類社會發展的。
更多程式相關知識,請造訪:程式設計影片! !
以上是一文淺析Golang中的閉包的詳細內容。更多資訊請關注PHP中文網其他相關文章!