标签 Channel 下的文章

Rust 布道者Jon Gjengset深度访谈:在 AI 时代,我们该如何思考编程、职业与未来?

本文永久链接 – https://tonybai.com/2025/10/30/jon-gjengset-rust-ai-future

大家好,我是Tony Bai。

他是 MIT 的博士,Rust 社区的知名布道者,《Rust for Rustaceans》作者,前亚马逊首席工程师,现欧洲顶尖 AI 防务公司 Helsing 的首席工程师。Jon Gjengset 的履历,本身就是一部现代软件工程师的精英成长史。

在一场深度访谈中,Gjengset 以其一贯的冷静与深刻,系统性地阐述了他对 Rust 语言的哲学、AI 带来的冲击、工程师的职业发展,乃至在美欧之间做出的人生选择。这既是一场关于技术的对话,更是一次关于如何在日益复杂的软件世界中,保持清醒思考和持续成长的思想盛宴。

Rust 的“预先头疼”哲学

连续九年被评为 Stack Overflow“最受喜爱”的语言,但实际使用率却仍在缓慢爬坡——Rust 的这种“叫好不叫座”现象背后,隐藏着其核心的设计哲学。Gjengset 将其精辟地概括为:“Rust 让你预先头疼 (gives you the headache up front)。”

“你终究需要修复这些 bug。问题只在于,你愿意在编译时修复它们,还是在六个月后,当你的生产系统崩溃时再修复?”

这正是 Rust 与 Go、Java 等 GC 语言在开发者体验上的根本分歧。Rust 通过其著名的借用检查器 (Borrow Checker),在编译期强制开发者思考清楚数据的生命周期和所有权,以换取运行时的极致安全和性能。

这个陡峭的学习曲线,也正是 Rust 最大的“护城河”。Gjengset 认为,学习 Rust 的过程,本质上就是在你的大脑中,强制安装并训练一个强大的静态分析器。这个“脑内借用检查器”一旦形成,其价值将溢出到你使用的所有语言中。

AI 时代的“悲观”乐观主义

当被问及 AI 是否会取代程序员时,Gjengset 展现了一种独特的“悲观的乐观主义”。

  • 悲观之处:“AI 被过度炒作了,因为它无法真正理解”

他认为,当前由 LLM 驱动的 AI,其核心能力是模式复制与推断,而非真正的理解与推理 (understanding and reasoning)

“它们在预测行星的位置上表现出色,但它们无法推导出驱动其运动的底层物理原理。”

他将这一观点延伸到编程领域:AI 擅长编写那些有大量现有范例可供学习的代码,但对于那些需要深刻理解类型系统、并发模型或创造全新抽象的创新性任务,AI 依然力不从心。

  • 乐观之处:“它只是更好的电锯”

Gjengset 引用了一位开发者在 BlueSky 上的比喻,来阐述他对 AI 工具角色的看法:

“因为 Agentic AI 的出现而辞去软件工程师的工作,就像因为电锯的发明而辞去木匠的工作一样,毫无道理。”

AI 并非替代品,而是一个强大的工具,一个“加速器”。它将开发者从重复、繁琐的“模板式”工作中解放出来,让我们有更多时间去从事更高层次的、更具创造性的工作。

工程师的职业选择 —— 从 AWS 到欧洲独角兽

Gjengset 的职业路径,本身就是一部关于工程师如何在巨头与创业公司之间做出选择的生动教材。

  • 在亚马逊:自下而上的变革

在 AWS,他的职责是构建和维护 Rust 的内部构建系统。他强调,Rust 在亚马逊的普及,并非一次自上而下的行政命令,而是一场由一线团队驱动的、自下而上的变革。团队选择 Rust 的核心驱动力,是为了解决 Java/Kotlin 在 GC 停顿下难以优化的尾部延迟 (tail latency) 问题。

  • 离开美国,回归欧洲:一次关于“社会”的选择

2023 年,Gjengset 做出了一个令许多人意外的决定:离开美国,搬回欧洲。他坦言,这并非一个纯粹的职业选择,而是一个更深层次的、关于“社会” (society) 的选择。他的选择,为所有面临跨国职业机会的工程师提供了一个深刻的参考:职业选择,最终是个人价值观的体现。

对 Go 的犀利‘判词’——一场关于权衡的对话

Gjengset 的故事与 Go 有着不解之缘——他最初的博士论文项目原型,正是用 Go 编写的。这段经历,让他对 Go 与 Rust 的哲学差异,有了最为直观和深刻的体悟。

核心批评:“Go 忽略了自 70 年代以来的编程语言研究”

当被问及“Rust 在哪些方面比 Go 更好”时,Gjengset 的回答直截了当,甚至有些“刺耳”:

“哦,Rust 比 Go 更好,因为它有类型系统。这太简单了。Go 在被创造时,选择性地忽略了自 1970 年代以来几乎所有的编程语言研究成果。而 Rust 则决定从这些创新中学习。最终,你得到了一门更复杂,但写起来也有趣得多、表达力强得多的语言。对我来说,这就是最大的区别,也是我不想再用 Go 的原因。”

这句犀利的批评,直指 Go 语言设计的核心权衡:Go 为了追求极致的“简单”,在语言的“表达力”上做出了巨大的妥协。

Gjengset 认为,Rust 强大的类型系统(如 enum、模式匹配、Trait 系统)不仅仅是为了内存安全,更是为了让开发者能够在编译期,就对程序的行为建立起更强大的保证 (Guarantees)。他举例说,在 Rust 中可以利用类型系统创建 CoordinateInFrameA 这样的类型,从而在编译期就杜绝坐标系混用的错误,而这在 Go 中难以轻易实现。

Go 的“nil 指针” vs. Rust 的“编译期保证”

在向一个 Go 团队“推销”Rust 时,Gjengset 会说什么?

“你的应用在运行时因为一个错误而崩溃,这感觉很糟糕吧?在 Rust 中,这种事发生的概率要小得多。”

他认为,Go 开发者引以为傲的“我没有空指针异常”,其实只是将问题转化为了“nil 指针异常”。虽然 Go 通过 if err != nil 强制处理错误,但大量的业务逻辑错误,依然只能在运行时暴露。而 Rust 通过其类型系统和所有权模型,能将更多类别的错误扼杀在编译阶段。

“脑内借用检查器”对 Gopher 的价值

Gjengset 提出的一个极具启发性的观点是,学习 Rust 的思维模式,可以反哺我们的 Go 编程实践。一个内化了“借用检查”思想的 Gopher,会对以下问题更加敏感:

  • 理解 Go 的逃逸分析:当你的“脑内借用检查器”告诉你“从函数返回一个局部变量的引用是不合法的”时,在 Go 的世界里,这意味着“哦,这个变量会逃逸到堆上,我应该思考一下这带来的性能影响”。
  • 编写更健壮的并发代码:虽然 Go 的 channel 提供了强大的并发同步机制,但对于通过指针共享数据等场景,一个关于数据所有权和生命周期的清晰心智模型,能帮助你从根本上避免数据竞争。

小结:给开发者的忠告 —— 跨越语言的智慧

在访谈的最后,Gjengset 还分享了他对 C++ 等语言的看法,这些看法充满了辨证的智慧。

  • 对 C++ 团队:“你已经通过 RAII 获得了部分内存安全,但你的并发安全呢?Rust 可以在编译期静态地排除数据竞争。”
  • 对所有开发者:不要害怕借用检查器。它虽然让你“预先头疼”,但它正在你的大脑中构建一个关于数据流的强大心智模型,这个模型将使你在使用任何语言时,都成为一个更优秀的程序员。

Jon Gjengset 的这场访谈,远不止是一次对 Rust 的“布道”。它是一次关于工程权衡、技术信仰、职业战略和个人价值观的深度剖析。对于 Gopher 而言,这场来自 Rust 阵营的“他山之石”,深刻地揭示了 Go 在诞生之初所做出的核心权衡:用语言表达力的“舍”,换取工程效率和心智负担的“得”。

理解这场对话,将使我们对自己手中的工具,有更清醒的认知和更深刻的敬畏。

资料链接:https://www.youtube.com/watch?v=nOSxuaDgl3s


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


想系统学习Go,构建扎实的知识体系?

我的新书《Go语言第一课》是你的首选。源自2.4万人好评的极客时间专栏,内容全面升级,同步至Go 1.24。首发期有专属五折优惠,不到40元即可入手,扫码即可拥有这本300页的Go语言入门宝典,即刻开启你的Go语言高效学习之旅!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

写出让同事赞不绝口的Go代码:Reddit工程师总结的10条地道Go编程法则

本文永久链接 – https://tonybai.com/2025/10/21/10-go-programming-rules-from-reddit

大家好,我是Tony Bai。

在团队协作中,Code Review是我们与同事交流最频繁的阵地。我们都渴望自己提交的代码能够清晰、健壮,赢得同事的“LGTM”(Looks Good To Me)。但有时,一些看似“吹毛求疵”的风格评论,如“改下变量名”或“这里缩进不对”,会让我们感到困惑。

这些评论真的只是个人偏好吗?来自Reddit的工程师Konrad Reiche在其GoLab 2025的精彩分享《Writing Better Go》中给出了否定的答案。他一针见血地指出:大多数“风格(style)”评论,其本质并非关乎审美,而是关乎如何避免未来的生产环境之痛。

本文将和大家一起解读一下这场分享中提炼出的十条黄金法则。它们是Konrad从数百个Reddit的内部Pull Request中沉淀出的模式与智慧,内容涵盖了从错误处理的艺术、接口设计的哲学,到并发模式的选择、代码的组织与命名等方方面面。掌握它们,将帮助你写出真正让同事赞不绝口的地道Go代码,从根本上提升代码质量与团队协作效率。

法则 01:精准处理错误

Go的if err != nil是其哲学的核心,但如何正确地处理err,却是一门艺术。错误的错误处理方式,是生产环境中许多难以追踪的bug和panic的根源。这里Konrad列出的几种错误处理禁忌,都十分值得我们注意。

禁忌1:静默丢弃 (Silently Discarding)

这是最危险的行为,完全无视了函数可能失败的契约。

// BAD: Silently Discarding
// pickRandom可能会因为输入为空而返回错误,但我们用 _ 彻底忽略了它。
// 如果发生错误,result将是其零值(空字符串),程序可能会在后续逻辑中以意想不到的方式失败。
result, _ := pickRandom(input)
log.Printf("The random choice is: %s", result)

禁忌2:静默忽略 (Silently Ignoring)

比丢弃稍好,但同样危险。我们接收了错误,却没有做任何处理。

// BAD: Silently Ignoring
// 我们检查了err,但if语句块是空的。
// 程序会继续执行,仿佛错误从未发生,但result的值是不可信的。
result, err := pickRandom(input)
if err != nil {
    // An empty block is a sign of trouble.
}
log.Printf("The random choice is: %s", result)

禁忌3:吞噬错误 (Swallowing the Error)

这种模式在错误发生时,向上层调用者返回nil,彻底抹除了错误的痕迹。上层调用者无法知道操作是成功了,还是静默地失败了。

// BAD: Swallowing the Error
result, err := pickRandom(input)
if err != nil {
    return nil // 发生了错误,但我们却向上层返回了一个nil
}

禁忌4:重复报告 (Double Reporting)

一个经典的错误是在一个地方记录日志,然后又将err返回给上层,导致调用链中多处重复记录同一个错误。这会严重干扰日志分析和告警系统。

// BAD: Double Reporting
func process() error {
    result, err := pickRandom(input)
    if err != nil {
        // 在这里记录了日志...
        slog.Error("pickRandom failed", "error", err)
        // ...然后又将错误返回
        return err
    }
    // ...
    return nil
}

func main() {
    if err := process(); err != nil {
        // 调用方又记录了一次日志!
        slog.Error("process failed", "error", err)
    }
}

原则:在一个调用层级,要么处理错误,要么将错误返回给上层去处理,但最好不要两者都做。 通常,只有在程序的最高层(如main函数或HTTP handler)才应该记录日志。

以上的这些“禁忌”虽然糟糕,但通常只会导致逻辑错误或日志混乱。而接下来的这个模式,则会直接导致程序崩溃(panic)。

最危险的坏味道:模棱两可的返回契约

这种模式发生在:一个函数在返回非nil错误的同时,也返回了一个非nil的指针类型的值。

// http.DefaultClient.Do 的文档明确说明,当发生某些错误时(如重定向错误),
// 它会同时返回一个非nil的*http.Response和一个非nil的error。
// 这是一个经过深思熟虑并有文档说明的特例。
//
// 但在绝大多数我们自己编写的代码中,这种模式是极其危险的。

func fetch(req *http.Request) (*http.Response, error) {
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        // 危险!在这里,resp可能是一个非nil的指针,指向一个部分有效或无效的Response。
        // 如果我们直接将它返回...
        return resp, err
    }
    return resp, nil
}

func main() {
    invalid := &http.Request{} // 一个无效的请求
    resp, err := fetch(invalid)
    if err != nil {
        slog.Error("fetch failed", "error", err)

        // 调用者在这里陷入了两难:
        // 1. 我应该信任err,并认为resp是无效的吗?
        // 2. 还是应该检查一下resp是否为nil?

        // 如果调用者不假思索地访问resp...
        slog.Info(resp.Status) // <-- PANIC!
        // 将会引发: panic: runtime error: invalid memory address or nil pointer dereference
    }
}

问题的根源在于,这个fetch函数建立了一个模棱两可的契约。当调用方收到一个非nil的err时,它无法安全地假设另一个返回值resp的状态。如果调用者没有进行额外的nil检查就直接访问resp.Status,程序就会因为空指针解引用而崩溃。

一个健壮的、地道的Go函数,应该为调用者提供一个清晰无比的契约,消除所有猜测:

按照上述实践,我们的fetch函数修改为:

func fetch(req *http.Request) (*http.Response, error) {
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        // 无论resp此时是什么,我们都返回nil,建立清晰的契约
        return nil, err
    }
    return resp, nil
}

通过始终返回nil, err,调用者可以极大地简化其错误处理逻辑,放心地编写代码:

resp, err := fetch(invalid)
if err != nil {
    slog.Error("fetch failed", "error", err)
    // 在这个分支里,我们100%确定resp是nil,无需再做任何检查。
    return
}
// 只有在err为nil时,才安全地访问resp。
slog.Info(resp.Status)

这不仅避免了panic,更重要的是,它降低了代码的认知负荷,让程序变得更简单、更可预测。这,就是地道的Go错误处理之道。

法则 02:不要过早添加接口

在Go的世界里,“接口”是一个极其强大的工具,但它也极易被滥用,成为过度设计的重灾区。演讲者指出了两种最常见的接口误用场景:过早抽象为测试而抽象

过早抽象通常源于开发者从Java等传统面向对象语言带来的思维惯性。在那些语言中,“面向接口编程”是金科玉律,导致开发者习惯于在编写任何具体实现之前,先定义一个接口。例如,在构建一个缓存包时,开发者可能会立刻定义一个Cache接口,并随之创建LFU和LRU等多种实现。

// cache/cache.go
package cache

// 过早定义的接口
type Cache[T any] interface {
    Get(ctx context.Context, key string) (*T, error)
    Set(ctx context.Context, key string, value T) error
}

// LFU 实现...
type LFU[T any] struct { /* ... */ }
// LRU 实现...
type LRU[T any] struct { /* ... */ }

然后,在服务的代码中直接依赖这个cache.Cache接口:

type EligibilityService struct {
    cache   cache.Cache[model.Product] // 依赖于接口
    catalog *product.Catalog
}

这种做法的问题在于,它在需求尚不明确的时候,就引入了一个额外的抽象层。如果你的项目在可预见的未来都只需要一种缓存实现(比如LFU),那么这个Cache接口就是多余的。它不仅没有带来任何好处,反而增加了代码的间接性,使得跳转定义和理解代码变得更加困难。

Go的哲学恰恰相反:先从具体类型开始。 应该直接依赖*cache.LFU:

type EligibilityService struct {
    cache   *cache.LFU[model.Product] // 直接依赖具体类型
    catalog *product.Catalog
}

只有当未来你真正需要多种可互换的实现时(例如,需要根据配置在LFU和LRU之间切换),再回头去提取一个接口也不迟。这个原则可以用一个简单的“试金石”来检验:如果你能不写接口就实现功能,那你可能根本就不需要那个接口。

为测试而抽象是Go中最常见的接口滥用反模式。为了在单元测试中mock一个依赖(比如UserService),开发者常常会为其创建一个接口,仅仅是为了让测试代码能够传入一个mockUserService。这种做法虽然在短期内解决了测试问题,但却用一个“测试的便利性”污染了生产代码的设计,得不偿失。

更地道的做法是优先使用真实实现的测试替身,例如使用google.golang.org/grpc/test/bufconn来测试gRPC服务,而不是为每个gRPC客户端都定义一个接口。

法则 03:优先使用Mutex,Channel用于编排

“Channel很聪明。但在生产环境中,更简单的往往更安全。” 这句话精准地概括了Go并发编程中的一个核心权衡。Go的并发哲学常被新手误解为“无脑用Channel”,但资深的Gopher都明白,对于保护共享状态这一最常见的并发场景,sync.Mutex通常是更简单、更安全、性能也更易于推理的选择。

Channel的强大之处在于其协调和通信的能力,但这份强大也伴随着复杂性。演讲中列举了多种由Channel引发的panic或死锁场景,例如关闭一个已关闭的channel向一个已关闭的channel发送数据、或者在一个无缓冲的channel上发送但没有接收者。这些运行时错误提醒我们,Channel的生命周期和goroutine之间的同步需要精心管理。

一个典型的过度使用Channel的例子,是将一个简单的并发处理任务,构建成一个由多个goroutine、多个channel、select和sync.WaitGroup构成的复杂扇出/扇入(fan-out/fan-in)模式。虽然这种模式在功能上是可行的,但其心智负担远高于一个使用互斥锁的简单替代方案。

// 使用Mutex的简单、安全的并发模式
var mu sync.Mutex
resps := make([]int, 0)

g, ctx := errgroup.WithContext(ctx)
for _, v := range input {
    v := v // capture loop variable
    g.Go(func() error {
        resp, err := process(ctx, v)
        if err != nil {
            return err
        }
        mu.Lock()
        resps = append(resps, resp)
        mu.Unlock()
        return nil
    })
}
if err := g.Wait(); err != nil {
    return 0, err
}
return merge(resps...), nil

在这个例子中,我们使用errgroup来管理goroutine的生命周期和错误传递,并用一个简单的sync.Mutex来保护对共享切片resps的并发写入。这个模式清晰、直接,并且通过go test -race可以轻松检测出潜在的竞态问题。

因此,最佳实践的演进路径应该是

  1. 默认从同步代码开始。
  2. 只有当性能分析(profiling)显示出明确的瓶颈时,才引入goroutine进行并发优化。
  3. 对于简单的共享状态保护,优先使用sync.Mutex和sync.WaitGroup
  4. 当且仅当你的问题涉及到复杂的、需要协调多个goroutine执行流程的“编排”(orchestration)场景时,比如任务分发、信号传递、流式处理或实现CSP模式时,Channel才是那个更闪耀的工具。

法则 04:就近声明

代码的物理位置,深刻地影响着其可读性和可维护性。一个普遍的原则是:代码和它所操作的数据,应该尽可能地放在一起

这个原则贯穿了从包到函数再到代码块的每一个层面。在函数内部,最能体现这一点的就是变量声明的位置。许多来自C等旧语言的开发者,习惯在函数顶部声明所有将要用到的变量。

// BAD: 变量声明远离其使用位置
func fetch(auth auth, client Client, queries []string) ([]string, error) {
    var results []string
    var err error
    var authErr error // authErr的作用域贯穿整个函数

    if auth != nil {
        authErr = auth(func() error {
            results, err = client.PostSearch(queries)
            return err
        })
        if authErr != nil {
            return nil, authErr // 容易出错:这里应该返回authErr还是err?
        }
    } else {
        results, err = client.PostSearch(queries)
        if err != nil {
            return nil, err
        }
    }
    return results, nil
}

这种做法不仅让变量的作用域被人为地拉长,增加了阅读者追踪变量状态的心智负担,还可能引入微妙的bug,如上面代码中authErr和err的混淆。

地道的Go代码,应该在尽可能靠近其首次使用的地方声明变量。 这不仅使代码更紧凑,更重要的是最小化了变量的作用域,减少了变量阴影(shadowing)等潜在问题的发生概率。Go的if err := …; err != nil短声明,正是这一原则的最佳体现。

重构后的fetch函数如下:

// GOOD: 变量在需要时才声明,作用域被最小化
func fetch(auth auth, client Client, queries []string) ([]string, error) {
    if auth != nil {
        var results []string
        // err只在if块内有效
        if err := auth(func() (err error) {
            results, err = client.PostSearch(queries)
            return err
        }); err != nil {
            return nil, err
        }
        return results, nil
    }
    // 如果没有auth,直接调用并返回
    return client.PostSearch(queries)
}

通过将变量声明移入它们所属的逻辑块,代码不仅变得更短,逻辑也更加清晰和安全。这种对作用域的精细控制,是编写可维护Go代码的一项核心技能。

法则 05:避免运行时Panic

在Go中,错误是预期的、可处理的程序行为,而panic则代表了不可恢复的、灾难性的程序错误。因此,编写健壮的代码,一个核心原则就是主动避免可预见的运行时panic。panic最常见的来源有两个:未校验的输入对nil指针的解引用

对于来自系统边界之外的输入,我们必须抱以“零信任”的态度。无论是来自外部的API请求、数据库的查询结果,还是从配置文件读取的数据,都应该在使用前进行严格的校验。

// BAD: 盲目信任输入,可能导致panic
func selectNotifications(req *pb.Request) {
    // 如果 req.Options 为 nil,这里会 panic
    max := req.Options.MaxNotifications
    // 如果 max 大于 req.Notifications 的长度,这里会 panic
    req.Notifications = req.Notifications[:max]
}

// GOOD: 在使用前进行防御性检查
func selectNotifications(req *pb.Request) {
    if req == nil || req.Options == nil {
        return
    }
    max := req.Options.MaxNotifications
    if len(req.Notifications) > int(max) {
        req.Notifications = req.Notifications[:max]
    }
}

对nil指针的解引用是另一个常见的panic来源,尤其是在处理JSON反序列化或Protobuf消息这类包含可选字段的场景。

type FeedItem struct {
    Score *float64 json:"score" // score可能为nil
}

// BAD: 如果item.Score是nil, 对其解引用会立即引发panic
func sumFeedScores(feed *Feed) float64 {
    var total float64
    for _, item := range feed.Items {
        total += *item.Score
    }
    return total
}

最佳的防御策略并非仅仅是在解引用前添加if item.Score != nil检查。更根本的解决方案是通过设计来消除nil的可能性。如果业务逻辑中Score字段不应为空,那么在定义FeedItem时就应该使用值类型float64而不是指针类型*float64。这从类型层面就杜绝了nil指针panic的发生,将潜在的运行时错误,提升为了编译期或反序列化时的明确错误,这正是Go强类型系统优势的体现。

法则 06:最小化缩进

代码的缩进层级,是其逻辑复杂度的最直观体现。深层嵌套的if-else结构,就像一条蜿蜒曲折的隧道,让代码的阅读者极易迷失方向,难以理清核心的“快乐路径”(happy path)。

一个典型的“坏味道”是将所有核心逻辑都包裹在层层if-else的“金字塔”之中:

// BAD: 逻辑嵌套在if-else金字塔中,难以阅读
func processRequest() error {
    if err := doSomething(); err == nil {
        if ok := check(); ok {
            // ... 核心业务逻辑在这里 ...
            process()
            return nil
        } else {
            return errors.New("check failed")
        }
    } else {
        return err
    }
}

在这段代码中,为了找到真正的核心逻辑process(),我们的视线需要穿透两个if层级。

地道的Go代码,推崇使用“防卫语句”(Guard Clauses)和“提前返回”(Return Early)的模式来保持代码结构的扁平化。 这意味着在函数的开头,就优先处理掉所有的错误情况和边界条件,让函数的“快乐路径”代码能够保持在最左侧,不带任何缩进。

重构后的代码如下:

// GOOD: 优先处理错误,保持核心逻辑的扁平化
func processRequest() error {
    if err := doSomething(); err != nil {
        return err
    }
    if !check() {
        return errors.New("check failed")
    }
    // ... 核心业务逻辑在这里,清晰可见 ...
    process()
    return nil
}

这种风格不仅让代码的可读性大大提高,也使得每个逻辑分支更加独立,易于测试和维护。当你发现自己的函数主体被if包裹时,就应该警惕,思考是否能通过反转判断条件和提前返回,来“拉平”你的代码。

法则 07:避免“大杂烩”包和文件

util、common、helpers、misc…… 在许多代码库中,我们都能看到这些命名模糊的包和文件。它们如同厨房里的“杂物抽屉”,堆满了各种看似有用但彼此无关的工具函数、常量和类型。演讲者引用时尚大师Karl Lagerfeld的名言,并戏仿道:

“Util packages are a sign of defeat. You lost control of your code base, so you created some util packages.”
(Util包是失败的标志。你对自己的代码库失去了控制,所以你创建了一些util包。)

这种做法的根本问题在于,它遵循的是按“类型”而非“功能”或“领域”来组织代码。一个处理用户字符串的函数,和一个处理订单字符串的函数,可能仅仅因为它们都“操作字符串”,就被丢进了同一个util包。

这破坏了软件设计中最重要的原则之一:高内聚(High Cohesion)。代码应该和它所影响的东西、和它所属的业务领域,紧密地放在一起。一个user包应该包含所有与用户直接相关的逻辑,一个order包则应该包含所有订单的逻辑。当user包需要一个字符串处理函数时,它应该被定义在user包内部的一个私有函数,或者一个user/stringutil子包中,而不是被“流放”到一个遥远的、通用的util包。

最佳实践是:

  • 按领域或功能来组织和命名你的包。 包名应具有描述性,反映其业务职责,如http, user, order。
  • 追求代码的局部性。 如果一个辅助函数只被user包使用,那它就应该留在user包里。只有当它被多个不同领域的包共享时,才考虑将其提取到一个真正可复用的、具有明确职责的公共包中。

避免创建“杂物抽屉”,能迫使我们更深入地思考代码的结构和归属,从而构建出内聚性更强、更易于理解和维护的系统。

法则 08:按重要性组织声明

Go语言有一个便利的特性:函数在调用前无需预先声明。这与C/C++等语言不同,让我们可以更自由地组织代码。然而,这份自由并不意味着声明的顺序无关紧要。恰恰相反,一个经过深思熟虑的文件布局,是提升代码可读性的关键所在。

地道的Go代码,其文件组织方式应该像一篇写得很好的文章:最重要的信息在前,实现细节在后。 读者在打开一个.go文件时,应该能以“自顶向下”的方式,快速理解这个文件的核心职责和对外暴露的API。

因此,一个通用的最佳实践是,将导出的、面向API的函数放在文件顶部。它们是一个包的“门面”,是其他包与本包交互的入口。紧随其后的,才应该是那些作为实现细节的、未导出的私有辅助函数。

// GOOD: 导出的API函数在前,实现细节在后
package strings

// Trim 是这个包的核心API之一,放在最前面
func Trim(s, cutset string) string {
    // ...
    return trimLeftUnicode(trimRightUnicode(s, cutset), cutset)
}

// trimLeftUnicode 和 trimRightUnicode 是实现细节,放在后面
func trimLeftUnicode(s, cutset string) string { /* ... */ }
func trimRightUnicode(s, cutset string) string { /* ... */ }

这种“按重要性,而非按字母顺序或依赖关系”的排序原则,也同样适用于测试文件。我们应该将核心的测试用例函数(TestXxx)放在文件的前部,而将用于辅助测试的mock结构体或帮助函数放在文件的后部。这能让其他开发者在审查或修改测试时,第一时间就抓住测试的核心意图,而不是被大段的辅助代码分散注意力。

法则 09:精心命名

“计算机科学中只有两件难事:缓存失效和命名。” 这句古老的谚语至今仍然适用。命名不仅是一门艺术,更是深刻影响代码可读性的核心技能。

在Go中,一个常见的“坏味道”是在变量名中添加其类型作为后缀,例如userMap、idStr或injectFn。Go是一门静态类型语言,编译器和IDE都能明确地告诉我们每个变量的类型。在名称中重复这些信息是冗余的,它让名称描述的是“它是什么”,而不是“它包含了什么”

一个好的变量名,应该描述其内容或用途,而非其类型。

// BAD: 名称包含了类型信息
var userMap map[string]*User
var idStr string

// GOOD: 名称描述了内容和用途
var usersByID map[string]*User // 清楚地表明这是一个通过ID索引用户的map
var id string                // 简洁明了

另一个与命名相关的地道实践,是变量名的长度应与其作用域成反比。在一个仅有几行代码的for循环中,使用i、k、v这样的单字母变量是完全可以接受且非常常见的,因为它们的作用域极小,读者一眼就能看明白。

但对于一个在整个函数中都有效的变量,或者一个包级别的变量,就应该使用更具描述性的、完整的名称,以降低读者的认知负含。

最后,在为包和导出的标识符命名时,要时刻思考它们在调用点的可读性。Go的代码在调用时总是以packageName.Identifier的形式出现。因此,好的命名会利用这个上下文来避免重复。例如,consumer.NewHandler就比consumer.NewConsumerHandler更简洁、更地道,因为consumer这个包名已经提供了足够的上下文。

法则 10:为“Why”写文档,而不是“What”

代码本身就能清晰地告诉你它“做了什么”(What)。一行a := b + c的代码,任何有基础的程序员都能看懂。因此,一条注释如果只是在复述这行代码的功能,例如// a equals b plus c,那它就是毫无价值的噪音。

注释和文档的真正价值,在于解释代码存在的“为什么”(Why)。 它们应该为未来的读者(通常就是几个月后的你自己)提供那些无法从代码本身直接看出的、宝贵的上下文。

设想一下这个函数:

// BAD: 这条注释只是在复述代码的功能,没有提供任何额外信息
// Escapes internal double quotes by replacing " with \".
func EscapeDoubleQuotes(s string) string {
    if strings.HasPrefix(s, ") && strings.HasSuffix(s, ") {
        core := strings.TrimPrefix(strings.TrimSuffix(s, "), ")
        escaped := strings.ReplaceAll(core, ", \")
        // ...
        return fmt.Sprintf("%s", escaped)
    }
    return s
}

这段代码的逻辑有些奇怪,读者会困惑于“为什么要做这么复杂的操作?”。现在,我们来看一个更好的注释:

// GOOD: 这条注释解释了这段代码存在的“为什么”,为读者提供了关键的业务背景
// We can sometimes receive a label like ""How-To"" because the frontend
// wraps user-provided labels in quotes, even when the value itself
// contains literal " characters. In this case, we attempt to escape all
// internal double quotes, leaving only the outermost ones unescaped.
func EscapeDoubleQuotes(s string) string {
    // ...
}

有了这段注释,任何未来的维护者都能立刻理解这段代码的意图和它所要处理的特殊边界情况。无论是代码中的注释,还是Pull Request的描述,我们的核心目标都应该是沟通意图,而非机械地描述行为。读者通常能看懂代码在做什么,但他们真正挣扎的,是理解当初为什么要这么写。

小结:成为一名值得信赖的Go工匠

从错误处理的契约清晰度,到接口使用的审慎时机;从Mutex与Channel的选择哲学,到代码组织的局部性原则;再到对命名、缩进和文档意图的精雕细琢——这十条法则,共同描绘出了一位成熟Go工程师的画像。

通过Konrad Reiche的分享,我们得以清晰地看到,那些在Code Review中反复出现的“风格”问题,其背后往往并非个人偏好之争,而是关乎可维护性、可读性和长期健壮性的深刻工程考量。它们的核心目标并非追求代码的完美或审美上的愉悦。

它们的唯一目的,是减少未来团队协作中的摩擦——为未来的代码阅读者、维护者,以及几个月后的你自己,减少理解、修改和调试代码时的痛苦。一份清晰、健壮、易于维护的代码,正是同事们最希望看到的,也是最能体现你专业素养和“工匠精神”的“名片”。

每一个看似“吹毛求疵”的建议,最终都指向了同一个目标:让代码变得显而易见、本质安全、且易于演进。

Code Review的真正意义,也正在于此。它不仅是保证当前功能交付安全的流程,更是整个团队共同学习、传授经验、建立统一技术直觉和品味的宝贵熔炉。当你开始在CR中给出或收到这类有深度的评论,并能理解其背后的“Why”时,你就走在了成为一名值得同事信赖、能够写出传世代码的Go工匠的正确道路上。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


想系统学习Go,构建扎实的知识体系?

我的新书《Go语言第一课》是你的首选。源自2.4万人好评的极客时间专栏,内容全面升级,同步至Go 1.24。首发期有专属五折优惠,不到40元即可入手,扫码即可拥有这本300页的Go语言入门宝典,即刻开启你的Go语言高效学习之旅!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言进阶课 AI原生开发工作流实战 Go语言精进之路1 Go语言精进之路2 Go语言第一课 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com
这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats