天天看點

Go函數--匿名函數與閉包0 匿名函數概念 1 匿名函數2 閉包(Closure) — 引用了外部變量的匿名函數參考

0 匿名函數概念

Go語言提供兩種函數:有名函數和匿名函數。所謂匿名函數就是沒有函數名的函數。匿名函數沒有函數名,隻有函數體。它和有名函數的最大差別是:我們可以在函數内部定義匿名函數,形成類似嵌套的效果。

 匿名函數常用于實作回調函數、閉包等。

 1 匿名函數

 1.1 匿名函數的定義格式

func (參數清單) (傳回值參數清單) {
    函數體
}
           

 <說明> 匿名函數除了沒有函數名外,和普通函數完全相同。

1.2 匿名函數的特點

1、可以在定義匿名函數的同時直接調用執行。示例代碼如下:

func main() {
    func(s string) {
        fmt.Println(s)
    }("hello, world")    //傳入實參對匿名函數進行調用
}
           

 《代碼說明》上面示例中,定義了匿名函數體後,直接傳入實參調用匿名函數。

2、可以将匿名函數指派給函數變量。示例代碼如下:

func main(){
    add := func(x, y int) int {      //将匿名函數指派給函數變量add
        return x + y
    }
    
    fmt.Printf("add type: %T\n", add)  //列印變量add的類型
    fmt.Printf("add(1, 2)= %d\n", add(1, 2))
}
           

運作結果:

add type: func(int, int) int
add(1, 2)= 3
           

 《代碼說明》上面示例中,将匿名函數體指派給一個函數變量add,然後通過這個函數變量來調用匿名函數。

<提示> 将匿名函數指派給變量,與為普通函數提供函數名辨別符有着根本的差別。當然,編譯器會為匿名函數生成一個“随機”的符号名。

3、可以将匿名函數作為函數實參。其實這種代碼設計方式就是将匿名函數作為回調函數來使用。示例代碼如下:

//周遊切片的元素,通過給定函數通路切片元素
func visit(list []int, f func(int)) {
    for _, v := range list {
        f(v)
    }
}

func main() {
    //使用匿名函數列印切片内容
    visit([]int{1,2,3,4}, func(v int){
        fmt.Println(v)
    })
}
           

《代碼說明》上面的代碼中,使用匿名函數作為函數實參,傳遞給visit()函數的形參f,形參f是一個函數類型。在visit()函數中,就可以使用函數變量f 通路作為實參的匿名函數了。

<提示> 匿名函數作為回調函數來使用在Go語言的标準包中也是比較常見的。

4、匿名函數可以作為函數傳回值使用。示例代碼如下:

func test() func(int, int) int {
    return func(x, y int) int {
        return x + y
    }
}

func main() {
    add := test()
    
    fmt.Printf("add type: %T\n", add)       // add type: func(int, int) int
    fmt.Printf("add value: %v\n", add)      // add value: 0x49a800
    fmt.Printf("add(1,2)= %d\n", add(1,2))  // add(1,2)= 3
}
           

《代碼說明》test()函數的傳回值傳回的是一個函數類型:func(int, int) int 的值。在test()函數體中,使用了匿名函數作為函數的傳回值,然後在main()函數中,将test()函數的傳回值指派給一個函數變量add,它的類型和test()函數的傳回值類型是一樣的。從運作結果可以看出,test()函數傳回的是匿名函數的入口位址,并指派給函數變量add,然後通過這個函數變量add來調用匿名函數。

2 閉包(Closure) — 引用了外部變量的匿名函數

2.1 閉包的概念

閉包是引用了其外部作用域的變量的函數。這個函數在Go語言中一般是匿名函數。在《Go語言核心程式設計》一書中,是這樣描述閉包概念的:閉包是由函數及其相關引用環境組合而成的實體,一般通過在匿名函數中引用外部函數的局部變量或包全局變量構成。

簡單的說就是:閉包 = 函數 + 引用環境。

  • 函數:一般都是匿名函數。
  • 引用環境:匿名函數引用的其外部作用域的變量,稱為環境變量。

示例1:閉包的使用。

package main

import (
    "fmt"
)

func adder() func(int) int {
    var x int
    return func(y int) int {  //匿名函數引用了其外部作用域變量x,而x是該匿名函數外圍函數adder()的局部變量
        x += y
        return x
    }
}

func main() {
    var f = adder()    //
    fmt.Println(f(10)) //x=0,y=10 輸出:10
    fmt.Println(f(20)) //x=10,y=30  輸出:30
    fmt.Println(f(30)) //x=30,y=30  輸出:60

    f1 := adder()
    fmt.Println(f1(40)) //x=0,y=40   輸出:40
    fmt.Println(f1(50)) //x=40,y=50  輸出:90
}
           

《代碼說明》adder()函數傳回的匿名函數中直接引用了上下文環境變量x,注意這個變量x不是在匿名函數中定義的,而是在adder()函數中定義的,它是adder()函數的局部變量。當adder()函數傳回匿名函數,然後在main()函數中執行 f(10),它依然可以讀取x的值,這種現象就稱作閉包。

變量f 是一個函數變量并且它引用了其外部作用域中的變量x,此時 f 就是一個閉包。在閉包f 的生命周期内,被閉包引用的環境變量x也會一直存在,并且閉包擁有記憶效應,它能夠儲存上一次調用閉包f 時環境變量x被修改後的值。

此時,我們不禁要問:x不是adder()函數的局部變量嗎,adder()函數都已經傳回了,怎麼它的局部變量x還能在main函數中被通路到呢?這個我們就需要知道閉包的底層實作原理是什麼了,閉包的底層實作将會在下一篇部落格中詳細分析。簡單描述就是:

(1)函數可以作為傳回值。

(2)函數内部查找變量的順序,先在自己内部找,找不到往外層找。這個外層變量就是閉包引用的環境變量。

示例2:

package main

import (
    "fmt"
)

func test(x int) func() {
    fmt.Printf("test.x: &x=%p, x=%d\n", &x, x)
    return func(){
        fmt.Println("closure.x:", &x, x)
    }
}

func main(){
    f := test(0x100)
    //fmt.Printf("f type: %T, f value: %v\n", f, f)
    f()
}
           

運作結果:go run demo.go

test.x: &x=0xc000014088, x=256
closure.x: 0xc000014088 256
           

《結果分析》通過輸出變量x的位址,我們注意到在閉包中直接引用了原環境變量x。我們使用go build指令來檢視一下:

$ go build -gcflags '-m -l' demo.go
# command-line-arguments
./demo.go:7:11: moved to heap: x
./demo.go:8:15: ... argument does not escape
./demo.go:8:41: x escapes to heap
./demo.go:9:12: func literal escapes to heap
./demo.go:10:20: ... argument does not escape
./demo.go:10:21: "closure.x:" escapes to heap
./demo.go:10:35: x escapes to heap
           

可以看到,在編譯期, 變量x被移動到堆記憶體中了,這就能解釋為什麼x的生命期在test()函數傳回後還能繼續存在了。事實上,編譯器在編譯的時候,如果檢測到閉包,就會将閉包引用的外部變量移動到堆上。

 2.2 閉包的使用

如果函數傳回的閉包引用了該函數的局部變量(函數參數或函數内部變量),使用閉包的注意事項:

1、多次調用閉包的外圍函數,傳回的多個閉包所引用的外部環境變量是多個副本,原因是每次調用函數都會為局部變量配置設定記憶體。

2、調用一個閉包函數多次,如果該閉包修改了其引用的外部環境變量,則每一次調用該閉包都會對該閉包産生影響,因為閉包函數共享其外部引用。

示例3:

func fa(a int) func(int) int {
    return func(i int) int {
        fmt.Printf("&a=%p, a=%d\n", &a, a)
        a += i
        return a
    }
}

func main(){
    f := fa(1)  //f引用的外部的閉包環境包括本次函數調用的形參a的值1
    g := fa(1)  //g引用的外部的閉包環境包括本次函數調用的形參a的值1
	
    //此時f、g引用的閉包環境變量a的值并不是同一個,而是兩次函數調用産生到副本
    
    fmt.Printf("f(1)=%d\n", f(1))
    //多次調用f引用的是同一個副本
    fmt.Printf("f(1)=%d\n", f(1))
    
    //g中的a的值仍然是1
    fmt.Printf("g(1)=%d\n", g(1))
    fmt.Printf("g(1)=%d\n", g(1))
}
           

運作結果:

&a=0xc000016090, a=1
f(1)=2
&a=0xc000016090, a=2
f(1)=3
&a=0xc000016098, a=1
g(1)=2
&a=0xc000016098, a=2
g(1)=3
           

《代碼分析》從運作結果可以看到,f、g引用的閉包中的環境變量a是兩個不同的副本,也就是說f、g引用的是兩個不同的閉包。

3、如果一個函數調用傳回的閉包引用的環境變量是全局變量,則每次調用都會影響全局變量。即使是多次調用外圍函數傳回的多個閉包引用的都是同一個環境變量。

示例4:修改示例3中的代碼,将閉包中引用的局部變量改為全局變量。

var (
    a = 0
)

func fa() func(int) int {
    return func(i int) int {
        fmt.Printf("&a=%p, a=%d\n", &a, a)
        a += i
        return a
    }
}

func main(){
    f := fa()   //f引用的外部的閉包環境包括全局變量a
    g := fa()   //g引用的外部的閉包環境包括全局變量a
    
    //此時f、g引用的閉包環境變量a的值是同一個
    
    fmt.Printf("f(1)=%d\n", f(1))
    fmt.Printf("f(1)=%d\n", f(1))
    fmt.Printf("g(1)=%d\n", g(1))
    fmt.Printf("g(1)=%d\n", g(1))
}
           

運作結果:

&a=0x5868e0, a=0
f(1)=1
&a=0x5868e0, a=1
f(1)=2
&a=0x5868e0, a=2
g(1)=3
&a=0x5868e0, a=3
g(1)=4
           

<提示> 使用閉包的目的就是為了減少全局變量的使用,是以閉包引用全局變量不是好的程式設計方式。

4、同一個函數傳回的多個閉包共享該函數的局部變量。

示例5:

func fa(base int) (func(int) int, func(int) int) {
    fmt.Printf("fa: &base=%p, base=%d\n", &base, base)
    
    add := func(i int) int {
        base += i            //匿名函數引用了fa的局部變量base
        fmt.Printf("add: &base=%p, base=%d\n", &base, base)
        return base
    }
    
    sub := func(i int) int {
        base -= i            //匿名函數引用了fa的局部變量base
        fmt.Printf("sub: &base=%p, base=%d\n", &base, base)
        return base
    }
    
    return add, sub
}

func main(){
    f,g := fa(0)  //f、g閉包引用的base是同一個,是fa函數調用傳遞的實參值
    
    s,k := fa(0)  //s、k閉包引用的base是同一個,是fa函數調用傳遞的實參值
    
    //f,g和s,k 這兩組引用是不同的閉包變量,這是由于每次調用fa都要重新配置設定形參
    fmt.Printf("f(1)=%d, g(2)=%d\n", f(1), g(2))
    fmt.Printf("s(1)=%d, k(2)=%d\n", s(1), k(2))
}
           

運作結果:

fa: &base=0xc000016090, base=0
fa: &base=0xc000016098, base=0
add: &base=0xc000016090, base=1
sub: &base=0xc000016090, base=-1
f(1)=1, g(2)=-1
add: &base=0xc000016098, base=1
sub: &base=0xc000016098, base=-1
s(1)=1, k(2)=-1
           

《結果分析》fa()函數同時傳回了兩個閉包函數,這兩個閉包函數共享了其引用環境變量base,這兩個閉包其中任何一方對base的修改行為都會影響另一個閉包對base的取值,是以在并發環境下可能需要做同步處理。

【##】面試題。

func calc(base int) (func(int) int, func(int) int) {
    add := func(i int) int {
        base += i
        return base
    }

    sub := func(i int) int {
        base -= i
        return base
    }
    return add, sub
}

func main() {
    f1, f2 := calc(10)
    fmt.Println(f1(1), f2(2)) //11 9
    fmt.Println(f1(3), f2(4)) //12 8
    fmt.Println(f1(5), f2(6)) //13 7
}
           

《代碼分析》calc()函數傳回了兩個閉包f1、f2,這兩個閉包都引用了calc()函數的局部變量base,那麼此時閉包f1、f2共享引用環境變量base。

2.3 閉包的注意事項

示例6:

func test() []func() {
    var s []func()

    for i := 0; i < 3; i++ {
        s = append(s, func() {  //将多個匿名函數添加到切片
            fmt.Println(&i, i)
        })
    }

    return s    //傳回匿名函數切片
}
func main() {
    for _, f := range test() {  //執行所有匿名函數
        f()   
    }
}
           

運作結果:

0xc000016090 3
0xc000016090 3
0xc000016090 3
           

《代碼分析》每次 

append

 操作僅僅是将匿名函數放入到清單中,但并未執行,并且引用的環境變量

都是同一變量i,

随着 

i

 的改變匿名函數中的 

i

 也在改變,是以當在main函數中執行這些函數時,他們讀取的是環境變量 

i

 最後一次循環時的值=3。解決辦法就是每次使用不同的環境變量,讓各自閉包引用的環境變量各不相同。修改後的代碼如下:

func test() []func() {
    var s []func()

    for i := 0; i < 3; i++ {
        x := i                  //x 每次循環都重新定義
        //fmt.Printf("&x=%p, x=%d\n", &x, x)
        s = append(s, func() {  //将多個匿名函數添加到切片
            fmt.Println(&x, x)
        })
    }

    return s    //傳回匿名函數切片
}
func main() {
    for _, f := range test() {  //執行所有匿名函數
        f()   
    }
}
           

 運作結果:

0xc000016090 0
0xc000016098 1
0xc0000160a0 2
           

2.4 閉包的價值

閉包的最初目的是減少全局變量的使用,在函數調用過程中隐式地傳遞共享變量,有其有用的一面;但是這種隐秘的共享變量的方式帶來的壞處是不夠直接,不夠清晰,除非是非常有價值的地方,一般不建議使用閉包。閉包讓我們不用傳遞參數就可以讀取或修改環境狀态,當然這也需要付出額外的代價。對于性能要求較高的場合,須慎重使用。

【##】對象和閉包的差別

對象是附有行為的資料,而閉包是附有資料的行為。類在定義時就已經顯式地集中定義了行為,但是閉包中的資料沒有顯式地集中聲明的地方,這種資料和行為耦合的模型不是一種推薦的程式設計模型,閉包僅僅是錦上添花的東西,不是不可缺少的。

2.4 後記

我将在下一篇部落格中詳細分析Go語言閉包的底層實作原理。

參考

《Go語言從入門到進階實戰(視訊教學版)》

《Go語言核心程式設計》

《Go語言學習筆記》

《Go語言程式設計》

Go語言基礎之函數

GO 匿名函數和閉包