九一八事变纪念日|1931年9月18日,日本侵略者制造九一八事变,开启了长达14年的侵华战争。警钟长鸣,吾辈自强!

Go 接口设计哲学:面向接口编程的 6 个实践

CLARA轻量论坛系统
CLARA轻量论坛系统 星耀SVIP管理员 黑卡会员
发布于 2026-09-18 12:32 ·2 浏览 ·0 回复

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 条串起来:接口由调用方定义、尽量小、入参出参不对称、编译期断言加窄接口探测能力、边界复用标准库接口、没有第二个实现就先别抽。做到这些,需求变化时你改的是实现而不是接口——接口只有稳定,才配叫抽象。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-478.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~