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

本篇文章是 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 不支持函數嵌套但支持匿名函數,語法層面的差異性掩蓋不了閉包整體的統一性.
「雪之夢技術驛站」: 由於篇幅所限,爲什麼需要閉包以及閉包的優缺點等知識的相關分析打算另開一篇單獨討論,敬請期待...

相關資料參考

如果本文對你有所幫助,不用讚賞,也不必轉發,直接點贊留言告訴鼓勵一下就可以啦!

雪之夢技術驛站.png

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章