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


前言

入坑 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語言中,閉包的應用十分廣泛,掌握了閉包的使用可以讓你在寫代碼時能更加游刃有余,也可以避免很多不必要的麻煩。所以是必須要掌握的一個知識點。

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM