关于Go的错误处理的哲学思考

146 阅读8分钟

Go语言臭名昭著的错误处理已经引起了编程语言外部人士的注意,它经常被吹捧为该语言最值得怀疑的设计决策之一。如果你查看Github上任何一个用Go编写的项目,几乎可以保证你会看到这一行比代码库中的其他东西更频繁。

if err != nil {
    return err
}

尽管对于那些刚接触这门语言的人来说,这可能是多余的,也是不必要的,但Go中的错误之所以被当作一等公民(值),在编程语言理论中有着根深蒂固的历史,也是Go作为一种语言本身的主要目标。为了改变或改进Go处理错误的方式,人们做出了许多努力,但到目前为止,有一个提议胜过了所有其他提议。

-抛开if err != nil!

Go的错误哲学

Go的错误处理理念迫使开发人员将错误作为他们编写的大多数函数的第一类公民。即使您使用类似的方法忽略一个错误。

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

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

大多数浏览器或集成开发环境都会发现你忽略了一个错误,而且在代码审查时,你的队友肯定会看到这个错误。然而,在其他语言中,可能不清楚你的代码没有在try catch 代码块中处理一个潜在的异常,对处理你的控制流完全不透明。

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

  1. 没有隐藏的控制流
  2. 没有出乎意料的uncaught exception 日志炸毁你的终端(除了通过恐慌的实际程序崩溃之外)
  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)
}

如果出了问题,err != nil 在调用criticalDatabaseOperation() ,我们除了记录这个错误外,并没有对它做任何处理!我们可能有数据损坏或其他意外的问题,我们没有智能地处理,要么重试函数调用,要么取消进一步的程序流,或者在最坏的情况下,关闭程序。Go并不神奇,不能把你从这些情况中拯救出来。Go只提供了一个标准的方法来返回和使用错误的值,但你还是要自己想办法处理错误。

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

在像Javascript Node.js运行时,你可以把你的程序结构化,称为抛出exceptions

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

如果这些函数中的任何一个出现了错误,错误的堆栈跟踪将在运行时弹出,并记录到控制台,但没有明确的、程序化的处理出错的原因。

你的criticalOperation 函数不需要显式地处理错误流,因为任何在尝试块中发生的异常都会在运行时与出错的堆栈跟踪一起被提出来。 基于异常的语言的一个好处是,与Go相比,即使是未处理的异常,如果发生,也会在运行时通过堆栈跟踪提出来。在Go中,有可能完全不处理一个关键的错误,这可以说是更糟糕的。Go为你提供了对错误处理的完全控制,但也提供了完全的责任

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

为什么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 的顺序,这是不透明的,很难推理,而且会鼓励一些懒惰的编程习惯。在函数式编程的行话中,这被称为花哨的术语:违反参照物透明度。这篇来自微软工程博客2005年的博文在今天仍然适用,即:

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

Go的错误语法的好处

易于创建可操作的错误链

模式if err != nil 的一个超级力量是它允许轻松的错误链遍历程序的层次结构,一直到需要处理的地方。例如,一个由程序的main 函数处理的常见围棋错误可能如下所示。

[2020-07-05-9:00] 错误。无法创建用户:无法检查用户是否已经存在于DB中:无法建立数据库连接:没有互联网

上述错误(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中编写习惯性错误处理的最重要的建议。

  1. 当你的错误对开发者来说是可操作的时候,添加堆栈跟踪。

  2. 对返回的错误进行处理,不要只是把它们泡在main上,记录下来,然后忘记它们。

  3. 保持你的错误链不含糊。

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

"if ...; err != nil "是你在写go时可能会输入的东西。我不认为这是个优点或缺点。它能完成工作,易于理解,而且当程序失败时,它使程序员有能力做正确的事情。剩下的就看你自己了。

关键阅读