2025年6月,Go团队在官方博客发表了一篇题为"[On | No] syntactic support for error handling"的文章,宣布了一个意味深长的决定:停止追求错误处理的语法级改进,关闭所有相关的开放提案。这不是一次普通的功能延期,而是Go语言诞生十六年来,在错误处理这个核心议题上的彻底认输。

这背后是一场持续十年的拉锯战。三次正式提案、数百个社区方案、近千条GitHub评论,最终却没有一个能达成共识。为什么一个看似简单的"减少if err != nil样板代码"问题,会演变成编程语言设计史上最持久的争议之一?

一切始于一个设计选择

2007年,Rob Pike、Robert Griesemer和Ken Thompson在Google开始设计一门新语言。他们观察到的现状是:C++和Java过于复杂,编译缓慢;Python和JavaScript虽然灵活,但缺乏类型安全;而并发编程在当时几乎没有语言提供真正好用的支持。

在这些目标之外,他们对"错误处理"这个话题有一个明确的立场。Go FAQ中写道:

“We believe that coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional.”

这个观点并非空穴来风。Go的三位设计者都来自贝尔实验室,亲历了C语言和Unix的黄金时代。在他们看来,文件打开失败不是"异常",而是完全正常的、程序应该主动处理的情况。try-catch机制不仅让控制流变得难以追踪,更重要的是它改变了程序员的思维习惯——把本该认真对待的错误变成了"随便catch一下就好"的东西。

Go给出的答案是:错误是值(Errors are values)。

f, err := os.Open("filename.ext")
if err != nil {
    // handle error
}

这四行代码成为Go程序中最常见的模式。它的哲学很清晰:错误处理不应该隐藏在某个魔法关键字背后,它应该像其他任何代码一样显式、可见、可控。

当"简洁"变成"冗余"

问题在于,这个"显式"的选择带来了一个副作用:代码膨胀。

一个典型的Go函数往往长这样:

func CopyFile(src, dst string) error {
    r, err := os.Open(src)
    if err != nil {
        return err
    }
    defer r.Close()

    w, err := os.Create(dst)
    if err != nil {
        return err
    }
    defer w.Close()

    _, err = io.Copy(w, r)
    if err != nil {
        return err
    }
    return w.Close()
}

有人统计过,在大型Go项目中,errifreturnnil这四个词的出现频率超过了funcstring的总和。Ten lines of code, four of them are error handling boilerplate——这句话成了Go社区的流行吐槽。

Go开发者调查的结果也印证了这一点:错误处理连续多年位居开发者痛点榜首。即使在泛型问题解决后,它依然是用户抱怨最多的领域。问题是,抱怨归抱怨,怎么改?

2018年:check与handle

第一次严肃的尝试出现在2018年。Russ Cox在Go 2的规划中正式提出了解决错误处理冗余的问题,核心思路来自Marcel van Lohuizen的draft design:引入两个新关键字checkhandle

func CopyFile(src, dst string) error {
    handle err { return err }
    
    r := check os.Open(src)
    defer r.Close()
    
    w := check os.Create(dst)
    defer func() {
        w.Close()
        if err != nil {
            os.Remove(dst)
        }
    }()
    
    check io.Copy(w, r)
    check w.Close()
    return nil
}

check会自动检查最后一个返回值是否为nil,如果不是则触发错误处理器。handle则定义了错误处理的逻辑,可以被defer语句访问。这个设计确实消除了大量重复的if语句,但社区的反应却相当复杂。

批评者指出,handle的语义过于复杂。它像一个隐藏在暗处的幽灵,你不知道它何时被触发,也不知道它的作用域边界在哪里。更麻烦的是,handle与Go已有的defer机制存在功能重叠——两者都可以在函数返回时执行代码,这让控制流变得难以推理。

Go团队最终没有推进这个提案。他们的结论是:太复杂了。

2019年:try的内测与翻车

吸取了check/handle的教训,Go团队在2019年提出了一个大幅简化的方案:try

try是一个内置函数(built-in function),不是新关键字,这保证了向后兼容性。它的语义极其简单:

// Before
f, err := os.Open(filename)
if err != nil {
    return err
}

// After
f := try(os.Open(filename))

如果os.Open返回非nil的error,try会自动把这个错误赋值给外层函数的error返回值,然后立即return。一行代码搞定四行样板。

这个提案看起来简洁优雅,但它踩中了一个致命的雷区:隐式控制流

Go语言从诞生之初就有一条铁律:控制流必须是显式的。你不会在一个表达式的中间突然跳到函数末尾,除非你明确写了returnpanic。但try打破了这条规则——它看起来只是一个普通的函数调用,却能在幕后悄悄改变程序的执行路径。

争议迅速爆发。在GitHub issue上,讨论帖接近900条评论。反对者的核心论点是:

  • 可读性灾难:在复杂表达式中,try可能嵌套多层,读者完全无法判断哪些调用可能触发提前返回。
  • 调试困难:如果你想在某次错误返回前打印日志或设置断点,现在你必须把代码改回显式的if语句。
  • 与错误包装冲突:如果你想给返回的错误添加上下文信息,try本身不提供这个能力,你还得用defer来处理,反而更复杂。

Robert Griesemer在最终关闭提案时写道:“We have obviously underestimated the possible reaction towards it."(我们显然低估了社区对这个提案的反应。)

2024-2025年:向Rust取经

try提案失败后,Go团队沉默了几年。但开发者调查的数据没有变——错误处理依然是头号痛点。于是,Ian Lance Taylor在2024年提出了新的方案:借用Rust的?操作符。

Rust的?是出了名的优雅:

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();
    File::open("hello.txt")?.read_to_string(&mut username)?;
    Ok(username)
}

?会在遇到错误时提前返回,但它有一个关键特性:它是一个后缀操作符,明确标记在可能失败的表达式后面。这让控制流的变化一目了然。

Go的版本长这样:

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

Go团队做了一些非正式的用户研究,发现大多数人在看到?时都能正确猜到它的含义——Rust的普及功不可没。但问题在于,Go和Rust是两门设计哲学截然不同的语言。

Go有一条重要的设计原则:不要为同一件事提供多种方式(Do not provide multiple ways of doing the same thing)。如果引入?,那么if err != nil?就变成了两种并存的错误处理方式。新代码用什么?旧代码要不要改?代码审查时怎么统一标准?

更深层的问题是,Rust的?能优雅运作,是因为Rust有Result<T, E>类型和强大的类型系统支撑。Go没有sum types,没有泛型错误类型,?能做的事情实际上相当有限。

这个提案的结局和前两次一样:没有达成共识,最终被放弃。

为什么每一次都失败?

回顾三次尝试,它们的失败原因看似各不相同,但深挖下去,都指向同一个问题:Go的设计哲学与"简洁的错误处理语法"存在根本性冲突

冲突一:显式 vs 隐式

Go的灵魂是"显式”。你看到什么,代码就做什么。if err != nil虽然冗长,但它的语义完全透明——任何人,哪怕是Go新手,都能一眼看懂。

而try、check、?这些方案,本质上都是在用"隐式"换取"简洁"。它们把错误检查的逻辑藏到了语法糖背后。Go老用户对此有一种本能的抗拒:这是在背叛Go的核心价值观。

冲突二:统一 vs 多样

Go的成功很大程度上归功于它的"固执"。一门语言,一种风格,大家都这么写,阅读别人的代码就不费劲。这种统一性在大团队协作中价值巨大。

但如果引入新的错误处理语法,这种统一就会破裂。代码库中会出现两种风格并存的混乱局面,代码审查和风格指南都会变得更加复杂。Go团队不想冒这个险。

冲突三:简单 vs 强大

这里有一个悖论:如果错误处理语法足够简单,它就不够强大;如果足够强大,它就不够简单

try够简单,但它不能处理错误包装、不能添加上下文、不能区分不同类型的错误。check/handle够强大,但它的语义复杂到连Go团队自己都觉得难以维护。

Rust走了另一条路:它用强大的类型系统解决了这个问题。Result<T, E>是泛型,?可以自动进行类型转换,你还可以用map_err等方法定制错误处理逻辑。但这一切的前提是Rust有一个比Go复杂得多的类型系统——而Go的设计者从一开始就决定不要这种复杂度。

其他语言的答案

在编程语言的世界里,错误处理从来不是新问题。不同的语言给出了不同的答案,每个答案背后都是一套完整的设计哲学。

Java选择了checked exceptions:编译器强制你处理每个可能抛出的异常,用throws关键字声明它们。这个设计在理论上很美好,但实际效果是——开发者为了省事,要么写空的catch块,要么把所有异常都包装成RuntimeException抛出去。checked exceptions最终成了Java最受争议的特性之一。

Python和Ruby选择了unchecked exceptions:异常可以随时抛出,你可以选择捕获也可以选择忽略。这种方式写代码很爽,但调试时是噩梦——你永远不知道一个函数调用背后藏着多少可能炸掉的地方。

Haskell和OCaml选择了monadMaybeEither类型配合do-notation,实现了优雅的错误传播。但monad的概念门槛太高,大多数程序员看到这个词就头疼。

Rust选择了Result类型和?操作符:在简洁性和安全性之间找到了一个不错的平衡点。但它的代价是一个相对复杂的类型系统,以及较慢的编译速度。

Go选择了最朴素的方式:错误作为普通值返回,用显式的if语句检查。这种方式任何人都能理解,但它牺牲了代码的简洁性。

没有完美答案。每个选择都是在取舍,每个取舍都对应一种价值观。

2025年的终局

在2025年的那篇博客文章中,Go团队给出了最终的判决:

“After so many years of trying, with three full-fledged proposals by the Go team and literally hundreds (!) of community proposals, most of them variations on a theme, all of which failed to attract sufficient (let alone overwhelming) support, the question we now face is: how to proceed? Should we proceed at all?”

We think not.

他们列举了维持现状的理由:

  • Go已经存在了15年,现在的错误处理方式虽然冗长,但它是"好用的"。如果当初设计时就加入了语法糖,今天没人会抱怨;但现在改变,只会让一部分人高兴,让另一部分人不满。
  • 不添加新语法符合Go的设计原则:不要提供多种方式做同一件事。
  • 在真正复杂的错误处理场景中,样板代码的比例并没有那么高——因为你需要添加上下文、包装错误、记录日志,这些if err != nil只是其中的小部分。
  • IDE和AI工具的进步让写样板代码变得不那么痛苦。

还有一个有趣的观察:Go团队在Google Cloud Next 2025上询问现场的Go用户,每个人都表示不希望改变错误处理语法。这和GitHub上的讨论形成了鲜明对比——在线上发声的大多是新用户或不满者,而真正把Go用得顺风顺水的老用户,反而喜欢现状的稳定。

这不是技术问题

Go错误处理争议的本质,不是"哪个方案更好"的技术问题,而是"我们更在乎什么"的价值观问题。

想简洁?用try或?,但接受隐式控制流。想统一?维持现状,但忍受代码膨胀。想强大?引入类型系统,但接受语言复杂度上升。

你可以拥有两个,但不能拥有三个。

Go选择了"显式"和"统一",牺牲了"简洁"。这个选择在2007年做出,在2025年被再次确认。它不是一个错误,而是一个设计决策——一个很多人不喜欢,但足够多的人接受,以至于能够支撑一个庞大生态的决策。

编程语言设计从来不是寻找最优解,而是在无数约束条件下寻找一个足够好的妥协。Go的错误处理,就是这种妥协的极致体现:它不漂亮,但它工作;它有缺陷,但这个缺陷在大多数场景下可以接受。

十年争议,最终没有赢家。或者换个角度看,Go本身才是赢家——它坚持了自己的设计哲学,没有被社区的压力带着走。在一个用户导向的时代,这种固执反而是稀缺品。

参考

  1. Griesemer, R. (2025). [On | No] syntactic support for error handling. The Go Blog.
  2. Pike, R. (2015). Errors are values. The Go Blog.
  3. Go Team. (2018). Go 2 Draft Designs: Error Handling.
  4. Griesemer, R. (2019). Proposal: A built-in Go error check function, “try”. GitHub Issue #32437.
  5. Taylor, I. L. (2024). proposal: spec: reduce error handling boilerplate using “?”. GitHub Issue #71203.
  6. Go Team. (2025). Results from the 2025 Go Developer Survey. The Go Blog.
  7. Rust Documentation. The ? operator for error propagation. The Rust Programming Language.
  8. Go Team. Frequently Asked Questions (FAQ). go.dev.
  9. Cheney, D. (2011). Error handling and Go. The Go Blog.
  10. Sean K. H. Liao. (2020). Go error handling proposals. seankhliao.com.