小马别过河

V1

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.WriterWrite方法,并且记录第一个 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.WriterWrite方法和我们的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 的技术。记住使用 errWriterbufio.Writer不是唯一的简化错误处理的方法,这个方案不适用全部场景。然而,关键的教训是 error 是值,Go 编程语言是可以处理它的。

但请记住:无论你怎么做,永远要检查你的错误。

分类:

后端

标签:

Golang

作者介绍

小马别过河
V1