Go 博客

[ 有关 | 关于 ] 错误处理的语法支持

Robert Griesemer
2025 年 6 月 3 日

关于 Go 语言,最古老也最持续的抱怨之一就是错误处理的冗长。我们都非常熟悉(有些人可能会说痛苦地熟悉)这种代码模式:

x, err := call()
if err != nil {
        // handle err
}

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
}

在这个函数体中,十行代码中只有四行(调用和最后两行)似乎在做实际的工作。其余六行则显得多余。冗长是真实存在的,因此毫不奇怪,关于错误处理的抱怨多年来一直位居我们年度用户调查榜首。(有一段时间,缺少泛型超越了对错误处理的抱怨,但现在 Go 支持泛型,错误处理又回到了榜首。)

Go 团队认真对待社区反馈,因此多年来我们一直与 Go 社区的意见一起,努力寻找解决此问题的方法。

Go 团队的第一次明确尝试可以追溯到 2018 年,当时 Russ Cox 正式描述了这个问题,作为我们当时所称的 Go 2 工作的一部分。他根据 Marcel van Lohuizen 的草案设计概述了一个可能的解决方案。该设计基于 checkhandle 机制,并且相当全面。该草案包括对替代解决方案的详细分析,包括与其他语言所采取方法的比较。如果您想知道您特定的错误处理想法是否曾被考虑过,请阅读此文档!

// printSum implementation using the proposed check/handle mechanism.
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
}

checkhandle 方法被认为过于复杂,大约一年后,在 2019 年,我们推出了大幅简化且现在臭名昭著的try 提案。它基于 checkhandle 的思想,但 check 伪关键字变成了 try 内置函数,并且省略了 handle 部分。为了探索 try 内置函数的影响,我们编写了一个简单的工具(tryhard),它使用 try 重写了现有的错误处理代码。该提案经过了激烈的争论,在 GitHub issue 上接近 900 条评论。

// printSum implementation using the proposed try mechanism.
func printSum(a, b string) error {
    // use a defer statement to augment errors before returning
    x := try(strconv.Atoi(a))
    y := try(strconv.Atoi(b))
    fmt.Println("result:", x + y)
    return nil
}

然而,try 通过在出错时从封闭函数返回来影响控制流,并且可能从深度嵌套的表达式中这样做,从而隐藏了这种控制流。这使得该提案对许多人来说难以接受,尽管我们对该提案投入了大量精力,但我们决定放弃这项工作。回想起来,引入一个新关键字可能更好,因为现在我们可以通过 go.mod 文件和特定于文件的指令来对语言版本进行细粒度控制。将 try 的使用限制在赋值和语句中可能缓解了一些其他担忧。Jimmy Frasche 的最近提案,实质上回到了最初的 checkhandle 设计,并解决了该设计的一些缺点,正朝着这个方向发展。

try 提案的余波导致了许多深刻的反思,包括 Russ Cox 的一系列博客文章:《思考 Go 提案流程》。一个结论是,我们很可能通过提出一个几乎完全成熟的提案,而没有给社区反馈留下太多空间,并设定一个“威胁性”的实现时间表,从而降低了获得更好结果的机会。根据《Go 提案流程:重大变更》:“回想起来,try 是一个足够大的变更,我们发布的新设计 […] 应该是一个第二稿设计,而不是一个带有实现时间表的提案”。但无论在这种情况下可能存在的流程和沟通失败,用户对该提案的情绪都非常强烈地不赞成。

当时我们没有更好的解决方案,并且几年内没有继续进行错误处理的语法更改。然而,社区中很多人受到了启发,我们收到了源源不断的错误处理提案,其中许多彼此非常相似,有些很有趣,有些难以理解,还有一些不可行。为了跟踪不断扩大的局面,又过了一年,Ian Lance Taylor 创建了一个总括议题,总结了改进错误处理提议的当前状态。创建了一个Go Wiki 来收集相关的反馈、讨论和文章。独立地,其他人多年来也开始跟踪许多错误处理提案。看到所有这些提案的庞大数量真是令人惊叹,例如在 Sean K. H. Liao 关于“Go 错误处理提案”的博客文章中。

关于错误处理冗长的抱怨持续存在(参见Go 开发者调查 2024 年上半年结果),因此,在一系列日益完善的 Go 团队内部提案之后,Ian Lance Taylor 于 2024 年发布了“使用 ? 减少错误处理样板”。这次的想法是借鉴Rust 中实现的一种构造,特别是? 运算符。我们希望通过依赖一种使用既定符号的现有机制,并考虑到我们多年来的经验教训,我们应该能够最终取得一些进展。在小型非正式用户研究中,当向程序员展示使用 ? 的 Go 代码时,绝大多数参与者正确地猜出了代码的含义,这进一步说服我们再试一次。为了能够看到这一变化的影响,Ian 编写了一个工具,将普通的 Go 代码转换为使用提议的新语法的代码,我们还在编译器中原型化了该功能。

// printSum implementation using the proposed "?" statements.
func printSum(a, b string) error {
    x := strconv.Atoi(a) ?
    y := strconv.Atoi(b) ?
    fmt.Println("result:", x + y)
    return nil
}

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

经过这么多年的尝试,Go 团队提出了三个成熟的提案,以及社区的数百 (!) 个提案,其中大多数都是主题的变体,但都未能获得足够的(更不用说压倒性的)支持。我们现在面临的问题是:该如何继续?我们应该继续下去吗?

我们认为不应该。

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

提案流程的目标是在及时的情况下就结果达成普遍共识。如果在问题跟踪器的讨论中,提案审查无法确定普遍共识,通常的结果是提案被拒绝。

此外

可能会出现这种情况:提案审查未能达成普遍共识,但很明显该提案不应被直接拒绝。[…]如果提案审查小组无法确定共识或提案的下一步,则向前推进的决定将交给 Go 架构师 […],他们将审查讨论并旨在在他们之间达成共识。

所有错误处理提案都没有达成任何接近共识的意见,因此它们都被拒绝了。即使是 Google Go 团队中最资深的成员目前也无法就最佳前进道路达成一致意见(也许将来会改变)。但如果没有强烈的共识,我们无法合理地向前推进。

有一些支持现状的有效论点:

  • 如果 Go 语言早早引入了针对错误处理的特定语法糖,今天很少有人会为此争论。但我们已经走过了 15 年,机会已经过去,Go 拥有一种完美的错误处理方式,即使有时可能显得冗长。

  • 换个角度看,假设我们今天找到了完美的解决方案。将其纳入语言只会将一群不满意的用户(支持变革的那群)转变为另一群(偏爱现状的那群)。当我们决定向语言添加泛型时,我们也处于类似的情况,尽管有一个重要的区别:今天没有人被迫使用泛型,而且优秀的泛型库编写得很好,以至于用户大多可以忽略它们是泛型的事实,这得益于类型推断。相反,如果向语言添加新的错误处理语法结构,几乎每个人都将需要开始使用它,否则他们的代码将变得不地道。

  • 不添加额外语法与 Go 的设计规则之一相符:不要提供多种做同样事情的方法。在高“流量”领域存在此规则的例外:赋值就是一例。讽刺的是,在短变量声明:=)中重新声明变量的能力是为了解决因错误处理而产生的问题:如果没有重新声明,错误检查序列需要为每次检查使用不同名称的 err 变量(或额外的独立变量声明)。那时,一个更好的解决方案可能是为错误处理提供更多的语法支持。那样,可能就不需要重新声明规则了,随着它的消失,各种相关的复杂问题也将不复存在。

  • 回到实际的错误处理代码,如果错误真正*得到处理*,冗长就会退居次要位置。良好的错误处理通常需要向错误添加额外信息。例如,用户调查中一个反复出现的评论是关于错误缺乏堆栈跟踪。这可以通过生成并返回增强错误的辅助函数来解决。在这个( admittedly contrived)示例中,样板代码的相对数量要小得多:

    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
    }
    
  • 新的标准库功能也可以帮助减少错误处理样板,这与 Rob Pike 2015 年的博客文章《错误是值》非常一致。例如,在某些情况下,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 或在调试器中为设置断点提供专用行或源位置很有帮助。当已经有专用的 if 语句时,这很容易。但是,如果所有错误处理逻辑都隐藏在 checktry? 后面,则可能需要先将代码更改为普通的 if 语句,这会使调试复杂化,甚至可能引入细微的错误。

  • 还有一些实际考虑:想出新的错误处理语法很简单;因此社区涌现出大量提案。想出能经受住严格审查的好解决方案:就没那么容易了。需要协同努力才能正确设计语言变更并实际实现它。真正的成本还在后面:所有需要更改的代码、需要更新的文档、需要调整的工具。综合来看,语言变更非常昂贵,Go 团队相对较小,还有很多其他优先事项需要解决。(这些后一点可能会改变:优先事项可能会转移,团队规模可能会增减。)

  • 最后,我们中的一些人最近有机会参加了Google Cloud Next 2025,Go 团队在那里设有一个展位,我们还举办了一个小型的 Go Meetup。我们有机会询问的每一位 Go 用户都坚决表示我们不应该改变语言以更好地处理错误。许多人提到,Go 中缺乏特定的错误处理支持在刚从其他具有该支持的语言转过来时最明显。随着人们变得更流利并编写更多地道的 Go 代码,这个问题变得不那么重要了。当然,这不足以代表所有 Go 用户,但这可能是一群与我们在 GitHub 上看到的不同的用户,他们的反馈作为另一个数据点。

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

  • 缺乏更好的错误处理支持仍然是我们用户调查中最主要的抱怨。如果 Go 团队确实认真对待用户反馈,我们最终应该对此做些什么。(尽管似乎也没有压倒性的支持语言更改。)

  • 也许过于关注减少字符数是错误的。更好的方法可能是通过关键字使默认错误处理高度可见,同时仍然消除样板代码(err != nil)。这种方法可能使读者(代码审查员!)更容易看到错误已处理,而无需“看两遍”,从而提高代码质量和安全性。这将使我们回到 checkhandle 的起点。

  • 我们不确定这个问题在多大程度上是错误检查的直接语法冗长,还是良好错误处理的冗长:构建对 API 有用并对开发人员和最终用户都有意义的错误。这是我们希望更深入研究的问题。

然而,迄今为止,所有解决错误处理的尝试都未能获得足够的关注。如果我们诚实地评估我们所处的位置,我们只能承认我们既没有对问题达成共识,也没有都同意一开始就存在问题。考虑到这一点,我们做出了以下务实的决定:

在可预见的未来,Go 团队将停止寻求错误处理的语法语言变更。我们还将关闭所有主要关注错误处理语法的开放和传入提案,不再进行进一步调查。

社区为探讨、讨论和辩论这些问题付出了巨大的努力。虽然这可能没有导致错误处理语法的任何改变,但这些努力促成了 Go 语言和我们流程的许多其他改进。也许,在未来的某个时候,关于错误处理的更清晰的图景将会出现。在此之前,我们期待将这份巨大的热情集中在新的机会上,使 Go 对每个人都变得更好。

谢谢!

下一篇文章: 泛型接口
上一篇文章: Go 加密安全审计
博客索引