Go Quiz: 從Go面試題看函式命名返回值的注意事項(超過80%的人都回答錯了)

coding進階發表於2022-02-24

題目

Redhat的首席工程師、Prometheus開源專案Maintainer Bartłomiej Płotka 在Twitter上出了一道Go程式設計題,結果超過80%的人都回答錯了。

題目如下所示,回答下面這段程式的輸出結果。

// named_return.go
package main

import "fmt"

func aaa() (done func(), err error) {
    return func() { print("aaa: done") }, nil
}

func bbb() (done func(), _ error) {
    done, err := aaa()
    return func() { print("bbb: surprise!"); done() }, err
}

func main() {
    done, _ := bbb()
    done()
}
  • A: bbb: surprise!
  • B: bbb: surprise!aaa: done
  • C: 編譯報錯
  • D: 遞迴棧溢位

大家可以先思考下這段程式碼的輸出結果是什麼。

解析

在函式bbb最後執行return語句,會對返回值變數done進行賦值,

done := func() { print("bbb: surprise!"); done() }

注意:閉包func() { print("bbb: surprise!"); done() }裡的done並不會被替換成done, err := aaa()裡的done的值。

因此函式bbb執行完之後,返回值之一的done實際上成為了一個遞迴函式,先是列印"bbb: surprise!",然後再呼叫自己,這樣就會陷入無限遞迴,直到棧溢位。因此本題的答案是D

那為什麼函式bbb最後return的閉包func() { print("bbb: surprise!"); done() }裡的done並不會被替換成done, err := aaa()裡的done的值呢?如果替換了,那本題的答案就是B了。

這個時候就要搬出一句老話了:

This is a feature, not a bug

我們可以看下面這個更為簡單的例子,來幫助我們理解:

// named_return1.go
package main

import "fmt"

func test() (done func()) {
    return func() { fmt.Println("test"); done() }
}

func main() {
    done := test()
    // 下面的函式呼叫會進入死迴圈,不斷列印test
    done()
}

正如上面程式碼裡的註釋說明,這段程式同樣會進入無限遞迴直到棧溢位。

如果函式test最後return的閉包func() { fmt.Println("test"); done() }裡的done是被提前解析了的話,因為done是一個函式型別,done的零值是nil,那閉包裡的done的值就會是nil,執行nil函式是會引發panic的。

但實際上Go設計是允許上面的程式碼正常執行的,因此函式test最後return的閉包裡的done的值並不會提前解析,test函式執行完之後,實際上產生了下面的效果,返回的是一個遞迴函式,和本文開始的題目一樣。

done := func() { fmt.Println("test"); done() }

因此也會進入無限遞迴,直到棧溢位。

總結

這個題目其實很tricky,在實際程式設計中,要避免對命名返回值採用這種寫法,非常容易出錯。

想了解國外Go開發者對這個題目的討論詳情可以參考Go Named Return Parameters Discussion

另外題目作者也給瞭如下所示的解釋,原文地址可以參考詳細解釋

package main

func aaa() (done func(), err error) {
    return func() { print("aaa: done") }, nil
}

func bbb() (done func(), _ error) {
    // NOTE(bwplotka): Here is the problem. We already defined special "return argument" variable called "done".
    // By using `:=` and not `=` we define a totally new variable with the same name in
    // new, local function scope.
    done, err := aaa()

    // NOTE(bwplotka): In this closure (anonymous function), we might think we use `done` from the local scope,
    // but we don't! This is because Go "return" as a side effect ASSIGNS returned values to
    // our special "return arguments". If they are named, this means that after return we can refer
    // to those values with those names during any execution after the main body of function finishes
    // (e.g in defer or closures we created).
    //
    // What is happening here is that no matter what we do in the local "done" variable, the special "return named"
    // variable `done` will get assigned with whatever was returned. Which in bbb case is this closure with
    // "bbb:surprise" print. This means that anyone who runs this closure AFTER `return` did the assignment
    // will start infinite recursive execution.
    //
    // Note that it's a feature, not a bug. We use this often to capture
    // errors (e.g https://github.com/efficientgo/tools/blob/main/core/pkg/errcapture/doc.go)
    //
    // Go compiler actually detects that `done` variable defined above is NOT USED. But we also have `err`
    // variable which is actually used. This makes compiler to satisfy that unused variable check,
    // which is wrong in this context..
    return func() { print("bbb: surprise!"); done() }, err
}

func main() {
    done, _ := bbb()
    done()
}

不過這個解釋是有瑕疵的,主要是這句描述:

By using := and not = we define a totally new variable with the same name in
new, local function scope.

對於done, err := aaa(),返回變數done並不是一個新的變數,而是和函式bbb的返回變數done是同一個變數。

這裡有一個小插曲:本人把這個瑕疵反饋給了原作者,原作者同意了我的意見,刪除了這塊解釋


最新版的英文解釋如下,原文地址可以參考修正版解釋

package main

func aaa() (done func()) {
    return func() { print("aaa: done") }
}

func bbb() (done func()) {
    done = aaa()

    // NOTE(bwplotka): In this closure (anonymous function), we might think we use `done` value assigned to aaa(),
    // but we don't! This is because Go "return" as a side effect ASSIGNS returned values to
    // our special "return arguments". If they are named, this means that after return we can refer
    // to those values with those names during any execution after the main body of function finishes
    // (e.g in defer or closures we created).
    //
    // What is happening here is that no matter what we do with our "done" variable, the special "return named"
    // variable `done` will get assigned with whatever was returned when the function ends.
    // Which in bbb case is this closure with "bbb:surprise" print. This means that anyone who runs
    // this closure AFTER `return` did the assignment, will start infinite recursive execution.
    //
    // Note that it's a feature, not a bug. We use this often to capture
    // errors (e.g https://github.com/efficientgo/tools/blob/main/core/pkg/errcapture/doc.go)
    return func() { print("bbb: surprise!"); done() }
}

func main() {
    done := bbb()
    done()
}

思考題

下面這段程式碼同樣使用了命名返回值,大家可以看看這個道題的輸出結果是什麼。可以給微信公眾號傳送訊息nrv獲取答案。

package main

func bar() (r int) {
    defer func() {
        r += 4
        if recover() != nil {
            r += 8
        }
    }()
    
    var f func()
    defer f()
    f = func() {
        r += 2
    }

    return 1
}

func main() {
    println(bar())
}

開源地址

文章和示例程式碼開源在GitHub: Go語言初級、中級和高階教程

公眾號:coding進階。關注公眾號可以獲取最新Go面試題和技術棧。

個人網站:Jincheng's Blog

知乎:無忌

References

相關文章