探讨 Go 错误机制

如果是第一次用 Go,应该大概率会主要到 Go 的两点不同

  1. Go 的类型在后面
  2. Go 大部分函数都有个error返回,并且程序中有大量的if err != nil错误处理

第二点,是一个很多人称赞,并且很多人吐槽的饱受争议的特性。

错误和异常是什么

首先,需要考虑一个函数被调用,可能发生哪几种情况?

  1. 函数本身错误,调用失败(比如语法错误、链接的库错误)
  2. 调用者乱传参数,调用失败(比如明明要传 int,结果传了个 string)
  3. 调用过程中发现不满足某些情况,调用成功,但并未按照预期执行(比如原本希望写出到某个文件,但是发现没权限)
  4. 发生了意料外的情况,调用失败(比如除 0 了,发生了空指针)
  5. 调用成功,正确返回结果(函数正确运行)

前两者是 Python、Java 中的“错误”,也即开发者无法解决的问题,不需要在程序中检查
第三种是 Python、Java 中的异常,Go 中的错误
第四种是 Python、Java、Go 中的异常

Go 错误机制的优点

可以看出,实际上 Go 是把错误(可以预料到)和异常(预料不到)分为了两种情况,而 Java 和 Python 将他们归为一类(程序没正确成功,另一种错误这里不讨论,因为无法解决)

如果只考虑这一点,Go 本身的设计显然更好,因为错误和异常实际上就是两种东西,需要分开处理。
错误是可能可以解决的,体现为可以在调用结束后得到是否执行成功,如果失败则进入相应的逻辑继续。
而异常则是可能导致服务关闭的,在某些情况下可能需要关闭服务。体现为,如果不对异常进行捕获,程序将会退出

而其他语言中,可能并未显式要求进行错误处理,体现就是函数返回值中并没有体现是否是正确执行。大多数则将原本应该是错误的当作异常来进行处理。也即发现有问题,不是返回函数,而是抛出异常。
但异常并不代表一定会被捕获,有可能外层并没有,或者隔了很多层外才有一个捕获。这种情况下,这个异常(错误)实际上无法得到真正正确的处理。

换句话说,如果把诸如网络访问超时当作异常来处理,实际上可以认为是滥用了异常。一方面,异常肯定比直接返回是否成功消耗大得多,另一方面异常可能无法被及时捕获(及时处理),特别是一个第三方库,无法控制调用者去合理地处理异常。

而如果像 Go 或者 C 一样返回一个状态码/错误信息,那么实际上就是当前 Go 的解决方案。而 Go 大部分包都采用该模式,反而强迫了开发者一定要进行错误处理,反而更为统一。

也即发现错误,应该第一时间处理,而非看缘分处理

另一方面,try ... catch ...将多行集中处理,如果存在相同的异常,可能无法判断究竟是哪一步出错,也就没法针对性地进行解决。除非每行一个try ... catch ...,不然抛出异常无法达到非常精细地解决问题。
使用抛出异常的机制,实际上很难控制这个粒度问题,每行一个try ... catch ...,显然很蠢,而且需要大量无脑的判断处理;而如果一个函数就一个try ... catch ...,则又太无脑,而且这么做只有在异常无法解决只能向上抛时才有意义,如果存在解决问题的机制,实际上这么做无法解决任何问题。

Go 错误机制的问题

但是,反对者提出了这样做的弊病(实际上是 Go 的弊病)

  • 每行处理一次太繁琐
  • Go 的错误难以解析

对于第一点,确实麻烦,通常而言,Go 的程序长成下面的样子,非常不简洁,并且有大量无脑处理存在,处理错误部分可能只是简单的抛给上层而已

res, err := doSomething(1, 2, 3, 4)
if err != nil {
    // .... 处理错误
}
defer res.Close()

if err := res.do(); err != nil {
    // ... 处理错误
}
if err := res.do2(); err != nil {
    // ... 处理错误
}

而在其他语言中,完全可以一个try ... catch ... 解决

try:
    res = doSomethine(1, 2, 3, 4)
    res.do()
    res.do2()
except Exception as e:
    # 处理异常
finally:
    res.close()

可以看出,在这种情况下,try ... catch ...的写法,更符合思维。

尽管,Go 本身也存在异常机制,通过panic()recover()defer来实现try ... catch ...
不过,虽然解决了上面的问题,但是相对于真正的 try ... catch ...,这里不存在调用堆栈信息,也即无法确定具体是那里出的问题(这一点可以自己封装错误信息,在其中输出调用堆栈,如 OhYee/rainbow 这个库)
另外,这种方法只能处理panic(),无法处理error

func test() {
    defer func() {
        // catch 部分
        if e := recover(); e != nil {
            fmt.Printf("Panicing %s\r\n", e)
        }
        // finally 部分
    }()

    // try 部分
    badCall()
    fmt.Printf("After bad call\r\n") // <-- wordt niet bereikt
}

而第二点,则问题也很大,Go 的错误实际上是一个接口,包含一个Error() string成员函数。

type error interface {
    Error() string
}

如果调用者需要具体判断到底返回的是哪一个错误,实际上非常麻烦

switch err.Error() {
    case "打不开文件":
        // ...
    case "不知道为啥,就是像剥个错":
        // ...
    case "网络连接超时":
        // ...
    default:
        // 其他错误
}

错误信息可能包括多种语言,也可能存在部分内容是拼接起来的,如果错误类型很多,非常难以判断。在这种情况下,甚至不如 C 返回错误码的机制,但是错误码本身也极为割裂,由于不同程序错误码可能相同,在判断时容易造成混淆。

而在其他语言中,则完全可以根据抛出的异常类型,来确定到底是哪一种异常

Go 1.13

在 Go 1.13 中,引入了新的概念,实现了错误信息嵌套。
通过fmt.Errorf("发现了错误,内层错误是: %w", err),这样可以对错误进行二次封装。
借助errors.Unwrap()可以查看内层错误,同时使用errors.Is()errors.As()可以判断错误是否是某个类型,并且将其尝试还原成某个结构

这样做,可以某种程度上解决上面提到的难以解析的问题

Go 2

在 Go 2 的草案中,则引入了新的关键字来进行错误处理

func CopyFile(src, dst string) error {
    handle err {
        return fmt.Errorf("copy %s %s: %v", src, dst, err)
    }

    r := check os.Open(src)
    defer r.Close()

    w := check os.Create(dst)
    handle err {
        w.Close()
        os.Remove(dst) // (only if a check fails)
    }

    check io.Copy(w, r)
    check w.Close()
    return nil
}

可以看出,如果是相同的错误处理,可以将其简化为一个check(但是单论这个方案,如果存在多种处理措施交替使用可能还是会很麻烦)

但无论如何,这种机制至少尽可能地在解决目前 Go 存在的问题。

参考资料