logo

为什么 Go 语言的错误处理如此出色

Published on

Go 语言那备受争议的错误处理方式已经引起了编程语言圈外人士的广泛关注,它经常被视为该语言最值得商榷的设计决策之一。如果你查看 GitHub 上任何用 Go 编写的项目,几乎可以保证你会在代码库中看到这样的代码行比其他任何内容都更频繁:

if err != nil {
    return err
}

虽然对于语言新手来说,这可能看起来多余且不必要,但 Go 语言将错误视为一等公民(值)的原因在编程语言理论中有着深厚的根基,也与 Go 语言本身的主要目标密切相关。虽然已经有许多努力试图改变或改进 Go 处理错误的方式,但到目前为止,有一个提案在所有其他提案中胜出:

保持if err != nil不变!

Go 的错误处理哲学

Go 关于错误处理的哲学迫使开发者将错误作为他们编写的大多数函数中的一等公民。即使你使用类似以下方式忽略错误:

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

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

大多数代码检查工具或 IDE 都会发现你正在忽略一个错误,而且这肯定会在代码审查中被你的团队成员发现。然而,在其他语言中,可能并不清楚你的代码没有在 try-catch 代码块中处理潜在的异常,这对控制流的处理完全不透明。

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

  • 没有隐藏的控制流
  • 没有意外的未捕获异常日志爆满你的终端(除了通过 panic 导致的实际程序崩溃)
  • 完全控制代码中的错误,将其作为你可以处理、返回并任意操作的值

func f() (value, error)的语法不仅易于教给新手,而且在任何 Go 项目中都是一个标准,确保了一致性。

重要的是要注意,Go 的错误处理语法并不强制你处理程序可能抛出的每个错误。Go 只是提供了一个模式,确保你将错误视为程序流程中的关键部分,仅此而已。在程序结束时,如果发生错误,你使用err != nil发现了它,但你的应用程序没有采取可行的措施来处理它,那么无论如何你都会遇到麻烦 - Go 无法拯救你。让我们看一个例子:

if err := criticalDatabaseOperation(); err != nil {
    // 只记录错误而不返回它以停止控制流(不好!)
    log.Printf("Something went wrong in the DB: %v", err)
    // 我们应该在这一行下面添加`return`!
}

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 的禅理提到了两个重要的格言:

  • 简单性很重要
  • 为失败而非成功做计划

对所有返回(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'的顺序,这是不透明的,难以推理,并且可能鼓励一些懒惰的编程习惯。在函数式编程术语中,这被称为违反引用透明性。微软 2005 年工程博客中的这篇文章今天仍然适用,即:

"我的观点不是异常不好。我的观点是异常太难了,我不够聪明,无法处理它们。"

Go 错误语法的好处

轻松创建可操作的错误链

if err != nil模式的一个超能力是它如何允许轻松创建错误链,使其穿越程序层次结构直到需要处理它们的地方。例如,由程序的 main 函数处理的常见 Go 错误可能如下所示:

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 程序结构的一部分出现,可能看起来像这样:

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

// 在 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 习惯的错误处理的最重要建议:

  • 当错误对开发人员有可操作性时添加堆栈跟踪
  • 对返回的错误采取行动,不要只是将它们冒泡到 main 函数,记录它们,然后忘记它们
  • 保持错误链明确无歧义

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