Go 博客

错误是值

Rob Pike
2015 年 1 月 12 日

在 Go 程序员中,尤其是刚接触该语言的程序员中,一个常见的讨论点是如何处理错误。讨论常常演变成对以下序列出现次数过多的抱怨:

if err != nil {
    return err
}

出现。我们最近扫描了我们能找到的所有开源项目,发现这个片段每页或每两页才出现一次,比一些人让你以为的要少得多。尽管如此,如果那种认为必须输入

if err != nil

出现。我们最近扫描了我们能找到的所有开源项目,发现这段代码片段平均每页或两页才出现一次,比有些人认为的要少得多。不过,如果人们仍然认为必须一直输入

,那肯定有问题,而显而易见的矛头指向了 Go 本身。

这是不幸的、误导性的,并且很容易纠正。也许正在发生的是,刚接触 Go 的程序员会问:“如何处理错误?”,然后学习这个模式,并止步于此。在其他语言中,人们可能会使用 try-catch 块或其他类似的机制来处理错误。因此,程序员认为,当我以前的语言中使用 try-catch 时,在 Go 中我只会输入 if err != nil。随着时间的推移,Go 代码积累了许多这样的片段,结果让人感觉笨拙。

无论这种解释是否恰当,显然这些 Go 程序员忽略了关于错误的一个基本点:错误是值。

值是可以编程的,既然错误是值,那么错误也可以编程。

当然,涉及错误值的一个常见语句是测试它是否为 nil,但是使用错误值还可以做无数其他事情,应用其中一些其他方法可以使您的程序更好,消除如果每个错误都用生硬的 if 语句检查而产生的许多样板代码。

scanner := bufio.NewScanner(input)
for scanner.Scan() {
    token := scanner.Text()
    // process token
}
if err := scanner.Err(); err != nil {
    // process the error
}

这里有一个来自 bufio 包的 Scanner 类型的一个简单例子。它的 Scan 方法执行底层 I/O,这当然可能导致错误。然而 Scan 方法根本不暴露错误。相反,它返回一个布尔值,并在扫描结束时运行一个单独的方法,报告是否发生错误。客户端代码看起来像这样

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

当然,这里有一个对错误的 nil 检查,但它只出现并执行一次。Scan 方法本可以定义为

scanner := bufio.NewScanner(input)
for {
    token, err := scanner.Scan()
    if err != nil {
        return err // or maybe break
    }
    // process token
}

然后示例用户代码可能是(取决于如何检索 token),

这并没有太大区别,但有一个重要区别。在这段代码中,客户端必须在每次迭代时检查错误,但在实际的 Scanner API 中,错误处理被从关键的 API 元素(即迭代 token)中抽象出来。使用实际的 API,客户端的代码因此感觉更自然:循环直到完成,然后担心错误。错误处理不会模糊控制流。

if err != nil

当然,幕后发生的事情是,一旦 Scan 遇到 I/O 错误,它就会记录下来并返回 false。当客户端询问时,一个单独的方法 Err 会报告错误值。尽管这很微不足道,但它与到处放置

或要求客户端在每个 token 后检查错误不同。这是用错误值进行编程。是的,是简单的编程,但仍然是编程。

值得强调的是,无论设计如何,程序检查错误(无论它们如何暴露)是至关重要的。这里的讨论不是关于如何避免检查错误,而是关于如何优雅地使用语言处理错误。

_, 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

重复的错误检查代码的话题是在我参加 2014 年秋季东京 GoCon 会议时提出的。一位热情的 gopher,Twitter 用户名是 @jxck_,也表达了对错误检查的常见抱怨。他有一些代码,大概看起来像这样

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_ 不知道如何应用它。经过长时间的交流,受到语言障碍的一些阻碍,我问是否可以借用他的笔记本电脑,通过写一些代码给他演示。

type errWriter struct {
    w   io.Writer
    err error
}

我定义了一个名为 errWriter 的对象,大概像这样

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

并给了它一个方法,write。它不需要具有标准的 Write 签名,并且部分使用小写是为了强调区别。write 方法调用底层 WriterWrite 方法,并记录下第一个错误供将来参考

一旦发生错误,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
}

有了 errWriter 类型及其 write 方法,上面的代码可以重构为

这更简洁,即使与使用闭包相比也是如此,并且使页面上实际执行的写入序列更容易看清。不再有杂乱的代码。使用错误值(和接口)编程使代码更美观。

同一个包中的其他一些代码很可能可以在这个想法的基础上构建,甚至直接使用 errWriter

此外,一旦 errWriter 存在,它还可以做更多事情来提供帮助,尤其是在不那么人工的例子中。它可以累积字节数。它可以将写入合并到一个可以原子传输的缓冲区中。以及更多。

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()
}

事实上,这种模式经常出现在标准库中。archive/zipnet/http 包都使用了它。与本次讨论更相关的是,bufio 包的 Writer 实际上是 errWriter 想法的一个实现。尽管 bufio.Writer.Write 返回一个错误,但这主要是为了遵循 io.Writer 接口。bufio.WriterWrite 方法的行为就像我们上面的 errWriter.write 方法一样,通过 Flush 报告错误,所以我们的例子可以这样写

这种方法有一个显著的缺点,至少对于某些应用程序而言:无法知道在错误发生之前完成了多少处理。如果这些信息很重要,则需要更精细的方法。然而,通常情况下,最后的“全有或全无”检查就足够了。

我们只介绍了一种避免重复错误处理代码的技术。请记住,使用 errWriterbufio.Writer 并不是简化错误处理的唯一方法,这种方法也不适用于所有情况。然而,关键的一课是,错误是值,可以使用 Go 编程语言的全部能力来处理它们。

利用语言来简化您的错误处理。

但请记住:无论您做什么,务必检查您的错误!

最后,想了解我与 @jxck_ 互动的故事全貌,包括他录制的一个小视频,请访问他的博客
下一篇文章:包名
上一篇文章:GothamGo:大苹果城里的 gopher