Go 语言中的错误处理:为何没有语法支持的决策与反思

88 阅读11分钟

欢迎关注公众号 猩猩程序员

wechat_2025-08-14_125617_342.png

本文来自 go.dev/blog/error-…

Go 语言中最古老且最持久的抱怨之一,便是关于错误处理的冗长性。我们都对以下的代码模式非常熟悉(有些人甚至可能觉得它令人痛苦):

x, err := call()
if err != nil {
        // 处理错误
}

if err != nil 的检查可能如此普遍,以至于它会淹没其余的代码。通常出现在做了大量 API 调用的程序中,而错误处理非常简单,只是返回错误而已。有些程序最终的代码可能是这样:

func printSum(a, b string) error {
    x, err := strconv.Atoi(a)
    if err != nil {
        return err
    }
    y, err := strconv.Atoi(b)
    if err != nil {
        return err
    }
    fmt.Println("result:", x + y)
    return nil
}

在这个 10 行的函数中,只有四行(调用和最后的两行)看起来做了真正的工作。其余的六行则显得杂乱无章。冗长是显而易见的,这也就不奇怪,为什么多年来错误处理一直是我们年度用户调查中最常见的抱怨之一。(曾一度,缺乏泛型的抱怨超过了错误处理,但现在 Go 已支持泛型,错误处理又回到了焦点。)

Go 团队非常重视社区的反馈,因此多年来我们一直在尝试找到一个解决方案,并且广泛征求 Go 社区的意见。

Go 团队的第一次明确尝试是在 2018 年,当时 Russ Cox 将这个问题正式描述为 Go 2 工作的一部分。他概述了基于 Marcel van Lohuizen 的草案设计的一个可能解决方案。该设计使用了一个检查-处理机制,内容相当全面。草案还详细分析了替代方案,并与其他语言的处理方式进行了比较。如果你想知道你提出的特定错误处理方法是否曾被考虑过,可以阅读这份文档!

// 使用提议的检查/处理机制实现 printSum。
func printSum(a, b string) error {
    handle err { return err }
    x := check strconv.Atoi(a)
    y := check strconv.Atoi(b)
    fmt.Println("result:", x + y)
    return nil
}

然而,检查-处理方法被认为过于复杂。大约一年后,在 2019 年,我们跟进了一个大大简化且如今臭名昭著的 try 提案。这个提案基于检查-处理的思想,但将 check 伪关键词替换成了 try 内置函数,省略了处理部分。为了探索 try 内置函数的影响,我们编写了一个工具(tryhard),可以将现有的错误处理代码改写为使用 try。该提案经过了激烈的争论,GitHub 问题中有近 900 条评论。

// 使用提议的 try 机制实现 printSum。
func printSum(a, b string) error {
    // 使用 defer 语句在返回前增强错误信息
    x := try(strconv.Atoi(a))
    y := try(strconv.Atoi(b))
    fmt.Println("result:", x + y)
    return nil
}

然而,try 影响了控制流,当发生错误时,它会从外层函数返回,且可能是从嵌套表达式中返回,这使得控制流变得不明显。许多人对这个提案并不买账。尽管投入了大量精力,我们还是决定放弃这个提案。事后看来,或许引入一个新关键词会更好,因为我们现在可以通过 go.mod 文件和文件特定的指令对语言版本进行细粒度控制。

try 提案带来了深刻的反思,包括 Russ Cox 发布的系列博客文章《思考 Go 提案过程》。其中一个结论是,我们可能通过提交一个几乎已经完成的提案,且几乎没有为社区反馈留出空间,导致了结果的失败。根据《Go 提案过程:大型变更》中的描述:“事后看来,try 是一个足够大的变更,我们发布的设计 […] 应该是第二稿设计,而不是带有实施时间表的提案。”然而,不管是否存在过程上的失败,社区对该提案的情绪非常强烈,普遍反对。

那时我们没有更好的解决方案,并且多年没有追求错误处理的语法变更。尽管如此,社区中的许多人仍然受到了启发,并且我们收到了许多错误处理提案,许多提案相似,有的很有趣,有的无法理解,还有一些不可行。为了跟踪这个不断扩展的领域,Ian Lance Taylor 在一年后创建了一个总览问题,汇总了现有的错误处理改进提案。Go Wiki 也创建了一个页面,用于收集相关的反馈、讨论和文章。独立地,其他人也开始跟踪这些年来的错误处理提案,例如 Sean K. H. Liao 的博客文章《Go 错误处理提案》。

尽管冗长性抱怨依然存在(请见《Go 开发者调查 2024 上半年结果》),因此在 2024 年,经过 Go 团队内部的多次提案后,Ian Lance Taylor 发布了《使用 ? 减少错误处理的样板代码》。这一次,我们打算借用 Rust 中的 ? 运算符。我们希望通过使用已有的机制,结合多年来学到的经验,终于能够取得进展。在一些小型的非正式用户研究中,绝大多数参与者正确猜测了使用 ? 的 Go 代码的含义,这进一步让我们确信这是一个值得尝试的方向。为了看到该变更的影响,Ian 写了一个工具,将普通的 Go 代码转换为使用提议的新语法,我们还在编译器中原型化了这个功能。

// 使用提议的 “?” 语法实现 printSum。
func printSum(a, b string) error {
    x := strconv.Atoi(a) ?
    y := strconv.Atoi(b) ?
    fmt.Println("result:", x + y)
    return nil
}

不幸的是,和其他错误处理提案一样,这个新提案也很快遭到了评论的淹没,许多关于小调整的建议,通常是基于个人偏好的调整。Ian 关闭了该提案,将内容移到了一个讨论中,以促进对话并收集更多反馈。一个稍微修改过的版本得到了更积极的反馈,但广泛的支持依然难以获得。

经过多年的努力,Go 团队提交了三份完整的提案,社区提出了数百个提案(大多数变种的同一主题),但没有一个获得了足够的支持(更不用说压倒性的支持)。我们现在面临的问题是:接下来该如何进行?我们是否应该继续?

我们的答案是:不应该。

更准确地说,我们应该停止尝试解决语法问题,至少在可预见的未来是这样。提案过程为这一决定提供了依据:

提案过程的目标是及时达成对结果的普遍共识。如果提案评审无法在问题跟踪器的讨论中找到普遍共识,通常的结果是提案被拒绝。

此外:

即使评审无法找到共识,但显然提案不应被直接拒绝时,提案的决策权将转交给 Go 架构师 […], 他们会审查讨论并努力在自己之间达成共识。

没有一个错误处理提案达成共识,因此它们都被拒绝了。即使是 Google 的 Go 团队的资深成员,也没有就最佳的前进方向达成一致(或许以后会改变)。但没有强有力的共识,我们无法合理推进。

有理由支持保持现状:

如果 Go 在早期引入了专门的错误处理语法,今天很少有人会争论这个问题。但现在我们已经走过了 15 年,这个机会已经错过,而 Go 目前的错误处理方式完全可行,即使它有时显得冗长。

从另一个角度看,假设我们今天找到了完美的解决方案。引入它只会导致另一群不满意的用户(支持变更的那一群)和另一群更喜欢现状的用户。我们在引入泛型时遇到过类似的情况,但有一个重要的区别:今天没有人被迫使用泛型,而优秀的泛型库使得用户几乎可以忽略它们的存在。

不引入额外语法符合 Go 的设计原则之一:不要提供多种方式做同一件事。这个规则在“高频”领域有例外,比如赋值。具有讽刺意味的是,短变量声明 (:=) 最初是为了处理错误检查引入的——如果没有

它,连续的错误检查就需要为每个检查使用不同的变量名。

回到实际的错误处理代码,如果错误真正得到了处理,冗长性就会被忽略。例如,添加更多的错误信息,如堆栈跟踪,可以减少样板代码:

func printSum(a, b string) error {
    x, err := strconv.Atoi(a)
    if err != nil {
        return fmt.Errorf("invalid integer: %q", a)
    }
    y, err := strconv.Atoi(b)
    if err != nil {
        return fmt.Errorf("invalid integer: %q", b)
    }
    fmt.Println("result:", x + y)
    return nil
}

新的标准库功能,如 cmp.Or,也可以减少错误处理样板代码:

func printSum(a, b string) error {
    x, err1 := strconv.Atoi(a)
    y, err2 := strconv.Atoi(b)
    if err := cmp.Or(err1, err2); err != nil {
        return err
    }
    fmt.Println("result:", x + y)
    return nil
}

编写、阅读和调试代码是不同的活动。编写重复的错误检查可能很繁琐,但如今的 IDE 和 LLM 辅助的代码补全工具可以使这变得更容易。冗长性在阅读代码时更为显著,但工具可以帮助隐藏错误处理代码;例如,带有 Go 语言设置的 IDE 可以提供一个开关来隐藏错误处理代码。

在调试时,能够快速添加 println 或为设置断点提供专用的行或源位置非常有帮助。如果所有的错误处理逻辑都隐藏在检查、try? 后面,代码可能必须先改成普通的 if 语句,这会增加调试的复杂性,甚至可能引入微妙的错误。

最后,尽管提出新的错误处理语法的想法很便宜,但设计出一个经过严格审查的好方案并不容易。语言变更的成本很高,Go 团队相对较小,还有很多其他的优先事项需要处理。(这些点可能会发生变化:优先级可以调整,团队规模可以变化。)

最后,我们最近有机会参加了 2025 年 Google Cloud Next 大会,Go 团队也设立了展位,并举办了一个小型的 Go Meetup。我们向每一位 Go 用户询问时,他们都坚决表示不应该改变语言来改进错误处理。许多人提到,从其他语言转到 Go 时,Go 缺乏专门的错误处理支持最为明显。随着熟悉度的提高,编写更具 Go 风格的代码,错误处理问题就变得不那么重要了。当然,这个群体的样本量并不大,不能代表整个用户群体,但这些人的反馈与我们在 GitHub 上看到的反馈不同,提供了另一种数据点。

当然,也有支持变更的有效论据:

在我们的用户调查中,缺乏更好错误处理支持仍然是最主要的抱怨。如果 Go 团队真的重视用户反馈,我们最终应该对这个问题做些改进。(尽管似乎并没有强烈支持进行语言层面的变更。)

或许,我们过于专注于减少字符数量的问题。更好的方法可能是通过一个关键词使默认的错误处理变得非常明显,同时仍然去除冗余的样板代码(err != nil)。这样的做法可能会让读者(代码审阅者)更容易看出错误已经被处理,而不用“再看一遍”,从而提高代码质量和安全性。这将让我们回到最初的检查和处理的思想。

我们现在并不完全知道问题到底是在于错误检查的简单语法冗长,还是在于良好的错误处理:构造对 API 和开发人员以及最终用户都有意义的错误。这是我们希望更深入研究的方向。

尽管如此,迄今为止,所有针对错误处理的尝试都未获得足够的支持。如果我们诚实地评估现状,我们只能承认,我们既没有对问题有共同的理解,也没有达成共识。基于此,我们做出以下务实的决策:

在可预见的未来,Go 团队将停止追求错误处理语法的语言变更。我们也将关闭所有关于错误处理语法的提案,而不做进一步的调查。

社区为探索、讨论和辩论这些问题付出了巨大努力。虽然这些努力没有导致语法上的任何变更,但它们却促成了 Go 语言和我们过程中的许多其他改进。也许在未来某个时刻,关于错误处理的更清晰的解决方案会浮现出来。直到那时,我们期待将这些宝贵的热情转向新的机会,让 Go 变得对每个人更好。

感谢大家!

欢迎关注公众号 猩猩程序员