原文:https://go.dev/blog/errors-are-values
Errors are values
Rob Pike
12 January 2015
Go 程序猿之间经常讨论的点,特别是那些新进使用 Go 的人,就是如何处理 errors。对话经常会转变为对以下程序出现次数的哀叹:
1 | if err != nil { |
我们最近扫描了所有我们能找到的开源项目,发现这个(代码)片段仅仅在每页或每两页中出现一次,比某些人认为的次数要少。尽管如此,如果还是有人坚持认为他们一直要输入以下代码:
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 | scanner := bufio.NewScanner(input) |
当然,有一个对 error 是否为 nil 的检查,但是他只出现和执行一次。Scan 方法可以使用如下定义:
1 | func (s *Scanner) Scan() (token []byte, error) |
那么上面的示例代码可能如下(依赖 token 如何获取):
1 | scanner := bufio.NewScanner(input) |
这没有很大的不同,但有一个很重要的区别。在这个代码里,客户端必须在每次迭代中检查 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 | _, err = fd.Write(p0[a:b]) |
代码重复性很高。在真实的代码中会更长,还有更多的事发生,所以使用辅助函数对此重构并不容易。但是在种理想情况中,使用一个函数关闭 error 变量会有帮助:
1 | var err error |
这个模式看起来不错,但是需要在每个执行写入的函数中使用闭包函数。单独的辅助函数使用起来比较笨拙,因为 err 变量需要在调用之间维护(试试它)。
我们可以让他更加简洁、通用和可重复使用,通过上面提到的 Scan 方法带来的想法。在我们的讨论中我提到了这个技术,但是 jxck_ 没看到如何应用他。经过长时间的交流,有点受语言交流的障碍,我询问我是否可以用他的笔记本输入一些代码向他展示。
我定义了一个对象叫 errWriter,如下:
1 | type errWriter struct { |
然后给他一个方法,write
。他不需要有标准的 Write 签名,他的小写是为了突出区别。write
方法调用底层Writer
的Write
方法和记录未来引用的第一个错误:
1 | func (ew *errWriter) write(buf []byte) { |
当 error 发生时,write
方法变成没有操作的方法,但是 error 的值已经被保存了。
给定 errWriter 类型和他的 write 方法,上面的代码可以重构为:
1 | ew := &errWriter{w: fd} |
这是干净的代码,甚至比闭包的还要干净,并且还使得实际的写入(的代码)顺序更加易读。不再有杂乱了,使用 error 值(和接口)已经让代码更好了。
同一个包中的其他代码也可以基于这个想法,甚至直接使用 errWriter。
此外,一旦 errWriter 存在,它可以做更多的事,特别是在一些(代码)做作的例子中。他可以累计字节数,他可以将写入合并到一个缓冲区中然后可以自动传输。以及更多。
事实上,这种模式经常在标准库中出现。archive/zip 和 net/http 包使用它。本地讨论更加突出,bufio 包的 Writer 实际上就是 errWriter 想法的实现。尽管 bufio.Writer.Write 返回了一个 error,这主要是为了实现 io.Writer 接口。bufio.Writer 的 Write 方法表现的像上面的 errWriter.write 方法一样,Flush 报告错误,所以我们的例子可以像下面这么写:
1 | b := bufio.NewWriter(fd) |
这种方式有一个重大的缺点,至少对于一些应用来说:无法知道在错误发生之前完成了多少处理。如果那个(完成了多少处理)信息很重要,需要更细粒度的方式。不过很多时候,在最后全有或全无的检查就足够了。
我们仅仅看到了一种避免重复处理 error 的代码。记住使用 errWriter 或 bufio.Writer 不是简化 error 处理的唯一方式,这种方式也不适用与所有情况。然而,关键的教训是:errors are values,并且可以使用 Go 编程语言全部能力来处理他们。
使用语言去简化你的错误处理。
但是记住:无论你做什么,总是要检查你的 errors!
最后,jxck 和我互动的完整故事,包括一个他记录的录像,访问他的博客。