LJ的Blog

学海无涯苦做舟

0%

Errors are values(翻译)

原文:https://go.dev/blog/errors-are-values

Errors are values

Rob Pike
12 January 2015

Go 程序猿之间经常讨论的点,特别是那些新进使用 Go 的人,就是如何处理 errors。对话经常会转变为对以下程序出现次数的哀叹:

1
2
3
if err != nil {
return err
}

我们最近扫描了所有我们能找到的开源项目,发现这个(代码)片段仅仅在每页或每两页中出现一次,比某些人认为的次数要少。尽管如此,如果还是有人坚持认为他们一直要输入以下代码:

1
if err != nil

那么一定是哪里出错了,(他们认为)显而易见的目标是 Go 本身。

这是不幸的,有误导性的,并且很容易纠正的。事情可能是这样的:一个 go 语言的新人提出问题“如何处理 errors?”,学会之后,就停在那了。(learn this pattern, and stop there)。在其他的语言里,也许使用 try-catch 块或者其他的机制处理 errors。所以,程序猿会认为在以前的语言里用 try-catch ,在 Go 里我只要输入 if err != nil。随着时间的推移,Go 代码收集了许多这样的片段,结果感觉很笨拙。

无论这个解释是否合适,很明显的一点是 Go 程序猿忽略了关于 errors 的一个基本的点:错误是值(Errors are values)

值可以被编程,既然 errors 是值,那么 errors 就可以被编程。当然一个常见的涉及错误值得语句就是测试它是否为 nil,但是还有无数其他的事可以用一个错误值来做,和其他的一些东西一起应用会让你的程序更好,并消灭大部分死板的错误检查代码。

这里有一个来自 bufio 包的 Scanner 类的简单栗子。他的 Scan 方法执行底层 I/O 会产生一个 error,然而 Scan 方法根本没有暴露一个 error,而是返回了一个 bool 值,和另一个在 scan 结束后调用的分离的方法,来报告是否有 error 产生,客户端代码如下:

1
2
3
4
5
6
7
8
scanner := bufio.NewScanner(input)
for scanner.Scan() {
token := scanner.Text()
// process token
}
if err := scanner.Err(); err != nil {
// process the error
}

当然,有一个对 error 是否为 nil 的检查,但是他只出现和执行一次。Scan 方法可以使用如下定义:

1
func (s *Scanner) Scan() (token []byte, error)

那么上面的示例代码可能如下(依赖 token 如何获取):

1
2
3
4
5
6
7
8
scanner := bufio.NewScanner(input)
for {
token, err := scanner.Scan()
if err != nil {
return err // or maybe break
}
// process token
}

这没有很大的不同,但有一个很重要的区别。在这个代码里,客户端必须在每次迭代中检查 error,但是在真正的 Scanner API 中,error 处理被从关键的 API 元素中抽离,使用 token 迭代。使用真正的 API,客户端代码感觉更加自然:循环到结束才需要担心 errors。错误处理不会掩盖控制流。

在这幕后发生了什么?当然,如果 Scan 立刻遭遇 I/O error,他会记录错误并返回 false。另一个分离的方法 Err 会在客户端询问的时候报告这个 error 值。尽管这是微不足道的,但是与 if err != nil无处不在要求客户端在每次在获取 token 后都检查 error 不同。这是用 error 值编程。简单的编程,是的,但是是编程。

值得强调的是无论设计如何,如果有 error 无论他们是如何暴露的,都要检查。这里的讨论不是如何避免错误检查,他是关于如何使用语言优雅的处理错误。

当我参加 2014 年秋季在东京举行的 GoCon 时,出现了重复错误检查代码的话题。一位热情的 gopher,在 twitter 上叫 @jxck_ ,也有类似的关于错误检查的哀叹。他有一些代码看起来如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
_, 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
}
// and so on

代码重复性很高。在真实的代码中会更长,还有更多的事发生,所以使用辅助函数对此重构并不容易。但是在种理想情况中,使用一个函数关闭 error 变量会有帮助:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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])
// and so on
if err != nil {
return err
}

这个模式看起来不错,但是需要在每个执行写入的函数中使用闭包函数。单独的辅助函数使用起来比较笨拙,因为 err 变量需要在调用之间维护(试试它)。

我们可以让他更加简洁、通用和可重复使用,通过上面提到的 Scan 方法带来的想法。在我们的讨论中我提到了这个技术,但是 jxck_ 没看到如何应用他。经过长时间的交流,有点受语言交流的障碍,我询问我是否可以用他的笔记本输入一些代码向他展示。

我定义了一个对象叫 errWriter,如下:

1
2
3
4
type errWriter struct {
w io.Writer
err error
}

然后给他一个方法,write。他不需要有标准的 Write 签名,他的小写是为了突出区别。write方法调用底层WriterWrite方法和记录未来引用的第一个错误:

1
2
3
4
5
6
func (ew *errWriter) write(buf []byte) {
if ew.err != nil {
return
}
_, ew.err = ew.w.Write(buf)
}

当 error 发生时,write方法变成没有操作的方法,但是 error 的值已经被保存了。

给定 errWriter 类型和他的 write 方法,上面的代码可以重构为:

1
2
3
4
5
6
7
8
ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
// and so on
if ew.err != nil {
return ew.err
}

这是干净的代码,甚至比闭包的还要干净,并且还使得实际的写入(的代码)顺序更加易读。不再有杂乱了,使用 error 值(和接口)已经让代码更好了。

同一个包中的其他代码也可以基于这个想法,甚至直接使用 errWriter。

此外,一旦 errWriter 存在,它可以做更多的事,特别是在一些(代码)做作的例子中。他可以累计字节数,他可以将写入合并到一个缓冲区中然后可以自动传输。以及更多。

事实上,这种模式经常在标准库中出现。archive/zip 和 net/http 包使用它。本地讨论更加突出,bufio 包的 Writer 实际上就是 errWriter 想法的实现。尽管 bufio.Writer.Write 返回了一个 error,这主要是为了实现 io.Writer 接口。bufio.Writer 的 Write 方法表现的像上面的 errWriter.write 方法一样,Flush 报告错误,所以我们的例子可以像下面这么写:

1
2
3
4
5
6
7
8
b := bufio.NewWriter(fd)
b.Write(p0[a:b])
b.Write(p1[c:d])
b.Write(p2[e:f])
// and so on
if b.Flush() != nil {
return b.Flush()
}

这种方式有一个重大的缺点,至少对于一些应用来说:无法知道在错误发生之前完成了多少处理。如果那个(完成了多少处理)信息很重要,需要更细粒度的方式。不过很多时候,在最后全有或全无的检查就足够了。

我们仅仅看到了一种避免重复处理 error 的代码。记住使用 errWriter 或 bufio.Writer 不是简化 error 处理的唯一方式,这种方式也不适用与所有情况。然而,关键的教训是:errors are values,并且可以使用 Go 编程语言全部能力来处理他们。

使用语言去简化你的错误处理。

但是记住:无论你做什么,总是要检查你的 errors!

最后,jxck 和我互动的完整故事,包括一个他记录的录像,访问他的博客