VINO/WANG返回博客 ←

BLOG / POST

Go 编码哲学

写简单、直接、符合 Go 惯用法的代码:接口按需发现、零值可用、泛型克制、错误与并发显式表达的十一条编码原则。

  • go
  • code-style

写简单、直接、符合 Go 惯用法的代码。

1. 从具体实现开始,发现接口

Don’t design with interfaces, discover them.

先写具体类型和函数。当调用方确实需要统一处理不同实现,或隔离外部依赖时,再定义接口。

  • 接口定义在使用它的地方。
  • 只包含调用方真正需要的方法。
  • 不为每个结构体配一个接口。
  • 不为假想的未来需求建立框架。

2. 接口越小,适用范围越广

The bigger the interface, the weaker the abstraction.

一个接口表达一种清楚的能力:

type streamEncoder interface {
    event(Event)
    finish()
}

优先组合小能力。只有一个可替换行为时,函数参数或函数字段通常就够了。不要为了减少方法数量,拆出没有独立意义的接口。

3. 用数据和组合表达差异

优先使用结构体、普通函数和组合:

type Field struct {
    Key string
    Get func() string
    Set func(string) error
}

函数字段是地道手法,但 nil 字段一旦被调用就会 panic:在构造时填齐,或调用前判空。

几个明确的 switch 分支,往往比注册框架、工厂层级和通用插件系统更清楚。只有真实的重复和变化证明抽象有用时,才提取它。

4. 让零值可用

Make the zero value useful.

类型的零值应当无需初始化即可安全使用,sync.Mutex 和 bytes.Buffer 是标准库里的范例。

  • 设计时先问:这个类型的零值能直接用吗?
  • 只有确实需要初始化资源(连接、句柄、后台 goroutine)时,才提供 NewXxx。
  • 不要让调用方落入“必须先设置某些字段才能使用”的半成品状态,不变量在构造入口一次立好。

5. 泛型用于真实同构的复用

和接口一样,泛型是被发现的,不是被设计的:先写具体类型,等到第三个形状完全一致的重复出现,再提取类型参数。

  • 适用的是真正同构的容器与算法:链表、堆、集合运算、取极值、通用并发封装。
  • 各实现行为有差异时是接口的场景,不用类型参数复刻多态。
  • 不为省一次类型断言而强行泛型化;any 已够用且无需类型安全时,泛型带来的复杂度大于收益。
// Keys 以不确定的顺序返回 m 的全部键。
func Keys[K comparable, V any](m map[K]V) []K {
    keys := make([]K, 0, len(m))
    for k := range m {
        keys = append(keys, k)
    }
    return keys
}

6. 让执行过程一眼可见

  • 使用普通控制流,错误和特殊情况尽早返回。
  • 主流程按业务发生的顺序组织。
  • 函数围绕一个明确任务展开,过长时按有意义的阶段拆分。
  • 显式传递依赖,限制包级可变状态。
  • 避免隐藏副作用和需要跨多个文件才能理解的调用链。

简单是容易理解,不是行数最少。

7. 错误是契约的一部分

检查错误,并选择处理、返回或有意降级。

  • 区分“不存在”和“读取失败”,区分“空数据”和“损坏数据”。
  • 返回错误时,必要时补充操作上下文,用 %w 保留错误链。
  • 使用 errors.Is、errors.As 判断错误。
  • 哨兵错误用 ErrXxx 命名,错误类型用 XxxError 命名;导出即是对外的兼容承诺,尽量少导出。
  • 只在错误确实不影响结果时忽略它;原因不明显就写注释。
  • 预期内的失败返回 error,不使用 panic。

不要让一次失败悄悄变成成功的零值。

8. 名字简短,含义明确

  • 包名表达职责,避免 utils、common、manager 这类模糊名称。
  • 小作用域使用短变量名;跨越较长逻辑时使用更具体的名字。
  • 优先使用领域里已有的词,不创造个人暗语。
  • 遵循 ID、URL、HTTP 等 Go 命名惯例。
  • 未导出是默认状态,导出是对外承诺;导出面保持最小。

名字应当让调用处自然可读。

9. 注释写契约和原因

注释优先回答:

  • 它做什么?
  • 为什么必须这样做?
  • 有哪些边界条件?
  • 调用者需要遵守什么?

导出声明的注释以声明名称开头。第一句说明行为,后续再解释原因,默认使用中文。

// Read returns the parsed file contents, using a cached result when unchanged.
// The returned value is shared; callers must not modify it.

记录兼容性约束、资源所有权、并发要求和重要取舍。避免复述代码、堆砌历史和写冗长散文。

10. 并发必须有明确的生命周期

  • 共享状态用 mutex,任务协调和数据传递按需使用 channel。
  • 每个 goroutine 都应明确何时退出、由谁负责。
  • 需要取消或超时的操作传入 context.Context;它固定放在参数表第一位,不存入结构体。
  • 明确谁关闭 channel、谁释放资源、谁等待任务完成。
  • 同生共死的一批并发任务用 errgroup 编队并统一收错,不手写 WaitGroup 加通道收错的样板。

不要为了“像 Go”而使用 goroutine 或 channel。

11. 优先标准库,保持测试朴素

优先使用标准库;第三方依赖应解决明确的问题。

使用 testing、httptest、t.TempDir() 等工具测试可观察行为。重复场景适合表驱动测试,真实故障适合回归测试。避免为了 mock 而设计整套架构。

提交前运行 gofmt、go vet 和相关测试,有条件再跑 staticcheck;涉及共享状态时运行 go test -race。

给 Agent 的执行要求

遵循现有项目中合理的惯例,完成当前需求所需的最小清晰实现。新增抽象时,能够指出它解决的实际问题;忽略错误或选择降级时,能够解释其正确性。

具体实现优先,小接口按需出现,零值可用,泛型克制,依赖与错误显式表达,注释解释原因,测试验证行为。

参考资料