go 學習筆記之僅僅需要一個示例就能講清楚什麼閉包

snowdreams1006發表於2019-09-23

本篇文章是 Go 語言學習筆記之函數語言程式設計系列文章的第二篇,上一篇介紹了函式基礎,這一篇文章重點介紹函式的重要應用之一: 閉包

空談誤國,實幹興邦,以具體程式碼示例為基礎講解什麼是閉包以及為什麼需要閉包等問題,下面我們沿用上篇文章的示例程式碼開始本文的學習吧!

斐波那契數列是形如 1 1 2 3 5 8 13 21 34 55遞增數列,即從第三個數開始,後一個數字是前兩個數字之和,保持此規律無限遞增...

go-functional-programming-about-fib.png

開門見山,直接給出斐波那契數列生成器,接下來的文章慢慢深挖背後隱藏的奧祕,一個例子講清楚什麼是閉包.

「雪之夢技術驛站」: 如果還不瞭解 Go 語言的函式用法,可以參考上一篇文章: go 學習筆記之學習函數語言程式設計前不要忘了函式基礎

  • Go 版本的斐波那契數列生成器
// 1 1 2 3 5 8 13 21 34 55
//     a b
//       a b
func fibonacci() func() int {
  a, b := 0, 1
  return func() int {
    a, b = b, a+b
    return a
  }
}

「雪之夢技術驛站」: Go 語言支援連續賦值,更加貼合思考方式,而其餘主流的程式語言可能不支援這種方式,大多采用臨時變數暫存的方式.

  • Go 版本的單元測試用例
// 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()
}

「雪之夢技術驛站」: 迴圈呼叫 10斐波那契數列生成器,因此生成前十位數列: 1 1 2 3 5 8 13 21 34 55

背後有故事

小小的斐波那契數列生成器背後蘊藏著豐富的 Go 語言特性,該示例也是官方示例之一.

go-functional-programming-fib-try-go.png

  • 支援連續賦值,無需中間變數

「雪之夢技術驛站」: Go 語言和其他主流的程式語言不同,它們大多數最多支援多變數的連續宣告而不支援連續賦值.

這也是 Go 語言特有的交換變數方式,a, b = b, a 語義簡單明確並不用引入額外的臨時變數.

func TestExchange(t *testing.T) {
  a, b := 1, 2
  t.Log(a,b)

  // 2,1
  a, b = b, a
  t.Log(a,b)
}

「雪之夢技術驛站」: Go 語言實現變數互動的示例,a, b = b, a 表示變數直接交換.

而其他主流的程式語言的慣用做法是需要引入臨時變數,大多數程式碼類似如下方式:

func TestExchange(t *testing.T) {
  a, b := 1, 2
  t.Log(a,b)

  // 2,1
  temp := a
  a = b
  b = temp
  t.Log(a,b)
}

「雪之夢技術驛站」: Go 語言的多變數同時賦值特性體現的更多是一種宣告式程式設計思想,不關注具體實現,而引入臨時變數這種體現的則是指令式程式設計思維.

  • 函式的返回值也可以是函式

「雪之夢技術驛站」: Go 語言中的函式是一等公民,不僅函式的返回值可以是函式,引數,變數等等都可以是函式.

函式的返回值可以是函式,這樣的實際意義在於使用者可以擁有更大的靈活性,有時可以用作延遲計算,有時也可以用作函式增強.

先來演示一下延遲計算的示例:

函式的返回值可以是函式,由此實現類似於 i++ 效果的自增函式.因為 i 的初值是 0,所以每呼叫一次該函式, i 的值就會自增,從而實現 i++ 的效果.

func autoIncrease() func() int {
  i := 0
  return func() int {
    i = i + 1
    return i
  }
}

再小的程式碼片段也不應該忘記測試,單元測試繼續走起,順便看一下使用方法.

func TestAutoIncrease(t *testing.T) {
  a := autoIncrease()

  // 1 2 3
  t.Log(a(),a(),a())
}

初始呼叫 autoIncrease 函式並沒有直接得到結果而是返回了函式引用,等到使用者覺得時機成熟後再次呼叫返回的函式引用即變數a ,這時候才會真正計算結果,這種方式被稱為延遲計算也叫做惰性求值.

繼續演示一下功能增強的示例:

因為要演示函式增強功能,沒有輸入哪來的輸出?

所以函式的入參應該也是函式,返回值就是增強後的函式.

這樣的話接下來要做的函式就比較清晰了,這裡我們定義 timeSpend 函式: 實現的功能是包裝特定型別的函式,增加計算函式執行時間的新功能幷包裝成函式,最後返回出去給使用者.

func timeSpend(fn func()) func() {
  return func()  {
    start := time.Now()

    fn()

    fmt.Println("time spend : ", time.Since(start).Seconds())
  }
}

為了演示包裝函式 timeSpend,需要定義一個比較耗時函式當做入參,函式名稱姑且稱之為為 slowFunc ,睡眠 1s模擬耗時操作.

func slowFunc() {
  time.Sleep(time.Second * 1)

  fmt.Println("I am slowFunc")
}

無測試不編碼,繼續執行單元測試用例,演示包裝函式 timeSpend 是如何增強原函式 slowFunc 以實現功能增強?

func TestSlowFuncTimeSpend(t *testing.T) {
  slowFuncTimeSpend := timeSpend(slowFunc)

  // I am slowFunc
  // time spend :  1.002530902
  slowFuncTimeSpend()
}

「雪之夢技術驛站」: 測試結果顯示原函式 slowFunc 被當做入參傳遞給包裝函式 timeSpend 後實現了功能增強,不僅保留了原函式功能還增加了計時功能.

  • 函式巢狀可能是閉包函式

不論是引言部分的斐波那契數列生成器函式還是演示函式返回值的自增函式示例,其實這種形式的函式有一種專業術語稱為"閉包".

一般而言,函式內部不僅存在變數還有巢狀函式,而巢狀函式又引用了這些外部變數的話,那麼這種形式很有可能就是閉包函式.

什麼是閉包

如果有一句話介紹什麼是閉包,那麼我更願意稱其為流浪在外的人想念爸爸媽媽!

go-functional-programming-fib-go-home.jpg

如果非要用比較官方的定義去解釋什麼是閉包,那隻好翻開維基百科 看下有關閉包的定義:

In programming languages, a closure, also lexical closure or function closure, is a technique for implementing lexically scoped name binding in a language with first-class functions. Operationally, a closure is a record storing a function[a] together with an environment.[1] The environment is a mapping associating each free variable of the function (variables that are used locally, but defined in an enclosing scope) with the value or reference to which the name was bound when the closure was created.[b] Unlike a plain function, a closure allows the function to access those captured variables through the closure's copies of their values or references, even when the function is invoked outside their scope.

如果能夠直接理解英文的同學可以略過這部分的中文翻譯,要是不願意費腦理解英文的小夥伴跟我一起解讀中文吧!

閉包是一種技術

第一句話英文如下:

In programming languages, a closure, also lexical closure or function closure, is a technique for implementing lexically scoped name binding in a language with first-class functions.

相應的中文翻譯:

閉包也叫做詞法閉包或者函式閉包,是函式優先程式語言中用於實現詞法範圍的名稱繫結技術.

概念性定義解釋後可能還是不太清楚,那麼就用程式碼解釋一下什麼是閉包?

「雪之夢技術驛站」: 程式語言千萬種,前端後端和中臺;心有餘而力不足,大眾化 Js 帶上 Go .

  • Go 實現斐波那契數列生成器

這是開篇引言的示例,直接照搬過來,這裡主要說明 Go 支援閉包這種技術而已,所以並不關心具體實現細節.

func fibonacci() func() int {
  a, b := 0, 1
  return func() int {
    a, b = b, a+b
    return a
  }
}

單元測試用例函式,連續 10 次呼叫斐波那契數列生成器,輸出斐波那契數列中的前十位數字.

// 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()
}
  • Js 實現斐波那契數列生成器

仿照 Go 語言的實現方式寫一個 Js 版本的斐波那契數列生成器,相關程式碼如下:

function fibonacci() {
  var a, b;
  a = 0;
  b = 1;
  return function() {
    var temp = a;
    a = b;
    b = temp + b;
    return a;
  }
}

同樣的,仿造測試程式碼寫出 Js 版本的測試用例:

// 1 1 2 3 5 8 13 21 34 55
function TestFibonacci() {
  var f = fibonacci();
  for(var i = 0; i < 10; i++ ){
    console.log(f() +" ");
  }
  console.log();
}

不僅僅是 JsGo 這兩種程式語言能夠實現閉包,實際上很多程式語言都能實現閉包,就像是物件導向程式設計一樣,也不是某種語言專有的技術,唯一的區別可能就是語法細節上略有不同吧,所以記住了: 閉包是一種技術!

閉包儲存了環境

第二句英文如下:

Operationally, a closure is a record storing a function[a] together with an environment.

相應的中文翻譯:

在操作上,閉包是將函式[a]與環境一起儲存的記錄

第一句我們知道了閉包是一種技術,而現在我們有知道了閉包儲存了閉包函式所需要的環境,而環境分為函式執行時所處的內部環境和依賴的外部環境,閉包函式被使用者呼叫時不會像普通函式那樣丟失環境而是儲存了環境.

如果是普通函式方式開啟上述示例的斐波那契數列生成器:

func fibonacciWithoutClosure() int {
  a, b := 0, 1
  a, b = b, a+b
  return a
}

可想而知,這樣肯定是不行的,因為函式內部環境是無法維持的,使用者每次呼叫 fibonacciWithoutClosure 函式都會重新初始化變數 a,b 的值,因而無法實現累加自增效果.

// 1 1 1 1 1 1 1 1 1 1 
func TestFibonacciWithoutClosure(t *testing.T) {
  for i := 0; i < 10; i++ {
    fmt.Print(fibonacciWithoutClosure(), " ")
  }
  fmt.Println()
}

很顯然,函式內部定義的變數每次執行函式時都會重新初始化,為了避免這種情況,在不改變整體實現思路的前提下,只需要提升變數的作用範圍就能實現斐波那契數列生成器函式:

var a, b = 0, 1
func fibonacciWithoutClosure() int {
  a, b = b, a+b
  return a
}

此時再次執行 10斐波那契數列生成器函式,如我們所願生成前 10 位斐波那契數列.

// 1 1 2 3 5 8 13 21 34 55
func TestFibonacciWithoutClosure(t *testing.T) {
  for i := 0; i < 10; i++ {
    fmt.Print(fibonacciWithoutClosure(), " ")
  }
  fmt.Println()
}

所以說普通函式 fibonacciWithoutClosure 的執行環境要麼是僅僅依賴內部變數維持的獨立環境,每次執行都會重新初始化,無法實現變數的重複利用;要麼是依賴了外部變數維持的具有記憶功能的環境,解決了重新初始化問題的同時引入了新的問題,那就是必須定義作用範圍更大的外部環境,增加了維護成本.

既然函式內的變數無法維持而函式外的變數又需要管理,如果能兩者結合的話,豈不是皆大歡喜,揚長補短?

go-functional-programming-fib-balance.jpg

對的,閉包基本上就是這種實現思路!

func fibonacci() func() int {
  a, b := 0, 1
  return func() int {
    a, b = b, a+b
    return a
  }
}

斐波那契數列生成器函式 fibonacci返回值是匿名函數,而匿名函式的返回值就是斐波那契數字.

如果不考慮函式內部實現細節,整個函式的語義是十分明確的,使用者初始化呼叫 fibonacci 函式時得到返回值是真正的斐波那契生成器函式,用變數暫存起來,當需要生成斐波那契數字的時候再呼叫剛才暫存的變數就能真正生成斐波那契數列.

// 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()
}

現在我們再好好比較一下這種形式實現的閉包和普通函式的區別?

  • 閉包函式 fibonacci內部定義了變數 a,b,最終返回的匿名函式中使用了變數 a,b,使用時間接生成斐波那契數字.
  • 普通函式 fibonacciWithoutClosure外部定義了變數 a,b,呼叫該函式直接生成斐波那契數字.
  • 閉包函式是延遲計算也就是惰性求值而普通函式是立即計算,兩者的呼叫方式不一樣.

但是如果把視角切換到真正有價值部分,你會發現閉包函式只不過是普通函式巢狀而已!

func fibonacciDeduction() func() int {
  a, b := 0, 1

  func fibonacciGenerator() int {
    a, b = b, a+b
    return a
  }

  return fibonacciGenerator
}

只不過 Go不支援函式巢狀,只能使用匿名函式來實現函式巢狀的效果,所以上述示例是會直接報錯的喲!

go-functional-programming-fib-nested-error.png

但是某些語言是支援函式巢狀的,比如最常用的 Js 語言就支援函式巢狀,用 Js 重寫上述程式碼如下:

function fibonacciDeduction() {
  var a, b;
  a = 0;
  b = 1;

  function fibonacciGenerator() {
    var temp = a;
    a = b;
    b = temp + b;
    return a
  }

  return fibonacciGenerator
}

斐波那契數列生成器函式是 fibonacciDeduction,該函式內部真正實現生成器功能的卻是 fibonacciGenerator 函式,正是這個函式使用了變數 a,b ,相當於把外部變數打包繫結成執行環境的一部分!

// 1 1 2 3 5 8 13 21 34 55
function TestFibonacciDeduction() {
  var f = fibonacciDeduction();
  for(var i = 0; i < 10; i++ ){
    console.log(f() +" ");
  }
  console.log();
}

「雪之夢技術驛站」: 閉包並不是某一種語言特有的技術,雖然各個語言的實現細節上有所差異,但並不妨礙整體理解,正如定義的第二句那樣: storing a **function**[a] together with an **environment**.

環境關聯了自由變數

第三句英文如下:

The environment is a mapping associating each free variable of the function (variables that are used locally, but defined in an enclosing scope) with the value or reference to which the name was bound when the closure was created

相應的中文翻譯:

環境是一種對映,它將函式的每個自由變數(在本地使用但在封閉範圍內定義的變數)與建立閉包時名稱繫結到的值或引用相關聯。

環境是閉包所處的環境,這裡強調的是外部環境,更確切的說是相對於匿名函式而言的外部變量,像這種被閉包函式使用但是定義在閉包函式外部的變數被稱為自由變數.

「雪之夢技術驛站」: 由於閉包函式內部使用了自由變數,所以閉包內部的也就關聯了自由變數的值或引用,這種繫結關係是建立閉包時確定的,執行時環境也會一直存在並不會發生像普通函式那樣無法維持環境.

  • 自由變數

這裡使用了一個比較陌生的概念: 自由變數(在本地使用但在封閉範圍內定義的變數)

很顯然,根據括號裡面的註釋說明我們知道: 所謂的自由變數是相對於閉包函式或者說匿名函式來說的外部變數,由於該變數的定義不受自己控制,所以對閉包函式自己來說就是自由的,並不受閉包函式的約束!

那麼按照這種邏輯繼續延伸猜測的話,匿名函式內部定義的變數豈不是約束變數?對於閉包函式而言的自由變數對於定義函式來說豈不是約束變數?

var a, b = 0, 1
func fibonacciWithoutClosure() int {
  a, b = b, a+b
  return a
}

「雪之夢技術驛站」: 這裡的變數 a,b 相對於函式 fibonacciWithoutClosure 來說,是不是自由變數?或者說全域性變數就是自由變數,對嗎?

  • 值或引用
func fibonacci() func() int {
  a, b := 0, 1
  return func() int {
    a, b = b, a+b
    return a
  }
}

變數 a,b 定義在函式 fibonacci 內部,相對於匿名函式 func() int 來說是自由變數,在匿名函式中直接使用了變數 a,b 並沒有重新複製一份,所以這種形式的環境關聯的自由變數是引用.

再舉個引用關聯的示例,加深一下閉包的環境理解.

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 .

這裡的迴圈變數的定義部分是在匿名函式的外部就是所謂的自由變數,變數 i 沒有進行拷貝所以也就是引用關聯.

func TestCountByClosure(t *testing.T) {
  // 4 4 4 
  for _, c := range countByClosureButWrong() {
    t.Log(c())
  }
}

執行這種閉包函式,最終的輸出結果都是 4 4 4,這是因為閉包的環境關聯的迴圈變數 i引用方式而不是值傳遞方式,所以閉包執行結束後的變數 i 已經是 4.

除了引用傳遞方式還有值傳遞方式,關聯自由變數時拷貝一份到匿名函式,使用者呼叫閉包函式時就能如願繫結到迴圈變數.

func countByClosureWithOk() []func() int {
  var arr []func() int
  for i := 1; i <= 3; i++ {
    func(n int) {
      arr = append(arr, func() int {
        return n
      })
    }(i)
  }
  return arr
}

「雪之夢技術驛站」: 自由變數 i 作為引數傳遞給匿名函式,而 Go 中的引數傳遞只有值傳遞,所以匿名函式使用的變數 n 就可以正確繫結迴圈變數了,這也就是自由變數的值繫結方式.

func TestCountByClosureWithOk(t *testing.T) {
  // 1 2 3
  for _, c := range countByClosureWithOk() {
    t.Log(c())
  }
}

「雪之夢技術驛站」: 自由變數通過值傳遞的方式傳遞給閉包函式,實現值繫結環境,正確繫結了迴圈變數 1 2 3 而不是 4 4 4

訪問被捕獲自由變數

第四句英文如下:

Unlike a plain function, a closure allows the function to access those captured variables through the closure's copies of their values or references, even when the function is invoked outside their scope.

相應的中文翻譯:

與普通函式不同,閉包允許函式通過閉包的值的副本或引用訪問那些被捕獲的變數,即使函式在其作用域之外被呼叫

閉包函式和普通函式的不同之處在於,閉包提供一種持續訪問被捕獲變數的能力,簡單的理解就是擴大了變數的作用域.

func fibonacci() func() int {
  a, b := 0, 1
  return func() int {
    a, b = b, a+b
    return a
  }
}

自由變數 a,b 的定義發生在函式 fibonacci 體內,一般而言,變數的作用域也僅限於函式內部,外界是無法訪問該變數的值或引用的.

但是,閉包提供了持續暴露變數的機制,外界突然能夠訪問原本應該私有的變數,實現了全域性變數的作用域效果!

var a, b = 0, 1
func fibonacciWithoutClosure() int {
  a, b = b, a+b
  return a
}

「雪之夢技術驛站」: 普通函式想要訪問變數 a,b 的值或引用,定義在函式內部是無法暴露給呼叫者訪問的,只能提升成全域性變數才能實現作用域範圍的擴大.

由此可見,一旦變數被閉包捕獲後,外界使用者是可以訪問這些被捕獲的變數的值或引用的,相當於訪問了私有變數!

怎麼理解閉包

閉包是一種函數語言程式設計中實現名稱繫結的技術,直觀表現為函式巢狀提升變數的作用範圍,使得原本壽命短暫的區域性變數獲得長生不死的能力,只要被捕獲到的自由變數一直在使用中,系統就不會回收記憶體空間!

知乎上關於閉包的眾多回答中,其中有一個回答言簡意賅,特意分享如下:

我叫獨孤求敗,我在一個山洞裡,裡面有世界上最好的劍法,還有最好的武器。我學習了裡面的劍法,拿走了最好的劍。離開了這裡。我來到這個江湖,快意恩仇。但是從來沒有人知道我這把劍的來歷,和我這一身的武功。。。那山洞就是一個閉包,而我,就是那個山洞裡唯一一個可以與外界交匯的地方。這山洞的一切對外人而言就像不存在一樣,只有我才擁有這裡面的寶藏!

這也是閉包定義中最後一句話表達的意思: 山洞是閉包函式,裡面的劍法和武器就是閉包的內部環境,而獨孤求敗劍客則是被捕獲的自由變數,他出生在山洞之外的世界,學成歸來後獨自闖蕩江湖.從此江湖上有了獨孤求敗的傳說和那把劍以及神祕莫測的劍法.

go-functional-programming-fib-swordsman.jpeg

掌握閉包了麼

  • 問題: 請將下述普通函式改寫成閉包函式?
func count() []int {
  var arr []int
  for i := 1; i <= 3; i++ {
    arr = append(arr, i)
  }
  return arr
}

func TestCount(t *testing.T) {
  // 1 2 3
  for _, c := range count() {
    t.Log(c)
  }
}
  • 回答: 閉包的錯誤示例以及正確示例
func countByClosureButWrong() []func() int {
  var arr []func() int
  for i := 1; i <= 3; i++ {
    arr = append(arr, func() int {
      return i
    })
  }
  return arr
}

func TestCountByClosure(t *testing.T) {
  // 4 4 4 
  for _, c := range countByClosureButWrong() {
    t.Log(c())
  }
}

func countByClosureWithOk() []func() int {
  var arr []func() int
  for i := 1; i <= 3; i++ {
    func(n int) {
      arr = append(arr, func() int {
        return n
      })
    }(i)
  }
  return arr
}

func TestCountByClosureWithOk(t *testing.T) {
  // 1 2 3
  for _, c := range countByClosureWithOk() {
    t.Log(c())
  }
}

那麼,問題來了,原本普通函式就能實現的需求更改成閉包函式實現後,一不小心就弄錯了,為什麼還需要閉包?

閉包歸納總結

現在再次回顧一下斐波那契數列生成器函式,相信你已經讀懂了吧,有沒有看到閉包的影子呢?

// 1 1 2 3 5 8 13 21 34 55
//     a b
//       a b
func fibonacci() func() int {
  a, b := 0, 1
  return func() int {
    a, b = b, a+b
    return a
  }
}

但是,有沒有想過這麼一個問題: 為什麼需要閉包,閉包解決了什麼問題?

  • 閉包不是某一種語言特有的機制,但常出現在函數語言程式設計中,尤其是函式佔據重要地位的程式語言.
  • 閉包的直觀表現是函式內部巢狀了函式,並且內部函式訪問了外部變數,從而使得自由變數獲得延長壽命的能力.
  • 閉包中使用的自由變數一般有值傳遞和引用傳遞兩種形式,示例中的斐波那契數列生成器利用的是引用而迴圈變數示例用的是值傳遞.
  • Go 不支援函式巢狀但支援匿名函式,語法層面的差異性掩蓋不了閉包整體的統一性.

「雪之夢技術驛站」: 由於篇幅所限,為什麼需要閉包以及閉包的優缺點等知識的相關分析打算另開一篇單獨討論,敬請期待...

相關資料參考

雪之夢技術驛站

相關文章