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 的执行要求
遵循现有项目中合理的惯例,完成当前需求所需的最小清晰实现。新增抽象时,能够指出它解决的实际问题;忽略错误或选择降级时,能够解释其正确性。
具体实现优先,小接口按需出现,零值可用,泛型克制,依赖与错误显式表达,注释解释原因,测试验证行为。