go 學習筆記之10 分鐘簡要理解 go 語言閉包技術
- 2019 年 10 月 3 日
- 筆記
閉包是主流程式語言中的一種通用技術,常常和函數式編程進行強強聯合,本文主要是介紹 Go 語言中什麼是閉包以及怎麼理解閉包.
如果讀者對於 Go 語言的閉包還不是特別清楚的話,可以參考上一篇文章 go 學習筆記之僅僅需要一個示例就能講清楚什麼閉包.
或者也可以直接無視,因為接下來會回顧一下前情概要,現在你準備好了嗎? Go !

斐波那契數列見閉包
不論是 Go 官網還是網上其他講解閉包的相關教程,總能看到斐波那契數列的身影,足以說明該示例的經典!
斐波那契數列(
Fibonacci sequence),又稱黃金分割數列 .因數學家列昂納多·斐波那契(Leonardoda Fibonacci)以兔子繁殖為例子而引入,故又稱為「兔子數列」,指的是這樣一個數列:1、1、2、3、5、8、13、21、34、……在數學上,斐波那契數列以如下被以遞推的方法定義:F(1)=1,F(2)=1, F(n)=F(n-1)+F(n-2)(n>=3,n∈N*).在現代物理、准晶體結構、化學等領域,斐波納契數列都有直接的應用,為此,美國數學會從1963年起出版了以《斐波納契數列季刊》為名的一份數學雜誌,用於專門刊載這方面的研究成果.

根據上述百度百科的有關描述,我們知道斐波那契數列就是形如 1 1 2 3 5 8 13 21 34 55 的遞增數列,從第三項開始起,當前項是前兩項之和.
為了計算方便,定義兩個變數 a,b 表示前兩項,初始值分別設置成 0,1 ,示例:
// 0 1 1 2 3 5 8 13 21 34 55 // a b // a b a, b := 0, 1
初始化後下一輪移動,a, b = b, a+b 結果是 a , b = 1 , 1,剛好能夠表示斐波那契數列的開頭.
「雪之夢技術驛站」試想一下: 如果
a,b變數的初始值是1,1,不更改邏輯的情況下,最終生成的斐波那契數列是什麼樣子?
func fibonacciByNormal() { a, b := 0, 1 a, b = b, a+b fmt.Print(a, " ") fmt.Println() }
但是上述示例只能生成斐波那契數列中的第一個數字,假如我們需要前十個數列,又該如何?
func fibonacciByNormal() { a, b := 0, 1 for i := 0; i < 10; i++ { a, b = b, a+b fmt.Print(a, " ") } fmt.Println() }
通過指定循環次數再稍加修改上述單數列程式碼,現在就可以生成前十位數列:
// 1 1 2 3 5 8 13 21 34 55 func TestFibonacciByNormal(t *testing.T) { fibonacciByNormal() }
這種做法是接觸閉包概念前我們一直在採用的解決方案,相信稍微有一定編程經驗的開發者都能實現,但是閉包卻提供了另一種思路!
// 1 1 2 3 5 8 13 21 34 55 func fibonacci() func() int { a, b := 0, 1 return func() int { a, b = b, a+b return a } }
不論是普通函數還是閉包函數,實現斐波那契數列生成器函數的邏輯不變,只是實現不同,閉包返回的是內部函數,留給使用者繼續調用而普通函數是直接生成斐波那契數列.
// 1 1 2 3 5 8 13 21 34 55 func TestFibonacci(t *testing.T) { f := fibonacci() for i := 0; i < 10; i++ { fmt.Print(f(), " ") } fmt.Println() }
對於這種函數內部嵌套另一個函數並且內部函數引用了外部變數的這種實現方式,稱之為"閉包"!
「雪之夢技術驛站」: 閉包是函數+引用環境組成的有機整體,兩者缺一不可,詳細請參考go 學習筆記之僅僅需要一個示例就能講清楚什麼閉包.
自帶獨立的運行環境

「雪之夢技術驛站」: 自帶運行環境的閉包正如電影中出場自帶背景音樂的發哥一樣,音樂響起,發哥登場,閉包出現,環境自帶!
閉包自帶獨立的運行環境,每一次運行閉包的環境都是相互獨立的,正如面向對象中類和對象實例化的關係那樣,閉包是類,閉包的引用是實例化對象.
func autoIncrease() func() int { i := 0 return func() int { i = i + 1 return i } }
上述示例是閉包實現的計算器自增,每一次引用 autoIncrease 函數獲得的閉包環境都是彼此獨立的,直接上單元測試用例.
func TestAutoIncrease(t *testing.T) { a := autoIncrease() // 1 2 3 t.Log(a(), a(), a()) b := autoIncrease() // 1 2 3 t.Log(b(), b(), b()) }
函數引用 a 和 b 的環境是獨立的,相當於另一個一模一樣計數器重新開始計數,並不會影響原來的計數器的運行結果.
「雪之夢技術驛站」: 閉包不僅僅是函數,更加重要的是環境.從運行效果上看,每一次引用閉包函數重新初始化運行環境這種機制,非常類似於面向對象中類和實例化對象的關係!
長生不老是福還是禍
普通函數內部定義的變數壽命有限,函數運行結束後也就被系統銷毀了,結束了自己短暫而又光榮的一生.
但是,閉包所引用的變數卻不一樣,只要一直處於使用中狀態,那麼變數就會"長生不老",並不會因為出身於函數內就和普通變數擁有一樣的短暫人生.
- 老驥伏櫪,志在千里

func fightWithHorse() func() int { horseShowTime := 0 return func() int { horseShowTime++ fmt.Printf("(%d)祖國需要我,我就提槍上馬立即戰鬥!n",horseShowTime) return horseShowTime } } func TestFightWithHorse(t *testing.T) { f := fightWithHorse() // 1 2 3 t.Log(f(), f(), f()) }

「雪之夢技術驛站」: 如果使用者一直在使用閉包函數,那麼閉包內部引用的自由變數就不會被銷毀,一直處於活躍狀態,從而獲得永生的超能力!
- 禍兮福所倚福兮禍所伏
凡事有利必有弊,閉包不死則引用變數不滅,如果不理解變數長生不老的特性,編寫閉包函數時可能一不小心就掉進作用域陷阱了,千萬要小心!

下面以綁定循環變數為例講解閉包作用域的陷阱,示例如下:
func countByClosureButWrong() []func() int { var arr []func() int for i := 1; i <= 3; i++ { arr = append(arr, func() int { return i }) } return arr }
countByClosureButWrong 閉包函數引用的自由變數不僅有 arr 數組還有循環變數 i ,函數的整體邏輯是: 閉包函數內部維護一個函數數組,保存的函數主要返回了循環變數.
func TestCountByClosure(t *testing.T) { // 4 4 4 for _, c := range countByClosureButWrong() { t.Log(c()) } }
當我們運行 countByClosureButWrong 函數獲得閉包返回的函數數組 arr,然後通過 range 關鍵字進行遍曆數組,得到正在遍歷的函數項 c.
當我們運行 c() 時,期望輸出的 1,2,3 循環變數的值,但是實際結果卻是 4,4,4.

原因仍然是變數長生不老的特性:遍歷循環時綁定的變數值肯定是 1,2,3,但是循環變數 i 卻沒有像普通函數那樣消亡而是一直長生不老,所以變數的引用發生變化了!

長生不老的循環變數的值剛好是當初循環的終止條件 i=4,只要運行閉包函數,不論是數組中的哪一項函數引用的都是相同的變數 i,所以全部都是 4,4,4.
既然是變數引用出現問題,那麼解決起來就很簡單了,不用變數引用就好了嘛!
最簡單的做法就是使用短暫的臨時變數 n 暫存起來正在遍歷的值,閉包內引用的變數不再是 i 而是臨時變數 n.
func countByClosureButWrong() []func() int { var arr []func() int for i := 1; i <= 3; i++ { n := i fmt.Printf("for i=%d n=%d n", i,n) arr = append(arr, func() int { fmt.Printf("append i=%d n=%dn", i, n) return n }) } return arr }

上述解決辦法很簡單就是採用臨時變數綁定循環變數的值,而不是原來的長生不老的變數引用,但是這種做法不夠優雅,還可以繼續簡化進行版本升級.
既然是採用變數賦值的做法,是不是和參數傳遞中的值傳遞很相像?那我們就可以用值傳遞的方式重新複製一份變數的值傳遞給閉包函數.
func countByClosureWithOk() []func() int { var arr []func() int for i := 1; i <= 3; i++ { fmt.Printf("for i=%d n", i) func(n int) { arr = append(arr, func() int { fmt.Printf("append n=%d n", n) return n }) }(i) } return arr }
「雪之夢技術驛站」: 採用匿名函數自執行的方式傳遞參數
i,函數內部使用變數n綁定了外部的循環變數,看起來更加優雅,有逼格!
採用匿名函數進行值傳遞進行改造後,我們再次運行測試用例驗證一下改造結果:
func TestCountByClosureWithOk(t *testing.T) { // 1 2 3 for _, c := range countByClosureWithOk() { t.Log(c()) } }
終於解決了正確綁定循環變數的問題,下次再出現實際結果和預期不符,不一定是 bug 有可能是理解不深,沒有正確使用閉包!
七嘴八舌暢談優缺點

- 模擬類和對象的關係,也可以實現封裝,具備一定面向對象能力
「雪之夢技術驛站」: 每次調用閉包函數所處的環境都是相互獨立的,這種特性類似於面向對象中類和實例化對象的關係.
- 快取複雜邏輯,常駐記憶體,避免濫用全局變數徒增維護成本.
「雪之夢技術驛站」: 長生不老的特性使得閉包引用變數可以常駐記憶體,用於快取一些複雜邏輯程式碼非常合適,避免了原來的全局變數的濫用.
- 實現閉包成本較高,同時也增加了理解難度.
「雪之夢技術驛站」: 普通函數轉變成閉包函數不僅實現起來有一定難度,而且理解起來也不容易,不僅要求多測試幾遍還要理解閉包的特性.
- 濫用容易佔用過多記憶體,可能造成記憶體泄漏.
「雪之夢技術驛站」: 過多使用閉包勢必造成引用變數一直常駐記憶體,如果出現循環引用或者垃圾回收不及時有可能造成記憶體泄漏問題.
簡單總結下閉包知識
閉包是一種通用技術,Go 語言支援閉包,主要體現在 Go 支援函數內部嵌套匿名函數,但 Go 不支援普通函數嵌套.
簡單的理解,閉包是函數和環境的有機結合整體,獨立和運行環境和長生不老的引用變數是閉包的兩大重要特徵.
不論是模擬面向對象特性,實現快取還是封裝對象等等應用都是這兩特性的應用.
最後,讓我們再回憶一下貫穿始終的斐波那契數列來結束此次閉包之旅!
func fibonacci() func() int { a, b := 0, 1 return func() int { a, b = b, a+b return a } }
本文涉及示例程式碼: https://github.com/snowdreams1006/learn-go/blob/master/functional/closure/closure_test.go
參考資料及延伸閱讀
- 閉包的概念、形式與應用
- Jartto 部落格:反思閉包
- 三點水部落格: 再談閉包
-
如果本文對你有所幫助,請動動小手點一下推薦,否則還請留言指正,如有需要,請關注個人公眾號「 雪之夢技術驛站 」

