Go 接口设计哲学:面向接口编程的 6 个实践
Go 接口设计的核心只有一句话:接口是「使用方对能力的最小需求描述」,所以要小、要窄、要由调用方定义。下面 6 个实践按重要性排序,照做基本不会写出「能跑但改不动」的代码。
实践一:接口定义在调用方,不在实现方
结论:接口应该定义在使用它的那个包里,而不是实现它的包里。
Go 的隐式实现(只要方法集匹配就自动满足接口,不需要 implements 关键字)让这件事成为可能。调用方声明自己需要什么,实现方只提供具体类型,依赖方向就不会被锁死:
// service 包:接口在这里定义
type UserGetter interface {
GetUser(id int64) (*User, error)
}
type Service struct{ users UserGetter }
// repository 包:只暴露具体类型,不定义接口
type UserRepo struct{ db *sql.DB }
func (r *UserRepo) GetUser(id int64) (*User, error) { /* ... */ }
反过来的做法很常见也很糟:在 repository 包里定义 `UserRepo` 接口,结果所有使用者都得 import 那个包,而且实现每加一个方法,接口就跟着膨胀,最后变成「结构体的影子」——没有任何抽象价值。
实践二:接口越小越强,1-3 个方法够了
结论:接口越大,抽象越弱。Go 谚语「接口越大,抽象越弱」说的就是这件事。
`io.Reader`、`io.Writer` 只有一个方法,却撑起了整个 Go 的 I/O 生态。需要更多能力时用接口组合,而不是定义大接口:
type ReadWriter interface {
Reader
Writer
}
大接口有三重代价:写 mock 要填一堆空方法;每个实现者被强迫实现全部方法(哪怕用不上);给接口新增方法会破坏所有已有实现。所以要拆就拆,宁可函数多收几个窄接口参数。
实践三:接受接口,返回结构体
结论:函数入参用接口,返回值用具体类型——Accept interfaces, return structs。
入参用接口,调用方可以传任何满足条件的东西;返回值给具体类型,调用方保留全部能力,还能自己决定要不要把它当某个接口用。返回接口则相反:调用方被限制在接口暴露的方法里,想用别的方法只得做类型断言。
唯一合理的例外是「故意隐藏实现」——不导出实现类型,构造函数只返回接口,比如 `net.Conn`、工厂函数返回 `io.Reader`。判断标准很简单:你是否真的需要让实现细节不可见,而不是顺手写的。
实践四:编译期断言守住契约,窄接口做能力探测
结论:`var _ Iface = (*T)(nil)` 是最便宜的契约测试;给类型加能力时,优先新增一个窄接口 + 类型断言,而不是往老接口里塞方法。
var _ io.Writer = (*Buffer)(nil)
type Flusher interface{ Flush() error }
if f, ok := w.(Flusher); ok {
_ = f.Flush()
}
标准库就是这么干的:`http.Flusher`、`io.StringWriter`、`encoding.BinaryMarshaler` 都是可选能力接口。两个坑要注意:一是接口里装 typed nil 时 `err != nil` 会为 true(`var e error = (*MyErr)(nil)`),判空要落到具体类型上;二是类型断言失败必须有兜底逻辑,不要靠 panic 做控制流。
实践五:包边界优先复用标准库接口
结论:跨包、跨模块的边界上,优先用 `io.Reader`、`io.Writer`、`fmt.Stringer`、`error`、`context.Context`,别自造同类接口。
标准接口是全 Go 社区共享的词汇表,别人一眼看懂;更实际的好处是你的类型天然能和生态协作——实现了 `Read` 就能直接喂给 `json.NewDecoder`、`io.Copy`、`gzip.Reader`,一行适配代码都不用写。
`error` 本身就是接口:自定义错误实现 `Error() string`,需要携带上下文再实现 `Unwrap()`,判断错误用 `errors.Is` / `errors.As`,别比较错误字符串。
实践六:别为「以后可能」提前抽接口
结论:只有当出现第二个真实实现、或测试确实需要替换依赖时,才引入接口。
可以照着这三个信号判断:已有另一种实现(内存缓存 vs Redis);单测里必须替换外部依赖(数据库、HTTP 客户端、时钟、随机源);同一算法要在多种类型上复用——最后这种先考虑泛型。
Go 1.18+ 的泛型解决编译期多态(容器、算法),接口解决运行时多态(多实现、插件、回调),两者不要互相硬套。更不要把 `any` 当万能接口用,那是主动放弃类型检查。
把这 6 条串起来:接口由调用方定义、尽量小、入参出参不对称、编译期断言加窄接口探测能力、边界复用标准库接口、没有第二个实现就先别抽。做到这些,需求变化时你改的是实现而不是接口——接口只有稳定,才配叫抽象。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员





