一道 Go 閉包題,面試官說原來自己答錯了:面別人也漲知識
大家好,我是站長 polarisxu。
通常,JS 面試,閉包應該是必考的題目。隨著越來越多的語言對函數式范式的支持,閉包問題經常出現。在 Go 語言中也是如此。
本文從一道題引出 Go 中的閉包。這是 Go 語言愛好者周刊第 90 期的一道題目。以下代碼輸出什么?
- package main
- import "fmt"
- func app() func(string) string {
- t := "Hi"
- c := func(b string) string {
- t = t + " " + b
- return t
- }
- return c
- }
- func main() {
- a := app()
- b := app()
- a("go")
- fmt.Println(b("All"))
- }
這道題目答對的人蠻多的:60%。不管你是答對還是答錯,如果最后再加一行代碼:fmt.Println(a("All")),它輸出什么?想看看你是不是蒙對了。(提示:你可以輸出 t 的地址,看看是什么情況。)
01 什么是閉包
維基百科對閉包的定義:
在計算機科學中,閉包(英語:Closure),又稱詞法閉包(Lexical Closure)或函數閉包(function closures),是在支持頭等函數的編程語言中實現詞法綁定的一種技術。閉包在實現上是一個結構體,它存儲了一個函數(通常是其入口地址)和一個關聯的環境(相當于一個符號查找表)。環境里是若干對符號和值的對應關系,它既要包括約束變量(該函數內部綁定的符號),也要包括自由變量(在函數外部定義但在函數內被引用),有些函數也可能沒有自由變量。閉包跟函數最大的不同在于,當捕捉閉包的時候,它的自由變量會在捕捉時被確定,這樣即便脫離了捕捉時的上下文,它也能照常運行。捕捉時對于值的處理可以是值拷貝,也可以是名稱引用,這通常由語言設計者決定,也可能由用戶自行指定(如 C++)。
關于(函數)閉包,有幾個關鍵點:
- 函數是一等公民;
- 閉包所處環境,可以引用環境里的值;
問到什么是閉包時,網上一般這么回答的:
在支持函數是一等公民的語言中,一個函數的返回值是另一個函數,被返回的函數可以訪問父函數內的變量,當這個被返回的函數在外部執行時,就產生了閉包。
所以,上面題目中,函數 app 的返回值是另一個函數,因此產生了閉包。
02 Go 中的閉包
Go 中的函數是一等公民,之前寫過一篇文章:函數是一等公民,這到底在說什么?
日常開發中,閉包是很常見的。舉幾個例子。
標準庫
在 net/http 包中的函數 ProxyURL,實現如下:
- // ProxyURL returns a proxy function (for use in a Transport)
- // that always returns the same URL.
- func ProxyURL(fixedURL *url.URL) func(*Request) (*url.URL, error) {
- return func(*Request) (*url.URL, error) {
- return fixedURL, nil
- }
- }
它的返回值是另一個函數,簽名是:
- func(*Request) (*url.URL, error)
在返回的函數中,引用了父函數(ProxyURL)的參數 fixedURL,因此這是閉包。
Web 中間件
在 Web 開發中,中間件一般都會使用閉包。比如 Echo 框架中的一個中間件:
- // BasicAuthWithConfig returns an BasicAuth middleware with config.
- // See `BasicAuth()`.
- func BasicAuthWithConfig(config BasicAuthConfig) echo.MiddlewareFunc {
- // Defaults
- if config.Validator == nil {
- panic("echo: basic-auth middleware requires a validator function")
- }
- ...
- return func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(c echo.Context) error {
- /// 省略很多代碼
- ...
- }
- }
- }
首先,echo.MiddlewareFunc 是一個函數:
- type MiddlewareFunc func(HandlerFunc) HandlerFunc
而 echo.HandlerFunc 也是一個函數:
- type HandlerFunc func(Context) error
所以,上面的函數嵌套了幾層,是典型的閉包。
這是閉包嗎?
在 Go 中不支持函數嵌套定義,函數內嵌套函數,必須通過匿名函數的形式。匿名函數在 Go 中是很常見的,比如開啟一個 goroutine,通常通過匿名函數。
現在有一個問題,以下代碼是閉包嗎?
- package main
- import (
- "fmt"
- )
- func main() {
- a := 5
- func() {
- fmt.Println("a =", a)
- }()
- }
如果按照上面網上一般的回答,這不是閉包,因為并沒有返回函數。但按照維基百科的定義,這個屬于閉包。有沒有其他證據呢?
在 Go 語言規范中,關于函數字面值(匿名函數)有這么一句話:
Function literals are closures: they may refer to variables defined in a surrounding function. Those variables are then shared between the surrounding function and the function literal, and they survive as long as they are accessible.
也就是說,函數字面值(匿名函數)是閉包,它們可以引用外層函數定義的變量。
此外,在官方 FAQ 中有這樣的說明:
What happens with closures running as goroutines?
例子是:
- func main() {
- done := make(chan bool)
- values := []string{"a", "b", "c"}
- for _, v := range values {
- go func() {
- fmt.Println(v)
- done <- true
- }()
- }
- // wait for all goroutines to complete before exiting
- for _ = range values {
- <-done
- }
- }
這是 Go 中很常見的代碼(很容易寫錯的),FAQ 稱開啟 goroutine 的那個匿名函數是一個閉包。
03 匯編看看實現
回到開始的題目,我們通過匯編看看,Go 閉包的實現,是不是按照維基百科說的,「閉包在實現上是一個結構體,它存儲了一個函數(通常是其入口地址)和一個關聯的環境(相當于一個符號查找表)」。
- $ go tool compile -S main.go
看關鍵代碼:
- 0x0000 00000 (main.go:5) TEXT "".app(SB), ABIInternal, $24-8
- 0x0000 00000 (main.go:5) MOVQ (TLS), CX
- 0x0009 00009 (main.go:5) CMPQ SP, 16(CX)
- 0x000d 00013 (main.go:5) PCDATA $0, $-2
- 0x000d 00013 (main.go:5) JLS 96
- 0x000f 00015 (main.go:5) PCDATA $0, $-1
- 0x000f 00015 (main.go:5) SUBQ $24, SP
- 0x0013 00019 (main.go:5) MOVQ BP, 16(SP)
- 0x0018 00024 (main.go:5) LEAQ 16(SP), BP
- 0x001d 00029 (main.go:5) FUNCDATA $0, gclocals·2a5305abe05176240e61b8620e19a815(SB)
- 0x001d 00029 (main.go:5) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
- 0x001d 00029 (main.go:7) LEAQ type.noalg.struct { F uintptr; "".t string }(SB), AX
- 0x0024 00036 (main.go:7) MOVQ AX, (SP)
- 0x0028 00040 (main.go:7) PCDATA $1, $0
- 0x0028 00040 (main.go:7) CALL runtime.newobject(SB)
- 0x002d 00045 (main.go:7) MOVQ 8(SP), AX
- 0x0032 00050 (main.go:7) LEAQ "".app.func1(SB), CX
- 0x0039 00057 (main.go:7) MOVQ CX, (AX)
- 0x003c 00060 (main.go:7) MOVQ $2, 16(AX)
- 0x0044 00068 (main.go:7) LEAQ go.string."Hi"(SB), CX
- 0x004b 00075 (main.go:7) MOVQ CX, 8(AX)
- 0x004f 00079 (main.go:10) MOVQ AX, "".~r0+32(SP)
- 0x0054 00084 (main.go:10) MOVQ 16(SP), BP
- 0x0059 00089 (main.go:10) ADDQ $24, SP
- 0x005d 00093 (main.go:10) RET
- 0x005e 00094 (main.go:10) NOP
其中 LEAQ type.noalg.struct { F uintptr; "".t string }(SB), AX 這行表明 Go 對閉包的實現和維基百科說的類似。
現在看看下面這種是不是這么實現的:
- package main
- import (
- "fmt"
- )
- func main() {
- a := 5
- func() {
- fmt.Println("a =", a)
- }()
- }
看看匯編
- $ go tool compile -S test.go
- "".main.func1 STEXT size=215 args=0x8 locals=0x50 funcid=0x0
- 0x0000 00000 (test.go:9) TEXT "".main.func1(SB), ABIInternal, $80-8
- 0x0000 00000 (test.go:9) MOVQ (TLS), CX
- 0x0009 00009 (test.go:9) CMPQ SP, 16(CX)
- 0x000d 00013 (test.go:9) PCDATA $0, $-2
- 0x000d 00013 (test.go:9) JLS 205
- 0x0013 00019 (test.go:9) PCDATA $0, $-1
- 0x0013 00019 (test.go:9) SUBQ $80, SP
- 0x0017 00023 (test.go:9) MOVQ BP, 72(SP)
- 0x001c 00028 (test.go:9) LEAQ 72(SP), BP
- 0x0021 00033 (test.go:9) FUNCDATA $0, gclocals·69c1753bd5f81501d95132d08af04464(SB)
- 0x0021 00033 (test.go:9) FUNCDATA $1, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB)
- 0x0021 00033 (test.go:10) MOVQ "".a+88(SP), AX
- 0x0026 00038 (test.go:10) MOVQ AX, (SP)
- 0x002a 00042 (test.go:10) PCDATA $1, $0
- 0x002a 00042 (test.go:10) CALL runtime.convT64(SB)
- 0x002f 00047 (test.go:10) MOVQ 8(SP), AX
- 0x0034 00052 (test.go:10) MOVQ AX, ""..autotmp_21+64(SP)
- 0x0039 00057 (test.go:10) LEAQ type.[2]interface {}(SB), CX
- 0x0040 00064 (test.go:10) MOVQ CX, (SP)
- 0x0044 00068 (test.go:10) PCDATA $1, $1
- 0x0044 00068 (test.go:10) CALL runtime.newobject(SB)
- 0x0049 00073 (test.go:10) MOVQ 8(SP), AX
- 0x004e 00078 (test.go:10) LEAQ type.string(SB), CX
- 0x0055 00085 (test.go:10) MOVQ CX, (AX)
- 0x0058 00088 (test.go:10) LEAQ ""..stmp_1(SB), CX
- 0x005f 00095 (test.go:10) MOVQ CX, 8(AX)
- 0x0063 00099 (test.go:10) LEAQ type.int(SB), CX
- 0x006a 00106 (test.go:10) MOVQ CX, 16(AX)
- 0x006e 00110 (test.go:10) PCDATA $0, $-2
- 0x006e 00110 (test.go:10) CMPL runtime.writeBarrier(SB), $0
- 0x0075 00117 (test.go:10) JNE 189
- 0x0077 00119 (test.go:10) MOVQ ""..autotmp_21+64(SP), CX
- 0x007c 00124 (test.go:10) MOVQ CX, 24(AX)
- 0x0080 00128 (test.go:10) PCDATA $0, $-1
- 0x0080 00128 (test.go:10) PCDATA $1, $-1
發現并沒有這樣的結構體,可見 Go 對這種情況做了特殊處理,因為它不是重復使用的匿名函數。
04 總結
通過以上的講解,對閉包應該有了更清晰的認識。如果面試中再被問到閉包,你可以這么回答:
對閉包來說,函數在該語言中得是一等公民。一般來說,一個函數返回另外一個函數,這個被返回的函數可以引用外層函數的局部變量,這形成了一個閉包。通常,閉包通過一個結構體來實現,它存儲一個函數和一個關聯的上下文環境。但 Go 語言中,匿名函數就是一個閉包,它可以直接引用外部函數的局部變量,因為 Go 規范和 FAQ 都這么說了。
面試官會不會被你驚到:原來如此,后一種說法我之前沒有注意過。
本文轉載自微信公眾號「polarisxu」,可以通過以下二維碼關注。轉載本文請聯系polarisxu公眾號。