Go语言的错误处理

2021-08-22 ⏳6.6分钟(2.6千字) 🕸️

本文是Go语言错误处理系列文章的第二篇。我在上文指出错误处理的本质是流程控制,并系统地介绍了Go语言的流程控制语句。今天就来介绍一下Go语言内置的错误处理机制。其实Go语言压根就没有内容什么错误处理机制,而是把错误处理的包袱甩给了程序员。请容我细细道来。

C语言的错误处理

Go语言的设计者之一 Ken Thompson 是C语言之父。显然,Go语言在很多方面都深受C语言影响,错误处理也不例外。C语言基本上没有像样的错误处理语法,程序员只能自己动手解决这个问题。一种典型的做法就是让函数返回一个int型的数字表示不同的错误,也就是大家常说的错误码。但有一个问题,C函数只能有一个返回值,如果我们返回了错误码,那又该如何返回正常的函数调用结果呢?没办法,C程序员只能在通过函数入参传入一个返回值的指针,然后在函数内部通过这个指针保存函数结果。整个处理过程如下:

int return = 0;
int errno = 0;

int div(int a, int b, int *return);

errno = div(1, 2, &return);
if errno != 0 {
  // ...
}
printf("1/2 = %d\n", return);

使用 int 作为函数返回值表示错误,进而不得不通过参数传入指针保存真正的返回值,这是受制于C的设计而不得不做出的妥协。因为一方面C语言没有内置的错误类型,另一方面C语言不支持多值返回。

另外,错误只能用 int 表示吗?非也,非也。C语言压根就不知道这里的返回值是一个错误。程序员想用什么表示都行。但是UNIX是用C开发的,所有的系统调用都是用int做为错误码,最终大家约定俗成,也都用int了。但使用错误真的好吗?其实不好,因为表达的信息太少了。除了错误码,至少应该附带上具体的错误消息吧。我认为至少应该使用下面这样的结构体来传递错误信息:

struct error {
  int code;
  char msg[];
}

多返回值和error接口

在设计Go语言的时候,C语言之父吸取了当年的教训,为Go语言添加了两样特性。

头一样特性就是支持多值返回。也就是说,一个函数可以返回多个值。比如:

// a, b := swap(1,2)
// a == 2
// b == 1
func swap(a int, b int) (int, int) {
  return b, a
}

相对C来说,这是一个巨大的进步。从此程序员实现了返回值自由(但支持多返回值也有一个副作用,容易写出有很多返回值的函数😂)

另外一样特性就是内置了一个error接口,其定义如下:

type error interface {
  Error() string
}

Go语言使用这个error接口表示错误。把error跟多返回值结合起来,我们就会看到这样的函数:

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

因为可以返回多个值了,不妨就约定用最后一个返回值表示错误。而且Go也规定了错误的接口类型。从各方面看都比C语言好了不少。但注意,Go语言本身不并不知道最后一个参数是错误信息,这只是程序员的一种约定。你可以用第一个参数表示错误,也可以用第二个、第三个或随便哪一个。对Go语言的编译器和运行时而言,这只是一个实现了error接口的变量,跟其他类型的变量什么区别。

if err != nil

那如何检查有没有产生错误呢?那就要用到下面这一段经典代码了:

f, err := os.Open("foo.txt")
if err != nil {
  return err
}

这估计是Go程序员使用最多的代码片段了。⚠️这里用到了Go语言的流程控制语句⚠️大家都说Go的这种错误处理方式很丑,却忽略了很重要的一点:就是Go语言把错误处理的包袱甩给了程序员。Go一直标榜自己是一门简单的语言,简单到连错误处理也不支持。

Errors are values

程序员写的if err != nil跟其他像是if user.Age > 18的判断没有本质区别。这也就引出了Go错误处理的另外一个原则:Errors are values。错误就是变量值,怎么处理变量,就怎么处理错误。比如下面的例子

_, err = fd.Write(p0[a:b])
if err != nil {
    return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
    return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
    return err
}

每次调用Write()都要检查有没有报错。烦是烦了点,但健壮的代码不就应该时时考虑错误处理吗?而且这样的代码虽然罗嗦,却也很直观,正可谓「朴实无华」,正可谓「大巧不工」。当然了,这是官方的说法。当有人提出能否简化这种代码的时候,Go语言之父(之一)Rob Pike(也是UTF-8之父,参见我的文章UTF-8往事)说 Errors are values。你可以像处理普通变量那样处理错误,这是Go的优势。于是他把代码改成了下面的样子:

var err error
write := func(buf []byte) {
    if err != nil { return }
    _, err = w.Write(buf)
}

write(p0[a:b])
write(p1[c:d])
write(p2[e:f])

if err != nil { return err }

因为 err 是普通变量,所以我们可以使用函数闭包捕获它。Rob 将Write调用封闭成write函数,并在函数内部对通过检查if err != nil { return } 来确定是否直的调用Write函数。这样一来,就可以直接调用write而无需每一次都检查有不有报错。代码立刻就清晰了。等所有的write都调完,再统一检查是否有报错。

大家怎么看这种处理方式呢?这样做确实可以少写很多if err != nil,但理解成本也上去了,不直观了。这跟Go语言的宗旨是不是有点矛盾呢?而且这种写法有一个致命的问题,当最后发现 err 非空的时候,你没法确定是哪一个 write 出了问题。

如果觉得通过闭包更新err有点费脑子,Rob还提出了另一个方案:

type errWriter struct {
    w   io.Writer
    err error
}
func (ew *errWriter) write(buf []byte) {
    if ew.err != nil {
        return
    }
    _, ew.err = ew.w.Write(buf)
}

ew := &errWriter{w: fd}

ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])

if ew.err != nil {
    return ew.err
}

不用闭包了,却引入了一个 struct 还定义了成员方法write方法。到底有没有真的简化,大家可以自己体会。这种模式却实打实地用到了很多地方,比如:

b := bufio.NewWriter(fd)
b.Write(p0[a:b])
b.Write(p1[c:d])
b.Write(p2[e:f])
if b.Flush() != nil {
    return b.Flush()
}

因为 error 就是普通的值,所以我们可以使用 Go 语言的全部语法特性参与错误处理。这当然是非常灵活。但灵活的代价是不够直观。如何在两者之前取得一个平衡点,是值得思考的问题。

错误上下文信息

除了流程控制外,如何判断错误类型也是一个不小的问题。我们前面说过,Go语言的错误都得实现error接口:

type error interface {
  Error() string
}

但这个接口太简单了,只有一个Error方法,返回的还是字符串。难道要程序员根据字符串的内容来区分不同的错误吗?不能够!

一种简单的处理方式就是预先定义不同的 error,然后判断返回的 err 跟预定义的值是否一致。最典型的例子就是database/sql中的ErrNoRows错误,其定义如下:

var ErrNoRows = errors.New("sql: no rows in result set")

我们在执行Scan之后需要判断err的值是否为ErrNoRows

err := rows.Scan()
if err == sql.ErrNoRows {
  // 数据库里没有查到对应记录
}

除了检查是否相等,我们还可以通过类型转换来获取更多错误信息。比如建立网络连接的时候可能会返回net.OpError这个错误。如果我们想检查是否为超时错误,我们可以:

n, err := net.Dial()
if e, ok := err.(*net.OpError); ok && e.Timeout() {
  // 处理超时逻辑
}

这种处理方式简单有效,却有一个致命的问题。我们不能修改err的值。也就是说,底层传给我们什么错误,我们就得给上层原样返回什么错误。不然上层就没法使用相等判断了。但我们在跨层级传递错误信息的时候往往需要附带一些当前的上下文信息,势必就要修改底层的错误。怎么办呢?

到了1.13版本,Go官方终于认为这是一个必要的问题,于是引入了 errors 这一官方包。

错误包装与判别

首先,Go语言为fmt包加入了%w命令。如果想向上层返回一个底层的错误而且还要附带当前的业务信息,你可以这样包装一下:

err = fmt.Errorf("user err: %w", err)

新的 err 在上层可以通过 errors.Is(err, sql.ErrNoRows) 来判断错误类型。也可以通过 errors.As 实现类型转换:

var e *net.OpError
if errors.As(err, &e) {
  if e.Timeout() {
    // 处理超时逻辑
  }
}

那什么时候需要包装,什么时候又不能包装呢?这是一个问题。

首先,我们应该避免把实现细节暴露给上层。以判断用户权限为例,如果我们把底层的sql.ErrNoRows包装之后返回给调用方,调用方就会依赖底层的sql实现:

err := pkg.LookupUser(...)
if errors.Is(err, sql.ErrNoRows) { ... }

这样会为以后我们迁移到SQL之外存储引擎制造障碍。所以,在这种情况下就不能简单包装底层错误并返回。

另一方面,我们应该避免直接给上层返回具体的错误,不然后面很难再添加上下文信息:

var ErrPermission = errors.New("permission denied")

func DoSomething() error {
    if !userHasPermission() {
        return fmt.Errorf("%w", ErrPermission)
    }
    // ...
}

这样上层代码必须通过errors.Is判断错误类型:

if err := pkg.DoSomething(); errors.Is(err, pkg.ErrPermission) {}

如果我们直接返回ErrPermission,那上层代码可能会写成:

if err := pkg.DoSomething(); err == pkg.ErrPermission {}

以后再想包装就不容易了(需要改很多地方)。

总结一下就是:底层错误不包装,隐藏实现细节;本层错误需要包装,方便以后扩展。

总结

以上就是Go语言的错误处理机制。说实话,只比C语言强了一点点,大部分工作都需要程序员自己完成。简单是简单,就是有点太简单了。