Go 错误是值

本文是对 Rob Pike 博客 Errors are values 的翻译。

在 Go 中,经常可以看到关于错误处理的讨论。在讨论的过程中,对话通常会演变成对语句

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

出现次数的哀叹。我们最近扫描了我们能找到的所有开源项目,发现这个代码段在每一页只出现过一两次,比一些人让你相信的要少。不过,如果人们仍然认为你必须一直输入

1
if err != nil 

这种事情发生一定是有原因的,一个显而易见的原因就是 Go 语言本身。

这是一个不幸的,具有误导性的结论,并且它很容易被纠正。一个可能发生的情况是,刚接触 Go 的程序员会问,“如何处理错误?“,在学会了上面的这种处理模式后,就止步不前了。在其他语言中,例如 java,人们可能会使用 try-catch 块或其他此类机制来处理错误。因此,程序员会认为,就像我以前用过的语言中使用 try-catch 一样,我只需在 Go 中输入 if err != nil 。随着时间的推移,代码库中累积了许多这样的代码段,最终让人觉得笨拙。

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

我们可以对值进行编程,并且由于错误是值,因此可以对错误进行编程。

当然,涉及错误值的一个常见语句是判断它是否为 nil,但还可以对错误值执行无数其他操作,并且应用其中一些操作可以使您的程序更好,从而消除如果使用死板的 if 语句检查每个错误时出现的许多样板代码。

下面是 bufio 包的 Scanner 类型的简单示例。它的 Scan 方法执行基础 I/O,这当然可能导致错误。然而, Scan方法根本不公开错误。相反,它返回一个布尔值,并在扫描结束时运行一个单独的方法来报告是否发生错误。客户端代码如下所示:

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
}

当然,在代码中有一个错误的 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
}

这并没有什么不同,但有一个重要的区别。在此代码中,客户端必须在每次迭代时都检查一次错误,但在真正的 ScannerAPI 中,错误处理从关键的 API 元素(即遍历标记)中抽象出来。因此,使用真正的 API 时,客户端的代码感觉更自然:循环直到完成,然后处理错误。错误处理不会掩盖控制流。

当然,在底层发生的事情是,一旦 Scan 遇到 I/O 错误,它就会记录该错误并返回 false 。当客户端询问时,另一个方法 Err 会报告错误值。虽然这很微不足道,但它与在每个地方,都放置

1
if err != nil

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

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

我在 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

它非常重复。在更长的实际代码中,还有更多内容,因此不能简单地使用辅助函数重构它,但这种理想化的形式,一个封闭在错误变量上的函数字面量会有所帮助:

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

一旦发生错误, write 方法将变为无操作,但会保存错误值。

给定 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
}

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

很可能同一软件包中的其他一些代码片段可以基于此想法进行构建,甚至直接使用 errWriter

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

事实上,这种模式经常出现在标准库中。 archive/zipnet/http 包使用它。更重要的是, bufio 包的 Writer 实际上是 errWriter 思想的实现。虽然 bufio.Writer.Write 返回错误,但这主要与遵守 io.Writer接口有关。 bufio.WriterWrite 方法的行为与我们上面的 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()
}

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

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

使用该语言来简化错误处理。

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


Go 错误是值
https://blog.zhangliangliang.cc/post/go-errors-are-value.html
作者
Bobby Zhang
发布于
2023年12月25日
许可协议