小马别过河
2022/10/14阅读:14主题:默认主题
Go: Errors Are Values
译者序
可能社区吐槽 go 代码 if err != nil
的片段的呼声比较大,Go 作者之一 Rob Pike 大神在 2015 写了这篇文章,给大家一些优化的建议。原文见:
https://go.dev/blog/errors-are-values
正文
Go 开发者(特别是新手)中最常讨论的一个点是,如何处理 error。通常是哀叹项目中有大量的这种代码段:
if err != nil {
return err
}
我们最近扫描了所有的开源项目发现,这个代码段一般一到两页出现一次,比我们预想的要低得多。但是,如果大部分人坚持认为经常要敲这种代码:
if err != nill
肯定事出有因,很明显出在 Go 身上。
这个误导性的做法很容易改正。可能新手会问:“怎么处理 erros”?学习一下本文的模式,然后不再疑惑。在别的语言,开发者可能使用 try-catch 块或者其他类似的机制来处理 error。因此,他们可能会有惯性,原本的语言哪里需要用 try-catch,在 Go 也加上 if err != nil
。随着时间迁移,这种代码块在 Go 代码中随处可见,显得非常笨拙。
且不论这个解释是否合理,但这些 Go 程序员明显忽略了一个基础点:error 是个值。
值可以被编码,既然 error 是值,也同样可以被编码。
比如说 bufio
包的 Scanner
类型。它的Scan
方法执行低层的 IO,这当然可能导致错误。但是Scan
方法并没有返回 error。相反,它返回一个 boolean 和一个单独方法,在扫描结束时运行,报告是否发生了错误。客户端代码如下:
scanner := bufio.NewScanner(input)
for scanner.Scan() {
token := scanner.Text()
// process token
}
if err := scanner.Err(); err != nil {
// process the error
}
这段代码也需要检查 error 是否为 nil,但只需要一次。Scan
可能原本是这么设计:
func (s *Scanner) Scan() (token []byte, error)
然后客户端调用的代码可能:
scanner := bufio.NewScanner(input)
for {
token, err := scanner.Scan()
if err != nil {
return err // or maybe break
}
// process token
}
两者很相似,但有一个重要的区别。在这段代码中,客户端必须在每个循环都检查 err,但在实际的 Scanner
API,错误处理从关键的 API 中抽象出来。相比之下,实际上的 API 客户端用起来会更加自然:循环结束后再考虑错误的事。错误处理不会混在流程控制里。
在底层,Scan
出现 IO 错误,会记录起来,然后返回 false。在另外一个Err
方法返回具体的错误。这很琐碎,但不会把这段代码
if err != nil
放的到处都是,或者每次调用都要判断一下。这使用错误值编程,简洁编程,但依然是编程。
值得强调的是,无论怎么设计,检查错误都是至关重要的。我们讨论的不是怎么避免检查错误,而是如何优雅处理。
关于重复处理 error 的话题,是我在 2014 年参加在东京举办的 GoCon 遇到的。一位 Twitter 名为 @jxck_
热情的 gopher,对重复判断 error 痛心疾首。他有类似如下的代码:
_, 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
这很冗余。在真实的代码中,代码更多,且不容易使用辅助函数(helper function)重构,但在这种偏理想化的情况下,一个处理错误的字面量函数可能会有用:
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
对象,就像:
type errWriter struct {
w io.Writer
err error
}
然后给它一个方法 write
。它并不需要标准的Write
签名,小写以示区别。write
方法调用底层io.Writer
的Write
方法,并且记录第一个 error 供后续使用:
func (ew *errWriter) write(buf []byte) {
if ew.err != nil {
return
}
_, ew.err = ew.w.Write(buf)
}
一旦发生错误,write
方法会变成空操作,error 会被保存。
有了 errWriter
类型和它的 write
方法,上面的代码可以重构成:
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
,它可以提供更多的帮助,特别是在不人性化的例子。它能累计字节数,或者合并多个写到一个可以原子传输的 buffer 等等。
实际上,这种模式经常出现在标准库中。archive/zip
和net/http
包就有使用它。更突出的是,bufio
包的Writer
实际上就是对errWriter
思想的实现。尽管 bufio.Writer.Writer
返回一个错误,那是为了符合 io.Writer
接口。bufio.Writer
的Write
方法和我们的errWriter.writer
方法表现一样,用Flush
返回错误,所以我们的例子可以这么写:
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 是值,Go 编程语言是可以处理它的。
但请记住:无论你怎么做,永远要检查你的错误。
作者介绍