【Go進階—基礎特性】錯誤

與昊發表於2022-04-06

錯誤處理是任何程式語言都繞不開的話題。一直以來,程式語言的錯誤處理機制有兩大流派:基於異常的結構化 try-catch-finally 處理機制和基於值的處理機制。前者的成員包括 C++、Java、Python、PHP 等主流程式語言,後者的代表則是 C 語言。Go 的設計追求簡單,採用的是後一種處理機制:錯誤就是值,而錯誤處理就是基於值比較後的決策。

認識 error

在 Go 語言中,錯誤是值,不過是一個介面值,也即我們平時常用的 error:

// $GOROOT/src/builtin/builtin.go
type interface error {
    Error() string
}                        

error 介面很簡單,只宣告瞭一個 Error() 方法。在標準庫中提供了構造錯誤值的兩種基本方法:errors.New() 和 fmt.Errorf(),在 Go 1.13 版本之前,這兩種方法實際上返回的是一個未匯出型別 errors.errorString:

// $GOROOT/src/errors/errors.go
func New(text string) error {
    return &errorString{text}
}

type errorString struct {
    s string
}

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

// $GOROOT/src/fmt/errors.go
// 1.13 版本之前
func Errorf(format string, a ...interface{}) error {
    return errors.New(Sprintf(format, a...))
}                       

fmt.Errorf() 適用於需要格式化輸出字串的場景,如果不需要格式化字串,則建議使用 errors.New()。因為 fmt.Errof() 在生成格式化字串時需要遍歷所有字元,會有一定的效能損失。

錯誤處理基本策略

瞭解了錯誤值後,我們來看一下 Go 語言錯誤處理的幾種慣用策略。

透明策略

透明處理策略是最簡單的策略,它完全不關心返回錯誤值攜帶的具體上下文資訊,只要發生錯誤就進入唯一的錯誤處理執行路徑。這也是 Go 語言中最常見的錯誤處理策略,絕大部分的錯誤處理情形可以歸類到這種策略下。

err := doSomething()
if err != nil {
    // 不關心err變數底層錯誤值所攜帶的具體上下文資訊
    // 執行簡單錯誤處理邏輯並返回
    ...
    return err
}                        

“哨兵”處理策略

“哨兵”策略通過特定值來表示成功和不同的錯誤,依靠呼叫方對錯誤進行檢查來處理錯誤。如果採用這種處理策略,錯誤值構造方通常會定義一系列匯出的“哨兵”錯誤值,用來輔助錯誤處理方檢視錯誤值並做出錯誤處理分支的決策。

// $GOROOT/src/bufio/bufio.go
var (
    ErrInvalidUnreadByte = errors.New("bufio: invalid use of UnreadByte")
    ErrInvalidUnreadRune = errors.New("bufio: invalid use of UnreadRune")
    ErrBufferFull        = errors.New("bufio: buffer full")
    ErrNegativeCount     = errors.New("bufio: negative count")
)

// 錯誤處理程式碼
data, err := b.Peek(1)
if err != nil {
    switch err {
    case bufio.ErrNegativeCount:
        // ...
        return
    case bufio.ErrBufferFull:
        // ...
        return
    case bufio.ErrInvalidUnreadByte:
        // ...
        return
    default:
        // ...
        return
    }
}            

與透明錯誤策略相比,“哨兵”策略讓錯誤處理方可以更靈活地處理錯誤。不過對於包的開發者而言,暴露“哨兵”錯誤值意味著這些錯誤值和包的公共函式一起成為包的一部分,會讓錯誤處理方對其產生依賴。

型別檢視策略

型別檢視策略又被稱為自定義錯誤策略,顧名思義,這種錯誤處理方式通過自定義的錯誤型別來表示特定的錯誤,同樣依賴上層程式碼對錯誤值進行檢查,不同的是需要使用型別斷言機制(type assertion)或型別選擇機制(type switch)對錯誤進行檢查。

來看一個標準庫的例子:

// $GOROOT/src/encoding/json/decode.go
type UnmarshalTypeError struct {
    Value  string       
    Type   reflect.Type 
    Offset int64        
    Struct string      
    Field  string       
}
            
// $GOROOT/src/encoding/json/decode_test.go
// 通過型別斷言機制獲取
func TestUnmarshalTypeError(t *testing.T) {
    for _, item := range decodeTypeErrorTests {
        err := Unmarshal([]byte(item.src), item.dest)
        if _, ok := err.(*UnmarshalTypeError); !ok {
            t.Errorf("expected type error for Unmarshal(%q, type %T): got %T",
                    item.src, item.dest, err)
        }
    }
}

// $GOROOT/src/encoding/json/decode.go
// 通過型別選擇機制獲取
func (d *decodeState) addErrorContext(err error) error {
    if d.errorContext.Struct != nil || len(d.errorContext.FieldStack) > 0 {
        switch err := err.(type) {
        case *UnmarshalTypeError:
            err.Struct = d.errorContext.Struct.Name()
            err.Field = strings.Join(d.errorContext.FieldStack, ".")
            return err
        }
    }
    return err
}                                    

這種錯誤處理的好處在於可以將錯誤包裝起來,提供更多的上下文資訊,但實現方必須向上層公開實現的錯誤型別,與使用方之間同樣需要產生依賴關係。

鏈式 error

在 1.13 版本之前,使用上述“哨兵”和型別檢視策略帶來的最大的一個問題是 error 經過函式或方法進行自定義處理後,原始的 error 會被丟棄。

// example 1
func main() {
    err := WriteFile("")
    if err == os.ErrPermission {
        fmt.Println("permission denied")
    }
}

func WriteFile(filename string) error {
    if filename == "" {
        return fmt.Errorf("write file error: %v", os.ErrPermission)
    }
    return nil
}

// example 2
func main() {
    err := WriteFile("")
    if _, ok := err.(*os.PathError); ok {
        fmt.Println("permission denied")
    }
}

func WriteFile(filename string) error {
    if filename == "" {
        return fmt.Errorf("write file error: %v", &os.PathError{})
    }
    return nil
}

通過上面兩個示例我們可以看到,原始的 error 經過函式的自定義包裝後,它的值或者型別就可能被“淹沒”了,使用方不能很容易地獲取到它,給錯誤處理帶來了不必要的麻煩。

為了解決這個問題,Go 1.13 版本中引入了一套稱為鏈式 error 的解決方案,error 在函式間傳遞時資訊並不會丟失,而是像鏈條一樣被串連起來。

wrapError

wrapError 是鏈式 error 的核心資料結構,其他相關優化都是圍繞它展開的:

type wrapError struct {
    msg string
    err error
}

func (e *wrapError) Error() string {
    return e.msg
}

func (e *wrapError) Unwrap() error {
    return e.err
}

wrapError 與傳統的 errorString 相比,額外實現了 Unwrap 方法,用於返回原始 error。

生成鏈式 error

在 Go 1.13 版本之後,我們可以使用 fmt.Errorf 函式配合格式動詞 %w 來生成鏈式 error,原始碼如下:

func Errorf(format string, a ...interface{}) error {
    p := newPrinter()
    p.wrapErrs = true
    // 解析格式,如果發現格式動詞 %w 且提供了合法的 error 引數,則把 p.wrappedErr 置為 error 
    p.doPrintf(format, a)
    s := string(p.buf)
    var err error
    if p.wrappedErr == nil {
        // 一般情況下生成 errorString
        err = errors.New(s)
    } else {
        // 存在 %w 動詞生成 wrapError
        err = &wrapError{s, p.wrappedErr}
    }
    p.free()
    return err
}

生成 wrapError 有兩點需要記住:

  • 每次只能使用一次 %w 動詞;
  • %w 動詞只能匹配實現 error 介面的引數。

errors.Is

errors 包提供了 Is 方法用於錯誤處理方對錯誤值進行比較,Is 支援錯誤在包裝過多層後的等值判斷。

func Is(err, target error) bool {
    if target == nil {
        return err == target
    }

    isComparable := reflectlite.TypeOf(target).Comparable()
    for {
        // 如果 target 是可比較的,則直接進行比較
        if isComparable && err == target {
            return true
        }
        // 如果 err 實現了 Is 方法,則呼叫該方法繼續進行判斷
        if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
            return true
        }
        // 否則,對 err 進行 Unwrap(也即返回 wrapError 的 err 欄位)
        if err = Unwrap(err); err == nil {
            return false
        }
    }
}

errors.As

As 方法類似於通過型別斷言判斷一個 error 型別變數是否為特定的自定義錯誤型別。不同的是,如果 error 型別變數的底層錯誤值是一個鏈式 error,那麼 As 方法會沿著錯誤鏈進行型別比較,直至找到一個匹配的錯誤型別。

func As(err error, target interface{}) bool {
    if target == nil {
        panic("errors: target cannot be nil")
    }
    // 通過反射獲取 target 的值和型別,並進行相關判斷
    val := reflectlite.ValueOf(target)
    typ := val.Type()
    if typ.Kind() != reflectlite.Ptr || val.IsNil() {
        panic("errors: target must be a non-nil pointer")
    }
    targetType := typ.Elem()
    if targetType.Kind() != reflectlite.Interface && !targetType.Implements(errorType) {
        panic("errors: *target must be interface or implement error")
    }
    for err != nil {
        // 如果 err 的型別與 target 匹配,直接賦值給 target
        if reflectlite.TypeOf(err).AssignableTo(targetType) {
            val.Elem().Set(reflectlite.ValueOf(err))
            return true
        }
        // 判斷 err 是否實現 As 方法,若已實現則呼叫該方法進一步匹配
        if x, ok := err.(interface{ As(interface{}) bool }); ok && x.As(target) {
            return true
        }
        // 否則,對 err 進行 Unwrap
        err = Unwrap(err)
    }
    return false
}

錯誤處理建議

關於錯誤處理的討論有很多,但沒有哪一種錯誤處理策略適用於所有專案或場合。綜合上述的構造錯誤值方法及錯誤處理策略,有以下幾點建議:

  • 優先使用透明錯誤處理策略,降低錯誤處理方與錯誤值構造方之間的耦合;
  • 其次儘量使用型別檢視策略;
  • 在上述兩種策略無法實施的情況下,再用“哨兵”策略;
  • 在 Go 1.13 及後續版本中,儘量用 errors.Is 和 errors.As 方法替換原先的錯誤處理語句。

優化 if err != nil

因為 Go 語言的錯誤處理機制,會在程式碼中產生大量的 if err != nil,十分繁瑣且不美觀,這也是 Go 語言經常被其他主流語言開發者吐槽的地方。那麼有什麼辦法可以優化?

首先能想到的就是視覺上的優化,將多個判斷語句放置在一起,但這種方法也只不過是“表面功夫”,而且有很大的侷限性。

第二種就是模仿其他語言用 panic 和 recover 來模擬異常捕獲來替換錯誤值判斷,不過這是一種反模式,並不推薦使用。首先,錯誤是正常的程式設計邏輯,而異常是意料之外的錯誤,二者不能畫等號,而且如果異常沒有得到捕獲將會導致整個程式退出,個別情況下後果很嚴重。還有一點,使用異常代替錯誤機制會大幅影響程式的執行速度。

在這裡提供兩種優化思路以供參考。

封裝多個 error

這個方法就是將多個 if err != nil 語句封裝到一個函式或方法中,這樣外部呼叫的時候只需要額外判斷一次就可以了。下面看一個例子:

func openBoth(src, dst string) (*os.File, *os.File, error) {
    var r, w *os.File
    var err error
    if r, err = os.Open(src); err != nil {
        return nil, nil, fmt.Errorf("copy %s %s: %v", src, dst, err)
    }
    
    if w, err = os.Create(dst); err != nil {
        r.Close()
        return nil, nil, fmt.Errorf("copy %s %s: %v", src, dst, err)
    }
    return r, w, nil
}

func CopyFile(src, dst string) error {
    var err error
    var r, w *os.File
    if r, w, err = openBoth(src, dst); err != nil {
        return err
    }
    defer func() {
        r.Close()
        w.Close()
        if err != nil {
            os.Remove(dst)
        }
    }()
    
    if _, err = io.Copy(w, r); err != nil {
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }
    return nil
}                        

為了減少 CopyFile 函式中 if err != nil 的重複次數,以上程式碼引入了一個 openBoth 函式,我們將開啟原始檔、建立目的檔案和相關的錯誤處理工作轉移到了 openBoth 函式中。這種方法的優點是比較簡單,缺點是效果有時並不顯著。

內建 error

我們先粗略看一下 bufio 包的 Writer 實現:

// $GOROOT/src/bufio/bufio.go
type Writer struct {
    err error
    buf []byte
    n   int
    wr  io.Writer
}

func (b *Writer) WriteByte(c byte) error {
    if b.err != nil {
        return b.err
    }
    if b.Available() <= 0 && b.Flush() != nil {
        return b.err
    }
    b.buf[b.n] = c
    b.n++
    return nil
}                       

可以看到,Writer 定義了一個 err 欄位作為內部錯誤狀態值,它與 Writer 的例項繫結在了一起,並且在 WriteByte 方法的入口判斷是否為 nil。一旦不為 nil,WriteByte 就直接返回內建的 err。我們來使用這種思路來重構一下前面例子中的程式碼:

type FileCopier struct {
    w   *os.File
    r   *os.File
    err error
}

func (f *FileCopier) open(path string) (*os.File, error) {
    if f.err != nil {
        return nil, f.err
    }
    
    h, err := os.Open(path)
    if err != nil {
        f.err = err
        return nil, err
    }
    return h, nil
}

func (f *FileCopier) openSrc(path string) {
    if f.err != nil {
        return
    }
    
    f.r, f.err = f.open(path)
    return
}

func (f *FileCopier) createDst(path string) {
    if f.err != nil {
        return
    }
    
    f.w, f.err = os.Create(path)
    return
}

func (f *FileCopier) copy() {
    if f.err != nil {
        return
    }
    
    if _, err := io.Copy(f.w, f.r); err != nil {
        f.err = err
    }
}

func (f *FileCopier) CopyFile(src, dst string) error {
    if f.err != nil {
        return f.err
    }
    
    defer func() {
        if f.r != nil {
            f.r.Close()
        }
        if f.w != nil {
            f.w.Close()
        }
        if f.err != nil {
            if f.w != nil {
                os.Remove(dst)
            }
        }
    }()
    
    f.openSrc(src)
    f.createDst(dst)
    f.copy()
    return f.err
}

func main() {
    var fc FileCopier
    err := fc.CopyFile("foo.txt", "bar.txt")
    if err != nil {
        fmt.Println("copy file error:", err)
        return
    }
    fmt.Println("copy file ok")
}                        

我們將原 CopyFile 函式徹底拋棄,將其邏輯封裝到 FileCopier 結構的 CopyFile 方法中。FileCopier 結構內建了一個 err 欄位用於儲存內部的錯誤狀態,這樣在 CopyFile 方法中,我們只需按照正常業務邏輯,順序執行 openSrc、createDst 和 copy 即可,正常業務邏輯的視覺連續性就這樣被很好地實現了。

相關文章