【Go語言學習】匿名函式與閉包

弗蘭克的貓發表於2020-07-27

前言

入坑 Go 語言已經大半年了,卻沒有寫過一篇像樣的技術文章,每次寫一半就擱筆,然後就爛尾了。

1.gif

幾經思考,痛定思痛,決定金盆洗手,重新做人,哦不,重新開始寫技術博文。

這段時間在研究Go語言閉包的過程中,發現了很多有意思的東西,也學到了不少內容,於是便以次為契機,重新開始技術文章的輸出。

什麼是閉包

閉包Go 語言中一個重要特性,也是 函數語言程式設計 中必不可少的角色。那麼什麼是 閉包 呢?

A closure is a function value that references variables from outside its body.

這是 A Tour of Go 上的定義,閉包 是一種引用了外部變數的函式。但我覺得這個定義還不夠準確,閉包 應該是引用了外部變數的 匿名函式

看了很多文章,大多把 閉包匿名函式混淆在了一起,也有很多人說,閉包 其實就是匿名函式,但其實兩者是不能直接劃等號的。

閉包 是一種特殊的匿名函式,是匿名函式的子集。所以在說 閉包 之前,我們先來看看 匿名函式 吧。

匿名函式

匿名函式 顧名思義,就是沒有名字的函式。在Go語言中,函式是一等公民,也就是說,函式可以被賦值或者當作返回值和引數進行傳遞,在很多時候我們並不需要一個有名字的函式(而且命名確實是一項相當費勁的事),所以我們在某些場景下可以選擇使用 匿名函式

舉個例子:

func main(){
    hello := func(){
        fmt.Println("Hello World")
    }
    hello()
}

這是一個簡單的例子,我們宣告瞭一個 匿名函式 ,然後把它賦值給一個叫 hello 的變數,然後我們就能像呼叫函式那樣使用它了。

這跟下面的程式碼效果是一樣的:

func main(){
    hello()
}

func hello(){
    fmt.Println("Hello World")
}

我們還可以把 匿名函式 當作函式引數進行傳遞:

func main(){
    doPrint("Hello World", func(s string){
		fmt.Println(s)
	})
}

type Printer func(string)

func doPrint(s string, printer Printer){
    printer(s)
}

或者當作函式返回值進行返回:

func main(){
    getPrinter()("Hello World")
}

type Printer func(string)

func getPrinter()Printer{
    return func(s string){
		fmt.Println(s)
	}
}

匿名函式 跟普通函式在絕大多數場景下沒什麼區別,普通函式的函式名可以當作是與該函式繫結的函式常量。

一個函式主要包含兩個資訊:函式簽名和函式體,函式的簽名包括引數型別,返回值的型別,函式簽名可以看做是函式的型別,函式的函式體即函式的值。所以一個接收匿名函式的變數的型別便是由函式的簽名決定的,一個匿名函式被賦值給一個變數後,這個變數便只能接收同樣簽名的函式。

func main(){
    hello := func(){
        fmt.Println("Hello World")
    } // 給 hello 變數賦值一個匿名函式
    hello()
    
    hello = func(){
        fmt.Println("Hello World2")
    } // 重新賦值新的匿名函式
    hello()
    
    hello = hi // 將一個普通函式賦值給 hello
    hello()
    
    hello = func(int){
        fmt.Println("Hello World3")
    } // 這裡編譯器會報錯
    hello()
}

func hi(){
    fmt.Println("Hi")
}

匿名函式 跟普通函式的微小區別在於 匿名函式 賦值的變數可以重新設定新的 匿名函式,但普通函式的函式名是與特定函式繫結的,無法再將其它函式賦值給它。這就類似於變數與常量之間的區別。

閉包的特性

說完了 匿名函式,我們再回過頭來看看 閉包

閉包 是指由一個擁有許多變數和繫結了這些變數的環境的 匿名函式
閉包 = 函式 + 引用環境

聽起來有點繞,什麼是 引用環境呢?

引用環境 是指在程式執行中的某個點所有處於活躍狀態的變數所組成的集合。

由於閉包把函式和執行時的引用環境打包成為一個新的整體,所以就解決了函式程式設計中的巢狀所引發的問題。

當每次呼叫包含閉包的函式時都將返回一個新的閉包例項,這些例項之間是隔離的,分別包含呼叫時不同的引用環境現場。不同於函式,閉包在執行時可以有多個例項,不同的引用環境和相同的函式組合可以產生不同的例項。

簡單來說,閉包 就是引用了外部變數的匿名函式。不太明白?沒關係,讓我們先來看一個栗子:

func adder() func() int {
	var i = 0
	return func() int {
		i++
		return i
	}
}

這是用閉包實現的簡單累加器,這一部分便是閉包,它引用在其作用域範圍之外的變數i。

func() int {
    i++
    return i
}

可以這樣使用:

func main() {
	a := adder()
	fmt.Println(a())
	fmt.Println(a())
	fmt.Println(a())
	fmt.Println(a())
    b := adder()
	fmt.Println(b())
	fmt.Println(b())
}

輸出如下:

1
2
3
4
1
2

上述例子中,adder 是一個函式,沒有入參,返回值是一個返回 int 型別的無參函式,也就是說呼叫 adder 函式會返回一個函式,這個函式的返回值是 int 型別,且不接收引數。

main 方法中:

a := adder()

這裡是將呼叫後得到的函式賦值給了變數 a ,隨後進行了四次函式呼叫和輸出:

fmt.Println(a())
fmt.Println(a())
fmt.Println(a())
fmt.Println(a())

也許你還是會感到困惑,iadder 函式裡的變數,呼叫完成之後變數的生命週期不久結束了嗎?為什麼還能不斷累加?

這就涉及到閉包的另一個重要話題了:閉包 會讓被引用的區域性變數從棧逃逸到堆上,從而使其能在其作用域範圍之外存活。閉包 “捕獲”了和它在同一作用域的其它常量和變數。這就意味著當閉包被呼叫的時候,不管在程式什麼地方呼叫,閉包能夠使用這些常量或者變數。它不關心這些捕獲了的變數和常量是否已經超出了作用域,只要閉包還在使用它們,這些變數就還會存在。

匿名函式和閉包的使用

可以利用匿名函式閉包可以實現很多有意思的功能,比如上面的累加器,便是利用了 閉包 的作用域隔離特性,每呼叫一次 adder 函式,就會生成一個新的累加器,使用新的變數 i,所以在呼叫 b() 時,仍舊會從1開始輸出。

再來看幾個匿名函式閉包應用的例子。

工廠函式

工廠函式即生產函式的函式,呼叫工廠函式可以得到其內嵌函式的引用,每次呼叫都可以得到一個新的函式引用。

func getFibGen() func() int {
	f1 := 0
	f2 := 1
	return func() int {
		f2, f1 = f1 + f2, f2
		return f1
	}
}

func main() {
	gen := getFibGen()
	for i := 0; i < 10; i++ {
		fmt.Println(gen())
	}
}

上面是利用閉包實現的函式工廠來求解斐波那契數列問題,呼叫 getFibGen 函式之後,gen 便獲得了內嵌函式的引用,且該函式引用裡一直持有 f1f2 的引用,每執行一次 gen(),便會運算一次斐波那契的遞推關係式:

func() int {
    f2, f1 = f1 + f2, f2
    return f1
}

輸出如下:

1
1
2
3
5
8
13
21
34
55

由於閉包能構造出單獨的變數環境,可以很好的實現環境隔離,所以很適合應用於函式工廠,在實現功能時儲存某些狀態變數。

裝飾器/中介軟體

修飾器是指在不改變物件的內部結構情況下,動態地擴充套件物件的功能。通過建立一個裝飾器,來包裝真實的物件。使用閉包很容易實現裝飾器模式

在 gin 中的 Middleware 便是使用裝飾器模式來實現的。比如我們可以這樣實現一個自定義的 Logger:

func Logger() gin.HandlerFunc {
	return func(context *gin.Context) {
		host := context.Request.Host
		url := context.Request.URL
		method := context.Request.Method
		fmt.Printf("%s::%s \t %s \t %s \n", time.Now().Format("2006-01-02 15:04:05"), host, url, method)
		context.Next()
        fmt.Println("response status: ", context.Writer.Status())
	}
}

這是在 gin 中利用 匿名函式 實現的自定義日誌中介軟體,在 gin 中,類似的用法十分常見。

defer

這是匿名函式閉包最常用的地方,我們會經常在 defer 函式中使用匿名函式閉包來做釋放鎖,關閉連線,處理 panic 等函式善後工作。

func main() {
    defer func() {
        if ok := recover(); ok != nil {
            fmt.Println("recover from panic")
        }
    }()

    panic("error")
}

gorutine

匿名函式閉包還有一個十分常用的場景,那便是在啟動 gorutine 時使用。

func main(){
    go func(){
        fmt.Println("Hello World")
    }()
    time.Sleep(1 * time.Second)
}

重新宣告一下,在函式內部引用了外部變數便是閉包,否則就是匿名函式

func main(){
    hello := "Hello World"
    go func(){
        fmt.Println(hello)
    }()
    time.Sleep(1 * time.Second)
}

context

在cancelContext中也使用到了閉包:

// A CancelFunc tells an operation to abandon its work.
// A CancelFunc does not wait for the work to stop.
// A CancelFunc may be called by multiple goroutines simultaneously.
// After the first call, subsequent calls to a CancelFunc do nothing.
type CancelFunc func()

// WithCancel returns a copy of parent with a new Done channel. The returned
// context's Done channel is closed when the returned cancel function is called
// or when the parent context's Done channel is closed, whichever happens first.
//
// Canceling this context releases resources associated with it, so code should
// call cancel as soon as the operations running in this Context complete.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	c := newCancelCtx(parent)
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}

// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx {
	return cancelCtx{Context: parent}
}

閉包的陷阱

閉包很好用,但在某些場景下,也十分具有欺騙性,稍有不慎,就會掉入其陷阱裡。

不如先來看一個例子:

for j := 0; j < 2; j++ {
	defer func() {
		fmt.Println(j)
	}()
}

你猜會輸出什麼?

2
2

這是因為在 defer 中使用的閉包引用了外部變數 j

閉包 中持有的是外部變數的引用

這是很容易犯的錯誤,在迴圈體中使用 defer,來關閉連線,釋放資源,但由於閉包內持有的是外部變數的引用,在這裡持有的是變數 j 的引用,defer 會在函式執行完成前呼叫閉包,在開始執行閉包時,j 的值已經是2了。

那麼這個問題應該如何修復呢?有兩種方式,一種是重新定義變數:

for j := 0; j < 2; j++ {
    k := j
	defer func() {
		fmt.Println(k)
	}()
}

在迴圈體裡,每次迴圈都定義了一個新的變數 k 來獲取原變數 j 的值,因此每次呼叫閉包時,引用的是不同的變數 k,從而達到變數隔離的效果。

另一種方式是把變數當成引數傳入:

for j := 0; j < 2; j++ {
	defer func(k int) {
		fmt.Println(k)
	}(j)
}

這裡每次呼叫閉包時,傳入的都是變數 j 的值,雖然 defer 仍會在函式執行完成前呼叫,但傳入閉包的引數值卻是先計算好的,因而能夠正確輸出。

閉包返回的包裝物件是一個複合結構,裡面包含匿名函式的地址,以及環境變數的地址。

為了更好的理解這一點,我們再來看一個例子:

package main

import "fmt"

func main() {
    x, y := 1, 2

    defer func(a int) { 
        fmt.Printf("x:%d,y:%d\n", a, y)  
    }(x)     

    x += 1
    y += 1
    fmt.Println(x, y)
}

輸出如下:

2 3
x:1,y:3

另外,由於閉包會使得其持有的外部變數逃逸出原有的作用域,所以使用不當可能會造成記憶體洩漏,這一點由於相當具有隱蔽性,所以也需要謹慎對待。

總結

閉包是一種特殊的匿名函式,是由函式體和引用的外部變數一起組成,可以看成類似如下結構:

type FF struct {
	F unitptr
	A *int
	B *int
	X *int // 如果X是string/[]int,那麼這裡應該為*string,*[]int
}

在Go語言中,閉包的應用十分廣泛,掌握了閉包的使用可以讓你在寫程式碼時能更加遊刃有餘,也可以避免很多不必要的麻煩。所以是必須要掌握的一個知識點。

至此,關於閉包的內容就完結了,希望能對你有幫助。

相關文章