go 學習筆記之10 分鐘簡要理解 go 語言閉包技術

snowdreams1006發表於2019-10-01

閉包是主流程式語言中的一種通用技術,常常和函數語言程式設計進行強強聯合,本文主要是介紹 Go 語言中什麼是閉包以及怎麼理解閉包.

如果讀者對於 Go 語言的閉包還不是特別清楚的話,可以參考上一篇文章 go 學習筆記之僅僅需要一個示例就能講清楚什麼閉包.

或者也可以直接無視,因為接下來會回顧一下前情概要,現在你準備好了嗎? Go !

https://i.iter01.com/images/b43627c3def120c89a83bd895107db87231ae16d3fd1b126d6ad6f3a7dd30d8e.png

go-functional-programming-closure-cheer.png

斐波那契數列見閉包

不論是 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年起出版了以《斐波納契數列季刊》為名的一份數學雜誌,用於專門刊載這方面的研究成果.

https://i.iter01.com/images/fb9b5bc6c6dd0aa720eb5c96601c89a6e7c6285ff32dfdb25b2af2faa9b1fb09.png

go-functional-programming-about-fib.png

根據上述百度百科的有關描述,我們知道斐波那契數列就是形如 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 學習筆記之僅僅需要一個示例就能講清楚什麼閉包.

自帶獨立的執行環境

https://i.iter01.com/images/9994ff7990eda19af7c665292eaca63a57c226e3ab7024052d0cd4f7aed7549d.jpg

go-functional-programming-closure-fage.jpeg

「雪之夢技術驛站」: 自帶執行環境的閉包正如電影中出場自帶背景音樂的發哥一樣,音樂響起,發哥登場,閉包出現,環境自帶!

閉包自帶獨立的執行環境,每一次執行閉包的環境都是相互獨立的,正如物件導向中類和物件例項化的關係那樣,閉包是類,閉包的引用是例項化物件.

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 的環境是獨立的,相當於另一個一模一樣計數器重新開始計數,並不會影響原來的計數器的執行結果.

「雪之夢技術驛站」: 閉包不僅僅是函式,更加重要的是環境.從執行效果上看,每一次引用閉包函式重新初始化執行環境這種機制,非常類似於物件導向中類和例項化物件的關係!

長生不老是福還是禍

普通函式內部定義的變數壽命有限,函式執行結束後也就被系統銷燬了,結束了自己短暫而又光榮的一生.

但是,閉包所引用的變數卻不一樣,只要一直處於使用中狀態,那麼變數就會"長生不老",並不會因為出身於函式內就和普通變數擁有一樣的短暫人生.

  • 老驥伏櫪,志在千里
https://i.iter01.com/images/8e5361bf2f3c8aad113a1d5acf027c52b1a026967c356c7f23ba57fe3c1ee3bd.jpg

go-functional-programming-closure-horse.jpeg
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())
}
https://i.iter01.com/images/2bd0c6a5a95e192058e0c2346bec9d025c5387653db242052c535b1fe389e0c3.png

go-functional-programming-closure-fight.png

「雪之夢技術驛站」: 如果使用者一直在使用閉包函式,那麼閉包內部引用的自由變數就不會被銷燬,一直處於活躍狀態,從而獲得永生的超能力!

  • 禍兮福所倚福兮禍所伏

凡事有利必有弊,閉包不死則引用變數不滅,如果不理解變數長生不老的特性,編寫閉包函式時可能一不小心就掉進作用域陷阱了,千萬要小心!

https://i.iter01.com/images/da78210b9a1234d0955215ad042342ae94a520f01d1df8bbb0532dbf4db8566b.jpg

go-functional-programming-closure-laozi.jpg

下面以繫結迴圈變數為例講解閉包作用域的陷阱,示例如下:

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.

https://i.iter01.com/images/59b4b0a99d09e276e614d71c99a3f940625da3f2b82ffdb8e165ed6920ae6c4e.png

go-functional-programming-closure-wrong.png

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

https://i.iter01.com/images/c6c6e1012b457ecb46619954f8be3fd8db4df86901cbabad01d6fc3f8cfe9659.png

go-functional-programming-closure-wrong-explain.png

長生不老的迴圈變數的值剛好是當初迴圈的終止條件 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=%d\n", i, n)

            return n
        })
    }
    return arr
}
https://i.iter01.com/images/cbb79e61ec7e69b40e9b36f60ed6464e3746e23b9dba11b14112e4b7ebcfcb42.png

go-functional-programming-closure-wrong-fix.png

上述解決辦法很簡單就是採用臨時變數繫結迴圈變數的值,而不是原來的長生不老的變數引用,但是這種做法不夠優雅,還可以繼續簡化進行版本升級.

既然是採用變數賦值的做法,是不是和引數傳遞中的值傳遞很相像?那我們就可以用值傳遞的方式重新複製一份變數的值傳遞給閉包函式.

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 有可能是理解不深,沒有正確使用閉包!

七嘴八舌暢談優缺點

https://i.iter01.com/images/3a9e7b7ef88e0e1bd69a269b6715d23cae64d8679238f1b7ea0644aafeb6c31e.jpg

go-functional-programming-closure-compare.jpg
  • 模擬類和物件的關係,也可以實現封裝,具備一定物件導向能力

「雪之夢技術驛站」: 每次呼叫閉包函式所處的環境都是相互獨立的,這種特性類似於物件導向中類和例項化物件的關係.

  • 快取複雜邏輯,常駐記憶體,避免濫用全域性變數徒增維護成本.

「雪之夢技術驛站」: 長生不老的特性使得閉包引用變數可以常駐記憶體,用於快取一些複雜邏輯程式碼非常合適,避免了原來的全域性變數的濫用.

  • 實現閉包成本較高,同時也增加了理解難度.

「雪之夢技術驛站」: 普通函式轉變成閉包函式不僅實現起來有一定難度,而且理解起來也不容易,不僅要求多測試幾遍還要理解閉包的特性.

  • 濫用容易佔用過多記憶體,可能造成記憶體洩漏.

「雪之夢技術驛站」: 過多使用閉包勢必造成引用變數一直常駐記憶體,如果出現迴圈引用或者垃圾回收不及時有可能造成記憶體洩漏問題.

簡單總結下閉包知識

閉包是一種通用技術,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

參考資料及延伸閱讀

如果本文對你有所幫助,不用讚賞,點贊鼓勵一下就是最大的認可,順便也可以關注下微信公眾號「 雪之夢技術驛站 」喲!

https://i.iter01.com/images/7a0215b7e076bbdf0ec6e642dbc0c2537db05f2426b694d66154d8e9ea91cc16.jpg

雪之夢技術驛站.png

相關文章