1

Error handling is an inescapable topic in any programming language. Historically, there have been two schools of error handling in programming languages: exception-based structured try-catch-finally handling and value-based handling. The former members include mainstream programming languages such as C++, Java, Python, and PHP, and the latter is represented by the C language. Go's design pursues simplicity and adopts the latter processing mechanism: errors are values, and error handling is a decision based on value comparison.

recognize error

In the Go language, an error is a value, but an interface value, which is the error we usually use:

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

The error interface is very simple, declaring only one Error() method. Two basic methods for constructing error values are provided in the standard library: errors.New() and fmt.Errorf(), and prior to Go 1.13, these two methods actually returned an unexported type 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() is suitable for scenarios that need to format the output string. If the format string is not required, errors.New() is recommended. Because fmt.Errof() needs to traverse all characters when generating a formatted string, there will be a certain performance penalty.

Error Handling Basic Strategies

Now that we understand error values, let's look at a few idiomatic strategies for error handling in Go.

transparent policy

The transparent processing strategy is the simplest strategy. It does not care about the specific context information carried by the returned error value at all. As long as an error occurs, it enters the only error processing execution path. This is also the most common error handling strategy in Go, and most error handling situations can be classified under this strategy.

 err := doSomething()
if err != nil {
    // 不关心err变量底层错误值所携带的具体上下文信息
    // 执行简单错误处理逻辑并返回
    ...
    return err
}                        

Sentinel processing strategy

The "sentinel" strategy uses specific values to indicate success and different errors, relying on the caller to check for errors to handle errors. If this processing strategy is adopted, the error value constructor will usually define a series of derived "sentinel" error values to assist the error handler in reviewing the error value and making a decision on the error handling branch.

 // $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
    }
}            

Compared with the transparent error strategy, the "sentry" strategy allows the error handler to handle errors more flexibly. However, for package developers, exposing "sentinel" error values means that these error values become part of the package along with the package's public functions, making error handlers dependent on them.

Type inspection strategy

The type inspection strategy is also called a custom error strategy. As the name suggests, this error handling method represents a specific error through a custom error type. It also relies on the upper-level code to check the error value. The difference is that it needs to use the type assertion mechanism ( type assertion) or type selection mechanism (type switch) to check for errors.

Take a look at an example from the standard library:

 // $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
}                                    

The advantage of this error handling is that it can wrap errors and provide more contextual information, but the implementation direction must expose the implemented error type to the upper layer, and there is also a dependency relationship between the user and the user.

chain error

Before version 1.13, the biggest problem brought by the use of the above "sentinel" and type inspection strategy is that the original error will be discarded after the error is custom processed by the function or method.

 // 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
}

From the above two examples, we can see that after the original error is custom-wrapped by the function, its value or type may be "submerged", and the user cannot easily obtain it, which brings about error handling. unnecessary trouble.

To solve this problem, Go 1.13 introduced a set of solutions called chained errors. When errors are passed between functions, information is not lost, but is chained together like a chain.

wrapError

wrapError is the core data structure of chained errors, and other related optimizations are developed around it:

 type wrapError struct {
    msg string
    err error
}

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

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

wrapError Compared with the traditional errorString, the Unwrap method is additionally implemented to return the original error.

generate chained errors

After Go 1.13, we can use the fmt.Errorf function with the format verb %w to generate chained errors. The source code is as follows:

 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
}

There are two things to keep in mind when generating wrapError:

  • The %w verb can only be used once at a time;
  • The %w verb can only match parameters that implement the error interface.

errors.Is

The errors package provides the Is method for error handlers to compare error values. Is supports the equivalence judgment of errors after multiple layers of packaging.

 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

The As method is similar to judging whether an error type variable is a specific custom error type through type assertion. The difference is that if the underlying error value of a variable of type error is a chained error, the As method performs type comparisons along the error chain until a matching error type is found.

 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
}

Error handling advice

There are many discussions about error handling, but no single error handling strategy is suitable for all projects or situations. Based on the above methods of constructing error values and error handling strategies, the following suggestions are made:

  • The transparent error handling strategy is preferred to reduce the coupling between the error handler and the error value constructor;
  • Second, try to use the type inspection strategy;
  • In the case that the above two strategies cannot be implemented, the "sentry" strategy is used again;
  • In Go 1.13 and later, try to replace old error handling statements with the errors.Is and errors.As methods.

optimize if err != nil

Because of the error handling mechanism of the Go language, a large number of if err != nil will be generated in the code, which is very cumbersome and unsightly. This is also where the Go language is often complained by other mainstream language developers. So is there any way to optimize it?

The first thing that comes to mind is visual optimization, placing multiple judgment statements together, but this method is only "superficial effort" and has great limitations.

The second is to imitate other languages to use panic and recover to simulate exception capture to replace error value judgment, but this is an anti-pattern and is not recommended. First of all, errors are normal programming logic, while exceptions are unexpected errors. The two cannot be drawn equal, and if the exception is not caught, it will cause the entire process to exit, and the consequences are serious in individual cases. Another point, the use of exceptions instead of error mechanisms can greatly affect the speed of the program.

Two optimization ideas are provided here for reference.

Encapsulate multiple errors

This method is to encapsulate multiple if err != nil statements into a function or method, so that only one additional judgment is required when calling externally. Let's see an example:

 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
}                        

In order to reduce the number of repetitions in the CopyFile function if err != nil , the above code introduces an openBoth function, and we transfer the opening of the source file, the creation of the destination file and the related error handling work to the openBoth function. The advantage of this method is that it is relatively simple, but the disadvantage is that the effect is sometimes not significant.

built-in error

Let's take a cursory look at the Writer implementation of the bufio package:

 // $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
}                       

As you can see, Writer defines an err field as an internal error status value, which is bound to the Writer instance, and judges whether it is nil at the entry of the WriteByte method. Once not nil, WriteByte returns the built-in err directly. Let's use this idea to refactor the code from the previous example:

 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")
}                        

We completely abandoned the original CopyFile function and encapsulated its logic into the CopyFile method of the FileCopier structure. The FileCopier structure has a built-in err field to save the internal error state, so in the CopyFile method, we only need to execute openSrc, createDst and copy in sequence according to the normal business logic, and the visual continuity of the normal business logic is thus greatly improved. Well done.


与昊
222 声望634 粉丝

IT民工,主要从事web方向,喜欢研究技术和投资之道