[译] 为什么 Go 的错误处理很棒?还是有人很喜欢的嘛

Go 臭名昭著的错误处理 [1] 引起了外部人士对该编程语言的广泛关注,它经常被说成是该语言最有争议的设计决策之一。如果您查看用 Go 编写的 GitHub 上的任何项目,几乎可以保证,你最常见到的代码是:

if err != nil {
    return err
}

尽管对于那些刚接触该语言的人来说似乎是多余的和不必要的,但是 Go 语言中的错误被视为一等公民(值)的原因在编程语言理论中有着深厚的历史。虽然已经做出了许多努力来更改或改进 Go 处理错误的方式,但是到目前为止,有一项提议比其他所有提议似乎更好:

Leave if err != nil alone! [2]

Go 的错误哲学

Go 关于错误处理的理念使得开发人员将错误纳入他们编写的大多数函数的一等公民。即使您使用类似以下内容忽略错误:

func getUserFromDB() (*User, error) { ... }

func main() {
    user, _ := getUserFromDB()
}

大多数 Linter 或 IDE 都会发现您忽略了一个错误,并且在代码审阅期间,您的团队成员肯定会看到该错误。但是,在其他语言中,可能不清楚您的代码没有在 try catch 代码块中处理潜在的异常,因为在处理控制流方面是完全不透明的。

如果您以标准方式处理 Go 中的错误,您将获得以下好处:

  1. 没有隐藏的控制流

  2. 没有意外的未捕获异常日志炸毁您的终端(除了由于 panic 导致的实际程序崩溃)

  3. 可以完全控制代码中的错误,可以选择处理,返回和执行任何其他操作

func f() (value, error) 的语法不仅易于向新手讲解,而且在任何 Go 项目中都可确保一致性。

请务必注意,Go 的错误语法不会强迫您处理程序可能抛出的每个错误。Go 只是提供了一种模式,以确保您认为错误对于程序流至关重要,没有其他更多要求。在程序结束时,如果发生错误,并且使用 err != nil 来发现它,如果您的应用程序不对其执行任何操作,则您可能会遇到麻烦——Go 不会为你自动保存什么。让我们看一个例子:

if err := criticalDatabaseOperation(); err != nil {
    // Only logging the error without returning it to stop control flow (bad!)
    log.Printf("Something went wrong in the DB: %v", err)
    // WE SHOULD `return` beneath this line!
}

if err := saveUser(user); err != nil {
    return fmt.Errorf("Could not save user: %w", err)
}

如果在调用 criticalDatabaseOperation() 时出错,即 err != nil,那么除了记录错误之外,如果我们不做任何其他事情,我们可能会遇到数据损坏或无法智能处理的其他无法预料的问题,解决方法是重试函数调用,或取消进一步的程序流,或者最坏情况是退出程序。Go 并不是神奇的事物,也无法从这些情况中解救您。Go 仅提供了一种返回并使用错误作为值的标准方法,但是您仍然必须自己弄清楚如何处理错误。

其他语言如何做的:抛出异常

在类似 Javascript/Node.js 运行时的环境中,您可以按以下方式构造程序,称为抛出异常:

try {
    criticalOperation1();
    criticalOperation2();
    criticalOperation3();
} catch (e) {
    console.error(e);
}

如果这些函数中的任何一个发生错误,则错误的堆栈跟踪将在运行时弹出并记录到控制台,但是不会对发生的问题进行明确的编程处理。

您的 criticalOperation 函数无需显式处理错误流,因为在该 try 块中发生的任何异常都会在运行时引发,并给出错误原因的堆栈跟踪。与 Go 相比,基于异常的语言的一个好处是,即使在运行时发生未处理的异常,仍会通过堆栈跟踪它。在 Go 中,可能根本不处理严重错误,这可能会更糟。Go 为您提供了对错误处理的完全控制权,但也要你承担全部责任。

注意:异常绝对不是其他语言处理错误的唯一方法。例如,Rust 很好地折衷了使用选项类型和模式匹配来查找错误条件,并利用一些不错的语法糖来达到类似的结果。

为什么 Go 不使用异常进行错误处理

Go 之禅

Go 之禅提到了两个重要的谚语:

  1. 简单很重要

  2. 为失败计划而不是成功

对所有返回 (value, error) 的函数使用简单的 if err!= nil 片段有助于确保首先考虑程序失败的情况。您无需费心处理复杂的嵌套 try catch 块,它们可以适当地处理所有可能出现的异常。

基于异常的代码通常是不透明的

但是,对于基于异常的代码,您会意识到这样的场景:代码实际有异常,但并没有正确处理,因为它们会被 try catch 块捕获。也就是说,它鼓励程序员从不检查错误,至少知道,某些异常(如果发生)将在运行时自动处理。

用基于异常的编程语言编写的函数通常看起来像这样:

item = getFromDB()
item.Value = 400
saveToDB(item)
item.Text = 'price changed'

此代码不执行任何操作以确保异常得到正确处理。也许让上面的代码感知到异常之间的区别是更换 saveToDB(item)item.Text = 'price changed' 的顺序,这是不透明的,难以推理,并且可能鼓励一些懒惰的编程习惯。在函数式编程术语中,这被称为幻想术语: 违反引用透明性 [3] 。微软 2005 年的工程博客中的 这篇博客文 [4] 章至今仍然适用,即:

我的意思不是说 Exception 不好的。我的观点是,Exception 太难了,我不够聪明,无法处理它们。

Go 错误语法的好处

轻松创建可行的错误链

if err != nil 模式的优势在于,通过错误链能方便遍历程序层次结构,直到发生错误的地方。例如,由程序的 main 函数处理的常见 Go 错误可能如下所示:

[2020-07-05-9:00] ERROR: Could not create user: could not check if user already exists in DB: could not establish database connection: no internet

上面的错误是(a)清楚的,(b)可操作的,(c)对于应用程序的哪一层出错了具有足够的信息。像这样的错误不是由难以理解的堆栈跟踪引起的,我们可以添加人类可读上下文描述这些错误的原因,并且通过如上所示的清晰错误链进行处理。

这种错误链自然会成为标准 Go 程序结构的一部分,可能看起来像这样:

// In controllers/user.go
if err := db.CreateUser(user); err != nil {
    return fmt.Errorf("could not create user: %w", err)
}

// In database/user.go
func (db *Database) CreateUser(user *User) error {
    ok, err := db.DoesUserExist(user)
    if err != nil {
        return fmt.Errorf("could not check if user already exists in db: %w", err)
    }
    ...
}

func (db *Database) DoesUserExist(user *User) error {
    if err := db.Connected(); err != nil {
        return fmt.Errorf("could not establish db connection: %w", err)
    }
    ...
}

func (db *Database) Connected() error {
    if !hasInternetConnection() {
        return errors.New("no internet connection")
    }
    ...
}

上面代码的美在于,这些错误中的每一个都完全由其各自的函数来命名,它们具有丰富的信息,仅对它们所知道的负责。使用 fmt.Errorf("something went wrong: %w", err) 进行的此类错误链接使构建令人敬畏的错误消息变得微不足道,这些错误消息可以根据您的定义准确地告诉您出了什么问题。

最重要的是,如果您还希望将堆栈跟踪附加到函数中,则可以利用出色的 github.com/pkg/errors 库,为您提供以下功能:

errors.Wrapf(err, "could not save user with email %s", email)

它将打印出堆栈跟踪以及您通过代码创建的易于理解的错误链。如果可以,我想总结一下我想到的有关在 Go 中编写符合 Go 习惯的错误处理的最重要建议:

  1. 当您的错误需要服务开发人员时,请添加堆栈跟踪

  2. 对返回的错误进行处理,不要只是将它们冒出来(返回),记录下来,然后忘记它们

  3. 保持您的错误链明确

当我编写 Go 代码时,错误处理是我永远不会担心的一件事,因为错误本身是我编写的每个函数的核心内容之一,从而使我能够完全控制我如何安全,可读且负责任地处理它们。

原文链接:https://rauljordan.com/2020/07/06/why-go-error-handling-is-awesome.html

作者:Raul Jordan

编译:polaris

参考资料

[1]

臭名昭著的错误处理: https://github.com/golang/go/issues/32825

[2]

Leave if err != nil alone!: https://github.com/golang/go/issues/32825

[3]

违反引用透明性: https://stackoverflow.com/questions/28992625/exceptions-and-referential-transparency/28993780#28993780

[4]

这篇博客文: https://devblogs.microsoft.com/oldnewthing/?p=36693