1. 程式人生 > >Go 系列教程-8 錯誤處理

Go 系列教程-8 錯誤處理

Go 系列教程 —— 30. 錯誤處理

 

什麼是錯誤?

錯誤表示程式中出現了異常情況。比如當我們試圖開啟一個檔案時,檔案系統裡卻並沒有這個檔案。這就是異常情況,它用一個錯誤來表示。

在 Go 中,錯誤一直是很常見的。錯誤用內建的 error 型別來表示。

就像其他的內建型別(如 intfloat64 等),錯誤值可以儲存在變數裡、作為函式的返回值等等。

示例

現在我們開始編寫一個示例,該程式試圖開啟一個並不存在的檔案。

package main

import (  
    "fmt"
    "os"
)

func main() {  
    f, err := os.Open("/test.txt")
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(f.Name(), "opened successfully")
}

在 playground 中執行

在程式的第 9 行,我們試圖開啟路徑為 /test.txt 的檔案(playground 顯然並不存在這個檔案)。os 包裡的 Open 函式有如下簽名:

func Open(name string) (file *File, err error)

如果成功開啟檔案,Open 函式會返回一個檔案控制代碼(File Handler)和一個值為 nil 的錯誤。而如果開啟檔案時發生了錯誤,會返回一個不等於 nil 的錯誤

如果一個函式 或

方法 返回了錯誤,按照慣例,錯誤會作為最後一個值返回。於是 Open 函式也是將 err 作為最後一個返回值。

按照 Go 的慣例,在處理錯誤時,通常都是將返回的錯誤與 nil 比較。nil 值表示了沒有錯誤發生,而非 nil 值表示出現了錯誤。在這裡,我們第 10 行檢查了錯誤值是否為 nil。如果不是 nil,我們會簡單地打印出錯誤,並在 main 函式中返回。

執行該程式會輸出:

open /test.txt: No such file or directory

很棒!我們得到了一個錯誤,它指出該檔案並不存在。

錯誤型別的表示

讓我們進一步深入,理解 error 型別是如何定義的。error 是一個介面型別,定義如下:

type error interface {  
    Error() string
}

error 有了一個簽名為 Error() string 的方法。所有實現該介面的型別都可以當作一個錯誤型別。Error() 方法給出了錯誤的描述。

fmt.Println 在列印錯誤時,會在內部呼叫 Error() string 方法來得到該錯誤的描述。上一節示例中的第 11 行,就是這樣打印出錯誤的描述的。

從錯誤獲取更多資訊的不同方法

現在,我們知道了 error 是一個介面型別,讓我們看看如何從一個錯誤獲取更多資訊。

在前面的示例裡,我們只是打印出錯誤的描述。如果我們想知道這個錯誤的檔案路徑,該怎麼做呢?一種選擇是直接解析錯誤的字串。這是前面示例的輸出:

open /test.txt: No such file or directory

我們解析了這條錯誤資訊,雖然獲取了發生錯誤的檔案路徑,但是這種方法很不優雅。隨著語言版本的更新,這條錯誤的描述隨時都有可能變化,使我們程式出錯

有沒有更加可靠的方法來獲取檔名呢?答案是肯定的,這是可以做到的,Go 標準庫給出了各種提取錯誤相關資訊的方法。我們一個個來看看吧。

1. 斷言底層結構體型別,使用結構體欄位獲取更多資訊

如果你仔細閱讀了 Open 函式的文件,你可以看見它返回的錯誤型別是 *PathErrorPathError 是結構體型別,它在標準庫中的實現如下:

type PathError struct {  
    Op   string
    Path string
    Err  error
}

func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }

如果你有興趣瞭解上述原始碼出現的位置,可以在這裡找到:https://golang.org/src/os/error.go?s=653:716#L11。

通過上面的程式碼,你就知道了 *PathError 通過宣告 Error() string 方法,實現了 error 介面。Error() string 將檔案操作、路徑和實際錯誤拼接,並返回該字串。於是我們得到該錯誤資訊:

open /test.txt: No such file or directory

結構體 PathError 的 Path 欄位,就有導致錯誤的檔案路徑。我們修改前面寫的程式,打印出該路徑。

package main

import (  
    "fmt"
    "os"
)

func main() {  
    f, err := os.Open("/test.txt")
    if err, ok := err.(*os.PathError); ok {
        fmt.Println("File at path", err.Path, "failed to open")
        return
    }
    fmt.Println(f.Name(), "opened successfully")
}

在 playground 上執行

在上面的程式裡,我們在第 10 行使用了型別斷言(Type Assertion)來獲取 error 介面的底層值(Underlying Value)。接下來在第 11 行,我們使用 err.Path 來列印該路徑。該程式會輸出:

File at path /test.txt failed to open

很棒!我們已經使用型別斷言成功獲取到了該錯誤的檔案路徑。

2. 斷言底層結構體型別,呼叫方法獲取更多資訊

第二種獲取更多錯誤資訊的方法,也是對底層型別進行斷言,然後通過呼叫該結構體型別的方法,來獲取更多的資訊。

我們通過一個例項來理解這一點。

標準庫中的 DNSError 結構體型別定義如下:

type DNSError struct {  
    ...
}

func (e *DNSError) Error() string {  
    ...
}
func (e *DNSError) Timeout() bool {  
    ... 
}
func (e *DNSError) Temporary() bool {  
    ... 
}

從上述程式碼可以看到,DNSError 結構體還有 Timeout() bool 和 Temporary() bool 兩個方法,它們返回一個布林值,指出該錯誤是由超時引起的,還是臨時性錯誤。

接下來我們編寫一個程式,斷言 *DNSError 型別,並呼叫這些方法來確定該錯誤是臨時性錯誤,還是由超時導致的。

package main

import (  
    "fmt"
    "net"
)

func main() {  
    addr, err := net.LookupHost("golangbot123.com")
    if err, ok := err.(*net.DNSError); ok {
        if err.Timeout() {
            fmt.Println("operation timed out")
        } else if err.Temporary() {
            fmt.Println("temporary error")
        } else {
            fmt.Println("generic error: ", err)
        }
        return
    }
    fmt.Println(addr)
}

注:在 playground 無法進行 DNS 解析。請在你的本地執行該程式

在上述程式中,我們在第 9 行,試圖獲取 golangbot123.com(無效的域名) 的 ip。在第 10 行,我們通過 *net.DNSError 的型別斷言,獲取到了錯誤的底層值。接下來的第 11 行和第 13 行,我們分別檢查了該錯誤是由超時引起的,還是一個臨時性錯誤。

在本例中,我們的錯誤既不是臨時性錯誤,也不是由超時引起的,因此該程式輸出:

generic error:  lookup golangbot123.com: no such host

如果該錯誤是臨時性錯誤,或是由超時引發的,那麼對應的 if 語句會執行,於是我們就可以適當地處理它們。

3. 直接比較

第三種獲取錯誤的更多資訊的方式,是與 error 型別的變數直接比較。我們通過一個示例來理解。

filepath 包中的 Glob 用於返回滿足 glob 模式的所有檔名。如果模式寫的不對,該函式會返回一個錯誤 ErrBadPattern

filepath 包中的 ErrBadPattern 定義如下:

var ErrBadPattern = errors.New("syntax error in pattern")

errors.New() 用於建立一個新的錯誤。我們會在下一教程中詳細討論它。

當模式不正確時,Glob 函式會返回 ErrBadPattern

我們來寫一個小程式來看看這個錯誤。

package main

import (  
    "fmt"
    "path/filepath"
)

func main() {  
    files, error := filepath.Glob("[")
    if error != nil && error == filepath.ErrBadPattern {
        fmt.Println(error)
        return
    }
    fmt.Println("matched files", files)
}

在 playground 上執行

在上述程式裡,我們查詢了模式為 [ 的檔案,然而這個模式寫的不正確。我們檢查了該錯誤是否為 nil。為了獲取該錯誤的更多資訊,我們在第 10 行將 error 直接與 filepath.ErrBadPattern 相比較。如果該條件滿足,那麼該錯誤就是由模式錯誤導致的。該程式會輸出:

syntax error in pattern

標準庫在提供錯誤的詳細資訊時,使用到了上述提到的三種方法。在下一教程裡,我們會通過這些方法來建立我們自己的自定義錯誤。

不可忽略錯誤

絕不要忽略錯誤。忽視錯誤會帶來問題。接下來我重寫上面的示例,在列出所有滿足模式的檔名時,我省略了錯誤處理的程式碼。

package main

import (  
    "fmt"
    "path/filepath"
)

func main() {  
    files, _ := filepath.Glob("[")
    fmt.Println("matched files", files)
}

在 playground 上執行

我們已經從前面的示例知道了這個模式是錯誤的。在第 9 行,通過使用 _ 空白識別符號,我忽略了 Glob 函式返回的錯誤。我在第 10 行簡單列印了所有匹配的檔案。該程式會輸出:

matched files []

由於我忽略了錯誤,輸出看起來就像是沒有任何匹配了 glob 模式的檔案,但實際上這是因為模式的寫法不對。所以絕不要忽略錯誤。

本教程到此結束。

這一教程我們討論了該如何處理程式中出現的錯誤,也討論瞭如何查詢關於錯誤的更多資訊。簡單概括一下本教程討論的內容:

  • 什麼是錯誤?
  • 錯誤的表示
  • 獲取錯誤詳細資訊的各種方法
  • 不能忽視錯誤

 

Go 系列教程 —— 31. 自定義錯誤

 

custom errors

 

 

使用 New 函式建立自定義錯誤

建立自定義錯誤最簡單的方法是使用 errors 包中的 New 函式。

在使用 New 函式 建立自定義錯誤之前,我們先來看看 New 是如何實現的。如下所示,是 errors 包 中的 New 函式的實現。

// Package errors implements functions to manipulate errors.
package errors

// New returns an error that formats as the given text.
func New(text string) error {
    return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

New 函式的實現很簡單。errorString 是一個結構體型別,只有一個字串欄位 s。第 14 行使用了 errorString 指標接受者(Pointer Receiver),來實現 error 介面的 Error() string 方法

第 5 行的 New 函式有一個字串引數,通過這個引數建立了 errorString 型別的變數,並返回了它的地址。於是它就建立並返回了一個新的錯誤。

現在我們已經知道了 New 函式是如何工作的,我們開始在程式裡使用 New 來建立自定義錯誤吧。

我們將建立一個計算圓半徑的簡單程式,如果半徑為負,它會返回一個錯誤。

package main

import (  
    "errors"
    "fmt"
    "math"
)

func circleArea(radius float64) (float64, error) {  
    if radius < 0 {
        return 0, errors.New("Area calculation failed, radius is less than zero")
    }
    return math.Pi * radius * radius, nil
}

func main() {  
    radius := -20.0
    area, err := circleArea(radius)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Printf("Area of circle %0.2f", area)
}

在 glayground 上執行

在上面的程式中,我們檢查半徑是否小於零(第 10 行)。如果半徑小於零,我們會返回等於 0 的面積,以及相應的錯誤資訊。如果半徑大於零,則會計算出面積,並返回值為 nil 的錯誤(第 13 行)。

在 main 函式裡,我們在第 19 行檢查錯誤是否等於 nil。如果不是 nil,我們會打印出錯誤並返回,否則我們會打印出圓的面積。

在我們的程式中,半徑小於零,因此打印出:

Area calculation failed, radius is less than zero

使用 Errorf 給錯誤新增更多資訊

上面的程式效果不錯,但是如果我們能夠打印出當前圓的半徑,那就更好了。這就要用到 fmt 包中的 Errorf 函數了。Errorf 函式會根據格式說明符,規定錯誤的格式,並返回一個符合該錯誤的字串

接下來我們使用 Errorf 函式來改進我們的程式。

package main

import (  
    "fmt"
    "math"
)

func circleArea(radius float64) (float64, error) {  
    if radius < 0 {
        return 0, fmt.Errorf("Area calculation failed, radius %0.2f is less than zero", radius)
    }
    return math.Pi * radius * radius, nil
}

func main() {  
    radius := -20.0
    area, err := circleArea(radius)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Printf("Area of circle %0.2f", area)
}

在 playground 上執行

在上面的程式中,我們使用 Errorf(第 10 行)列印了發生錯誤的半徑。程式執行後會輸出:

Area calculation failed, radius -20.00 is less than zero

使用結構體型別和欄位提供錯誤的更多資訊

錯誤還可以用實現了 error 介面的結構體來表示。這種方式可以更加靈活地處理錯誤。在上面例子中,如果我們希望訪問引發錯誤的半徑,現在唯一的方法就是解析錯誤的描述資訊 Area calculation failed, radius -20.00 is less than zero。這樣做不太好,因為一旦描述資訊發生變化,程式就會出錯。

我們會使用標準庫裡採用的方法,在上一教程中“斷言底層結構體型別,使用結構體欄位獲取更多資訊”這一節,我們講解了這一方法,可以使用結構體欄位來訪問引發錯誤的半徑。我們會建立一個實現 error 介面的結構體型別,並使用它的欄位來提供關於錯誤的更多資訊。

第一步就是建立一個表示錯誤的結構體型別。錯誤型別的命名約定是名稱以 Error 結尾。因此我們不妨把結構體型別命名為 areaError

type areaError struct {  
    err    string
    radius float64
}

上面的結構體型別有一個 radius 欄位,它儲存了與錯誤有關的半徑,而 err 欄位儲存了實際的錯誤資訊。

下一步是實現 error 介面。

func (e *areaError) Error() string {  
    return fmt.Sprintf("radius %0.2f: %s", e.radius, e.err)
}

在上面的程式碼中,我們使用指標接收者 *areaError,實現了 error 介面的 Error() string 方法。該方法打印出半徑和關於錯誤的描述。

現在我們來編寫 main 函式和 circleArea 函式來完成整個程式。

package main

import (  
    "fmt"
    "math"
)

type areaError struct {  
    err    string
    radius float64
}

func (e *areaError) Error() string {  
    return fmt.Sprintf("radius %0.2f: %s", e.radius, e.err)
}

func circleArea(radius float64) (float64, error) {  
    if radius < 0 {
        return 0, &areaError{"radius is negative", radius}
    }
    return math.Pi * radius * radius, nil
}

func main() {  
    radius := -20.0
    area, err := circleArea(radius)
    if err != nil {
        if err, ok := err.(*areaError); ok {
            fmt.Printf("Radius %0.2f is less than zero", err.radius)
            return
        }
        fmt.Println(err)
        return
    }
    fmt.Printf("Area of rectangle1 %0.2f", area)
}

在 playground 上執行

在上面的程式中,circleArea(第 17 行)用於計算圓的面積。該函式首先檢查半徑是否小於零,如果小於零,它會通過錯誤半徑和對應錯誤資訊,建立一個 areaError 型別的值,然後返回 areaError 值的地址,與此同時 area 等於 0(第 19 行)。於是我們提供了更多的錯誤資訊(即導致錯誤的半徑),我們使用了自定義錯誤的結構體欄位來定義它

如果半徑是非負數,該函式會在第 21 行計算並返回面積,同時錯誤值為 nil

在 main 函式的 26 行,我們試圖計算半徑為 -20 的圓的面積。由於半徑小於零,因此會導致一個錯誤。

我們在第 27 行檢查了錯誤是否為 nil,並在下一行斷言了 *areaError 型別。如果錯誤是 *areaError 型別,我們就可以用 err.radius 來獲取錯誤的半徑(第 29 行),打印出自定義錯誤的訊息,最後程式返回退出

如果斷言錯誤,我們就在第 32 行列印該錯誤,並返回。如果沒有發生錯誤,在第 35 行會打印出面積。

該程式會輸出:

Radius -20.00 is less than zero

下面我們來使用上一教程提到的第二種方法,使用自定義錯誤型別的方法來提供錯誤的更多資訊。

使用結構體型別的方法來提供錯誤的更多資訊

在本節裡,我們會編寫一個計算矩形面積的程式。如果長或寬小於零,程式就會打印出錯誤。

第一步就是建立一個表示錯誤的結構體。

type areaError struct {  
    err    string //error description
    length float64 //length which caused the error
    width  float64 //width which caused the error
}

上面的結構體型別除了有一個錯誤描述欄位,還有可能引發錯誤的寬和高。

現在我們有了錯誤型別,我們來實現 error 介面,並給該錯誤型別新增兩個方法,使它提供了更多的錯誤資訊。

func (e *areaError) Error() string {  
    return e.err
}

func (e *areaError) lengthNegative() bool {  
    return e.length < 0
}

func (e *areaError) widthNegative() bool {  
    return e.width < 0
}

在上面的程式碼片段中,我們從 Error() string 方法中返回了關於錯誤的描述。當 length 小於零時,lengthNegative() bool 方法返回 true,而當 width 小於零時,widthNegative() bool 方法返回 true這兩個方法都提供了關於錯誤的更多資訊,在這裡,它提示我們計算面積失敗的原因(長度為負數或者寬度為負數)。於是我們就有了兩個錯誤型別結構體的方法,來提供更多的錯誤資訊

下一步就是編寫計算面積的函式。

func rectArea(length, width float64) (float64, error) {  
    err := ""
    if length < 0 {
        err += "length is less than zero"
    }
    if width < 0 {
        if err == "" {
            err = "width is less than zero"
        } else {
            err += ", width is less than zero"
        }
    }
    if err != "" {
        return 0, &areaError{err, length, width}
    }
    return length * width, nil
}

上面的 rectArea 函式檢查了長或寬是否小於零,如果小於零,rectArea 會返回一個錯誤資訊,否則 rectArea 會返回矩形的面積和一個值為 nil 的錯誤。

讓我們建立 main 函式來完成整個程式。

func main() {  
    length, width := -5.0, -9.0
    area, err := rectArea(length, width)
    if err != nil {
        if err, ok := err.(*areaError); ok {
            if err.lengthNegative() {
                fmt.Printf("error: length %0.2f is less than zero\n", err.length)

            }
            if err.widthNegative() {
                fmt.Printf("error: width %0.2f is less than zero\n", err.width)

            }
            return
        }
        fmt.Println(err)
        return
    }
    fmt.Println("area of rect", area)
}

在 main 程式中,我們檢查了錯誤是否為 nil(第 4 行)。如果錯誤值不是 nil,我們會在下一行斷言 *areaError 型別。然後,我們使用 lengthNegative() 和 widthNegative() 方法,檢查錯誤的原因是長度小於零還是寬度小於零。這樣我們就使用了錯誤結構體型別的方法,來提供更多的錯誤資訊。

如果沒有錯誤發生,就會列印矩形的面積。

下面是整個程式的程式碼供你參考。

package main

import "fmt"

type areaError struct {  
    err    string  //error description
    length float64 //length which caused the error
    width  float64 //width which caused the error
}

func (e *areaError) Error() string {  
    return e.err
}

func (e *areaError) lengthNegative() bool {  
    return e.length < 0
}

func (e *areaError) widthNegative() bool {  
    return e.width < 0
}

func rectArea(length, width float64) (float64, error) {  
    err := ""
    if length < 0 {
        err += "length is less than zero"
    }
    if width < 0 {
        if err == "" {
            err = "width is less than zero"
        } else {
            err += ", width is less than zero"
        }
    }
    if err != "" {
        return 0, &areaError{err, length, width}
    }
    return length * width, nil
}

func main() {  
    length, width := -5.0, -9.0
    area, err := rectArea(length, width)
    if err != nil {
        if err, ok := err.(*areaError); ok {
            if err.lengthNegative() {
                fmt.Printf("error: length %0.2f is less than zero\n", err.length)

            }
            if err.widthNegative() {
                fmt.Printf("error: width %0.2f is less than zero\n", err.width)

            }
            return
        }
        fmt.Println(err)
        return
    }
    fmt.Println("area of rect", area)
}

在 playground 上執行

該程式會列印輸出:

error: length -5.00 is less than zero  
error: width -9.00 is less than zero

在上一教程錯誤處理中,我們介紹了三種提供更多錯誤資訊的方法,現在我們已經看了其中兩個示例。

第三種方法使用的是直接比較,比較簡單。我留給讀者作為練習,你們可以試著使用這種方法來給出自定義錯誤的更多資訊。

簡單概括一下本教程討論的內容:

  • 使用 New 函式建立自定義錯誤
  • 使用 Error 新增更多錯誤資訊
  • 使用結構體型別和欄位,提供更多錯誤資訊
  • 使用結構體型別和方法,提供更多錯誤資訊

 

 

Go 系列教程 —— 32. panic 和 recover

 

什麼是 panic?

在 Go 語言中,程式中一般是使用錯誤來處理異常情況。對於程式中出現的大部分異常情況,錯誤就已經夠用了。

但在有些情況,當程式發生異常時,無法繼續執行。在這種情況下,我們會使用 panic 來終止程式。當函式發生 panic 時,它會終止執行,在執行完所有的延遲函式後,程式控制返回到該函式的呼叫方。這樣的過程會一直持續下去,直到當前協程的所有函式都返回退出,然後程式會打印出 panic 資訊,接著打印出堆疊跟蹤(Stack Trace),最後程式終止。在編寫一個示例程式後,我們就能很好地理解這個概念了。

在本教程裡,我們還會接著討論,當程式發生 panic 時,使用 recover 可以重新獲得對該程式的控制。

可以認為 panic 和 recover 與其他語言中的 try-catch-finally 語句類似,只不過一般我們很少使用 panic 和 recover。而當我們使用了 panic 和 recover 時,也會比 try-catch-finally 更加優雅,程式碼更加整潔。

什麼時候應該使用 panic?

需要注意的是,你應該儘可能地使用錯誤,而不是使用 panic 和 recover。只有當程式不能繼續執行的時候,才應該使用 panic 和 recover 機制

panic 有兩個合理的用例。

  1. 發生了一個不能恢復的錯誤,此時程式不能繼續執行。 一個例子就是 web 伺服器無法繫結所要求的埠。在這種情況下,就應該使用 panic,因為如果不能繫結埠,啥也做不了。

  2. 發生了一個程式設計上的錯誤。 假如我們有一個接收指標引數的方法,而其他人使用 nil 作為引數呼叫了它。在這種情況下,我們可以使用 panic,因為這是一個程式設計錯誤:用 nil 引數呼叫了一個只能接收合法指標的方法。

panic 示例

內建函式 panic 的簽名如下所示:

func panic(interface{})

當程式終止時,會列印傳入 panic 的引數。我們寫一個示例,你就會清楚它的用途了。我們現在就開始吧。

我們會寫一個例子,來展示 panic 如何工作。

package main

import (  
    "fmt"
)

func fullName(firstName *string, lastName *string) {  
    if firstName == nil {
        panic("runtime error: first name cannot be nil")
    }
    if lastName == nil {
        panic("runtime error: last name cannot be nil")
    }
    fmt.Printf("%s %s\n", *firstName, *lastName)
    fmt.Println("returned normally from fullName")
}

func main() {  
    firstName := "Elon"
    fullName(&firstName, nil)
    fmt.Println("returned normally from main")
}

在 playground 上執行

上面的程式很簡單,會列印一個人的全名。第 7 行的 fullName 函式會打印出一個人的全名。該函式在第 8 行和第 11 行分別檢查了 firstName 和 lastName 的指標是否為 nil。如果是 nilfullName 函式會呼叫含有不同的錯誤資訊的 panic。當程式終止時,會打印出該錯誤資訊。

執行該程式,會有如下輸出:

panic: runtime error: last name cannot be nil

goroutine 1 [running]:  
main.fullName(0x1040c128, 0x0)  
    /tmp/sandbox135038844/main.go:12 +0x120
main.main()  
    /tmp/sandbox135038844/main.go:20 +0x80

我們來分析這個輸出,理解一下 panic 是如何工作的,並且思考當程式發生 panic 時,會怎樣列印堆疊跟蹤。

在第 19 行,我們將 Elon 賦值給了 firstName。在第 20 行,我們呼叫了 fullName 函式,其中 lastName 等於 nil。因此,滿足了第 11 行的條件,程式發生 panic。當出現了 panic 時,程式就會終止執行,打印出傳入 panic 的引數,接著打印出堆疊跟蹤。因此,第 14 行和第 15 行的程式碼並不會在發生 panic 之後執行。程式首先會打印出傳入 panic 函式的資訊:

panic: runtime error: last name cannot be empty

接著打印出堆疊跟蹤。

程式在 fullName 函式的第 12 行發生 panic,因此,首先會打印出如下所示的輸出。

main.fullName(0x1040c128, 0x0)  
    /tmp/sandbox135038844/main.go:12 +0x120

接著會打印出堆疊的下一項。在本例中,堆疊跟蹤中的下一項是第 20 行(因為發生 panic 的 fullName 呼叫就在這一行),因此接下來會打印出:

main.main()  
    /tmp/sandbox135038844/main.go:20 +0x80

現在我們已經到達了導致 panic 的頂層函式,這裡沒有更多的層級,因此結束列印。

發生 panic 時的 defer

我們重新總結一下 panic 做了什麼。當函式發生 panic 時,它會終止執行,在執行完所有的延遲函式後,程式控制返回到該函式的呼叫方。這樣的過程會一直持續下去,直到當前協程的所有函式都返回退出,然後程式會打印出 panic 資訊,接著打印出堆疊跟蹤,最後程式終止

在上面的例子中,我們沒有延遲呼叫任何函式。如果有延遲函式,會先呼叫它,然後程式控制返回到函式呼叫方。

我們來修改上面的示例,使用一個延遲語句。

package main

import (  
    "fmt"
)

func fullName(firstName *string, lastName *string) {  
    defer fmt.Println("deferred call in fullName")
    if firstName == nil {
        panic("runtime error: first name cannot be nil")
    }
    if lastName == nil {
        panic("runtime error: last name cannot be nil")
    }
    fmt.Printf("%s %s\n", *firstName, *lastName)
    fmt.Println("returned normally from fullName")
}

func main() {  
    defer fmt.Println("deferred call in main")
    firstName := "Elon"
    fullName(&firstName, nil)
    fmt.Println("returned normally from main")
}

在 playground 上執行

上述程式碼中,我們只修改了兩處,分別在第 8 行和第 20 行添加了延遲函式的呼叫。

該函式會列印:

This program prints,

deferred call in fullName  
deferred call in main  
panic: runtime error: last name cannot be nil

goroutine 1 [running]:  
main.fullName(0x1042bf90, 0x0)  
    /tmp/sandbox060731990/main.go:13 +0x280
main.main()  
    /tmp/sandbox060731990/main.go:22 +0xc0

當程式在第 13 行發生 panic 時,首先執行了延遲函式,接著控制返回到函式呼叫方,呼叫方的延遲函式繼續執行,直到到達頂層呼叫函式。

在我們的例子中,首先執行 fullName 函式中的 defer 語句(第 8 行)。程式打印出:

deferred call in fullName

接著程式返回到 main 函式,執行了 main 函式的延遲呼叫,因此會輸出:

deferred call in main

現在程式控制到達了頂層函式,因此該函式會打印出 panic 資訊,然後是堆疊跟蹤,最後終止程式。

recover

recover 是一個內建函式,用於重新獲得 panic 協程的控制。

recover 函式的標籤如下所示:

func recover() interface{}

只有在延遲函式的內部,呼叫 recover 才有用。在延遲函式內呼叫 recover,可以取到 panic 的錯誤資訊,並且停止 panic 續發事件(Panicking Sequence),程式執行恢復正常。如果在延遲函式的外部呼叫 recover,就不能停止 panic 續發事件。

我們來修改一下程式,在發生 panic 之後,使用 recover 來恢復正常的執行。

package main

import (  
    "fmt"
)

func recoverName() {  
    if r := recover(); r!= nil {
        fmt.Println("recovered from ", r)
    }
}

func fullName(firstName *string, lastName *string) {  
    defer recoverName()
    if firstName == nil {
        panic("runtime error: first name cannot be nil")
    }
    if lastName == nil {
        panic("runtime error: last name cannot be nil")
    }
    fmt.Printf("%s %s\n", *firstName, *lastName)
    fmt.Println("returned normally from fullName")
}

func main() {  
    defer fmt.Println("deferred call in main")
    firstName := "Elon"
    fullName(&firstName, nil)
    fmt.Println("returned normally from main")
}

在 playground 上執行

在第 7 行,recoverName() 函式呼叫了 recover(),返回了呼叫 panic 的傳參。在這裡,我們只是打印出 recover 的返回值(第 8 行)。在 fullName 函式內,我們在第 14 行延遲呼叫了 recoverNames()

當 fullName 發生 panic 時,會呼叫延遲函式 recoverName(),它使用了 recover() 來停止 panic 續發事件。

該程式會輸出:

recovered from  runtime error: last name cannot be nil  
returned normally from main  
deferred call in main

當程式在第 19 行發生 panic 時,會呼叫延遲函式 recoverName,它反過來會呼叫 recover() 來重新獲得 panic 協程的控制。第 8 行呼叫了 recover,返回了 panic 的傳參,因此會列印:

recovered from  runtime error: last name cannot be nil

在執行完 recover() 之後,panic 會停止,程式控制返回到呼叫方(在這裡就是 main 函式),程式在發生 panic 之後,從第 29 行開始會繼續正常地執行。程式會列印 returned normally from main,之後是 deferred call in main

panic,recover 和 Go 協程

只有在相同的 Go 協程中呼叫 recover 才管用。recover 不能恢復一個不同協程的 panic。我們用一個例子來理解這一點。

package main

import (  
    "fmt"
    "time"
)

func recovery() {  
    if r := recover(); r != nil {
        fmt.Println("recovered:", r)
    }
}

func a() {  
    defer recovery()
    fmt.Println("Inside A")
    go b()
    time.Sleep(1 * time.Second)
}

func b() {  
    fmt.Println("Inside B")
    panic("oh! B panicked")
}

func main() {  
    a()
    fmt.Println("normally returned from main")
}

在 playground 上執行

在上面的程式中,函式 b() 在第 23 行發生 panic。函式 a() 呼叫了一個延遲函式 recovery(),用於恢復 panic。在第 17 行,函式 b() 作為一個不同的協程來呼叫。下一行的 Sleep 只是保證 a() 在 b() 執行結束之後才退出。

你認為程式會輸出什麼?panic 能夠恢復嗎?答案是否定的,panic 並不會恢復。因為呼叫 recovery 的協程和 b() 中發生 panic 的協程並不相同,因此不可能恢復 panic。

執行該程式會輸出:

Inside A  
Inside B  
panic: oh! B panicked

goroutine 5 [running]:  
main.b()  
    /tmp/sandbox388039916/main.go:23 +0x80
created by main.a  
    /tmp/sandbox388039916/main.go:17 +0xc0

從輸出可以看出,panic 沒有恢復。

如果函式 b() 在相同的協程裡呼叫,panic 就可以恢復。

如果程式的第 17 行由 go b() 修改為 b(),就可以恢復 panic 了,因為 panic 發生在與 recover 相同的協程裡。如果執行這個修改後的程式,會輸出:

Inside A  
Inside B  
recovered: oh! B panicked  
normally returned from main

執行時 panic

執行時錯誤(如陣列越界)也會導致 panic。這等價於呼叫了內建函式 panic,其引數由介面型別 runtime.Error 給出。runtime.Error 介面的定義如下:

type Error interface {  
    error
    // RuntimeError is a no-op function but
    // serves to distinguish types that are run time
    // errors from ordinary errors: a type is a
    // run time error if it has a RuntimeError method.
    RuntimeError()
}

而 runtime.Error 介面滿足內建介面型別 error

我們來編寫一個示例,建立一個執行時 panic。

package main

import (  
    "fmt"
)

func a() {  
    n := []int{5, 7, 4}
    fmt.Println(n[3])
    fmt.Println("normally returned from a")
}
func main() {  
    a()
    fmt.Println("normally returned from main")
}

在 playground 上執行

在上面的程式中,第 9 行我們試圖訪問 n[3],這是一個對切片的錯誤引用。該程式會發生 panic,輸出如下:

panic: runtime error: index out of range

goroutine 1 [running]:  
main.a()  
    /tmp/sandbox780439659/main.go:9 +0x40
main.main()  
    /tmp/sandbox780439659/main.go:13 +0x20

你也許想知道,是否可以恢復一個執行時 panic?當然可以!我們來修改一下上面的程式碼,恢復這個 panic。

package main

import (  
    "fmt"
)

func r() {  
    if r := recover(); r != nil {
        fmt.Println("Recovered", r)
    }
}

func a() {  
    defer r()
    n := []int{5, 7, 4}
    fmt.Println(n[3])
    fmt.Println("normally returned from a")
}

func main() {  
    a()
    fmt.Println("normally returned from main")
}

在 playground 上執行

執行上面程式會輸出:

Recovered runtime error: index out of range  
normally returned from main

從輸出可以知道,我們已經恢復了這個 panic。

恢復後獲得堆疊跟蹤

當我們恢復 panic 時,我們就釋放了它的堆疊跟蹤。實際上,在上述程式裡,恢復 panic 之後,我們就失去了堆疊跟蹤。

有辦法可以打印出堆疊跟蹤,就是使用 Debug 包中的 PrintStack 函式。

package main

import (  
    "fmt"
    "runtime/debug"
)

func r() {  
    if r := recover(); r != nil {
        fmt.Println("Recovered", r)
        debug.PrintStack()
    }
}

func a() {  
    defer r()
    n := []int{5, 7, 4}
    fmt.Println(n[3])
    fmt.Println("normally returned from a")
}

func main() {  
    a()
    fmt.Println("normally returned from main")
}

在 playground 上執行

在上面的程式中,我們在第 11 行使用了 debug.PrintStack() 列印堆疊跟蹤。

該程式會輸出:

Recovered runtime error: index out of range  
goroutine 1 [running]:  
runtime/debug.Stack(0x1042beb8, 0x2, 0x2, 0x1c)  
    /usr/local/go/src/runtime/debug/stack.go:24 +0xc0
runtime/debug.PrintStack()  
    /usr/local/go/src/runtime/debug/stack.go:16 +0x20
main.r()  
    /tmp/sandbox949178097/main.go:11 +0xe0
panic(0xf0a80, 0x17cd50)  
    /usr/local/go/src/runtime/panic.go:491 +0x2c0
main.a()  
    /tmp/sandbox949178097/main.go:18 +0x80
main.main()  
    /tmp/sandbox949178097/main.go:23 +0x20
normally returned from main

從輸出我們可以看出,首先已經恢復了 panic,打印出 Recovered runtime error: index out of range。此外,我們也打印出了堆疊跟蹤。在恢復了 panic 之後,還打印出 normally returned from main

簡單概括一下本教程討論的內容:

  • 什麼是 panic?
  • 什麼時候應該使用 panic?
  • panic 示例
  • 發生 panic 時的 defer
  • recover
  • panic,recover 和 Go 協程
  • 執行時 panic
  • 恢復後獲得堆疊跟蹤