在 Go 社区里,有一句话几乎和高并发编程一样被人津津乐道:Accept interfaces, return structs。很多团队在代码评审时把它当作一条行业标准,看见函数返回了接口就质疑,看见参数是具体类型就要求改成接口。但当你真正开始设计较大规模的 Go 系统时,会发现这句话远远没有听起来那么“正确”。它更像是一条带着前提的经验法则,而前提往往被大多数人忽略了。

我第一次对这句话产生动摇,是在阅读标准库源码的时候。如果 Go 作者们都严格遵守它,为什么 context.WithCancel 会返回 context.Context 这个接口?为什么 errors.New 返回的是 error 而不是一个具体错误结构体?后来我想明白了一件事:接口设计的真正议题不是“接口还是结构体”,而是依赖方向和使用契约。本文我会试图把这句话掰开揉碎,结合工程里的真实处境,聊一聊它到底在什么场景下有效,又在什么场景下失效。
被误读的 Go 接口:隐式实现带来的依赖规则
要理解这句话,必须先理解 Go 接口和其他语言的接口有一个根本区别:Go 接口是隐式实现,且接口通常由调用方定义。类型不需要显式声明 implements 某个接口,只要它的方法集合能覆盖接口中定义的方法,它就自动实现了该接口。这意味着接口的语义不归属于实现包,而是归属于使用它的地方。
当你写下面这样的函数签名时:
func Save(ctx context.Context, s Storage) error
你实际上是在说:“我需要一个能保存任何类型的东西,但我不关心它怎么实现。” 这就是 Accept an interface 的本来含义。而如果你写的是 func Save(ctx context.Context, s *MySQLStorage) error,则意味着你认定了存储就是 *MySQLStorage,一旦换成 Redis 实现,调用方和函数本身都要改。
所以“接受接口”本身是在解耦调用方与实现方,让依赖的方向从“函数依赖具体实现”变成“实现依赖消费者的契约”。这在写库、做中间件、提供插件机制的时候特别有价值。
再来看返回值。如果函数返回一个接口,比如 io.Reader,调用方只能使用该接口定义的方法。它无法访问底层 File 类型的 Name() 方法,除非再做一次类型断言。而返回结构体则直接把全部能力交给调用方。因此“返回结构体”本意是避免不必要的封装,让调用方有最大的自由度。
到这里,这句话的内在逻辑是自洽的。但问题在于,很多人把“输入输出”简单地对应成“抽象与具体”,忽略了中间还有一个变量:接口该由谁来定义。如果接口定义错了位置,接受接口反而会增加耦合。
什么时候“返回接口”才是真正合理的
标准库提供了很好的反例。context.WithCancel 的签名是:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
这里没有返回具体的 cancelCtx 结构体,因为实现涉及取消链、goroutine 调度和父 context 通信,如果暴露具体类型,调用方就能构造出非法状态,或者依赖于不该依赖的内部字段。接口在这里承担了“信息隐藏”的职责。
再比如 errors.New,返回 error 接口不是因为它有多种实现,而是因为错误核心语义是“一句话 + 可比较性”,调用方除了判断类型、嵌套解析外,不需要知道更多。这种返回接口的设计,限制的是调用方的使用范围,保护的是接口的演进空间。
另一个常见场景是所有 API 统一返回接口,比如“返回错误码 + 错误信息”的 result 对象。虽然目前只有一个结构体实现,但接口的存在可以让未来新增一个不可变实现时,生产者不用破坏对外签名。当然这种设计也有代价:调用方有时候必须用类型断言才能拿到具体字段,代码就变得繁琐。
极端遵守原则的三个典型误区
误区一:为了让参数变成接口,强行抽象出多个实现
我见过一些项目,明明仓库层只有一种实现——MySQLRepository,却在 service 层定义了一个 Repository 接口,里面塞了 10 个方法。每次加一个方法,所有 mock 都要同步改。这种接口既不表达需求,也不约束行为,只是为了让“依赖接口”听起来更好听。更麻烦的是,接口里方法的粒度可能跟实际使用模式不匹配,调用方反而要被迫知道接口的所有方法。
什么时候参数应该接受接口?我的简单判断是:你能否说出第二个实现是谁。如果能,接口就有必要;如果不能,先写具体类型。
误区二:认为接口必须定义在调用方,所以到处定义小接口
这个说法本身有一定道理,但它有一个边界:当一组方法天然属于某个领域概念时,接口定义在领域包内更合理。例如 io.Reader 就不是定义在每个调用方,而是定义在 io 标准库里,因为所有消费者都认同 Read(p []byte) (n int, err error) 这个最小操作。如果每个函数都自己重新定义一个 Reader 接口,那代码将会变得极其碎片化,而且无法和 io 包的工具函数协作。
所以接口定义的“靠近调用方”指的是在业务代码中,让服务接口贴近消费者;而底层的基础抽象则往往集中在公共包中。
误区三:忽略接口的性能代价
接口调用通过 itab 间接跳转,这比直接调用具体方法多一次指针解除,而且往往阻止了编译器进行内联。对于 RPC 框架、序列化工具这样的热路径库,如果每个二进制读取都走一次 io.Reader 接口,性能损失是真实存在的。虽然现代 Go 编译器在部分场景下能通过 devirtualization 优化掉间接跳转,但依赖优化并不是安全的设计。因此热路径上的内部代码,使用具体类型往往是更好的选择。
参数和返回值的四种组合,分别适合什么场景
与其背一句口号,不如把参数/返回值的四种选择放进一个表格里比较:
| 设计选择 | 适用场景 | 优点 | 代价 |
|---|---|---|---|
| 参数接受接口 | 存在多种实现;需要 mock;依赖方向需要逆转 | 解耦调用方与实现,便于替换和测试 | 间接调用,可能阻碍内联;接口定义不当会带来认知负担 |
| 参数接受具体类型 | 实现唯一且稳定;性能敏感;需要直接在调用处使用字段 | 简单直接,编译器优化空间大 | 未来替换实现需要修改调用方 |
| 返回接口 | 需要隐藏实现细节;提供不可变视图;强制统一操作入口 | 契约稳定,防止调用者依赖具体实现 | 调用方需要类型断言才能访问完整方法;可能产生额外抽象 |
| 返回具体类型 | 调用方需要组合和方法扩展;返回值本身就是最终结果 | 使用灵活,不丢失能力 | 实现变化会影响调用方;无法隐藏内部状态 |
从表中可以看到,并不存在一个绝对正确的组合。“返回接口”和“接受具体类型”这两种组合都有存在的理由,关键是看你在保护什么。
如何把这条原则转化成可落地的设计
对于大多数业务系统,我建议采用以下演进路径。
- 先写具体类型,不要急着抽接口。隐式接口允许你在需要时再补接口,而不必修改实现。
- 当出现第二个实现,或需要在测试中 mock 时,从消费者的角度提取接口。接口中的方法应该是消费者真正调用的。
- 返回值时,默认返回具体类型。除非有强烈的理由需要隐藏实现、维持同一个契约版本,才返回接口。
- 在性能敏感路径上,优先使用具体类型,使用泛型作为替代方案,而不是接口。
这样做的好处是:你的接口都是为了解决真实问题而存在,不是为抽象而抽象。同时,因为 Go 接口是隐式的,提取接口的成本非常低。
我们还可以用一个更实际的场景来理解。假如你在开发一个内部日志库,一开始只有一个基于标准库的 FileLog 实现。这时候你完全没有必要定义 Logger 接口,直接返回 *FileLog 即可。后来团队需要接入 Kafka,你仍然不必立刻抽象接口——可以等调用方真的出现“今天用 FileLog,明天用 KafkaLog”的需求时,再在调用方声明一个 type Logger interface,并把已有的 *FileLog 传进去。由于 Go 接口的隐式实现,这个改动只涉及新增代码,不用改动现有实现。
回到标题:这条原则到底对不对
如果我们把“对”定义为“在任何场景下都成立”,那它肯定不对。任何一句工程师之间的黑话,一旦被当成原则,就容易被过分崇拜。
真正有价值的不是这句口号本身,而是它背后的追问:你的依赖是向着稳定方向流动的吗?你的返回值是否在保护不该被修改的契约?你定义的接口是消费者真正需要的吗?
在大多数情况下,“接受接口、返回结构体”确实能让代码更容易替换、测试和组合。但当你需要隐藏实现、稳定契约、控制调用方的操作边界时,返回接口也是一种优雅的设计。反过来,在性能热路径或实现高度稳定的内部模块中,参数用具体类型也完全没问题。
不要把任何编程口号当作不可违反的教条,它只描述了一种权衡。
工程上没有银弹,接口设计也一样。把这条原则当作起点,而不是终点,你才能真正理解 Go 的设计哲学。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/574/