Golang Guideline - Accept interfaces, return structs
Accept interfaces
对于那些你只依赖其行为的复杂对象,应当接受接口类型作为参数,这样就不会与该功能的某个具体实现产生耦合。这样做可以为使用者提供更大的灵活性,同时也能提高代码的可测试性。
不推荐:
func New(f *os.File) (*Parser, error) {
// ...
}
输入参数必须始终是一个文件,因此测试时也必须先把内容写入磁盘文件。这样不仅很难模拟读取失败,也很难使用文件以外的输入来源。
推荐:
func New(r io.Reader) (*Parser, error) {
// ...
}
这样就把依赖范围缩小到了我们实际需要的功能。输入既可以是内存中的缓冲区,也可以是网络流。我们还可以对这个接口进行 mock,从而模拟各种类型的失败情况。
你可以声明自己的接口
你并不局限于使用其他地方已经定义好的接口。对于你所使用的对象,你完全可以自己声明接口——即使这些类型并不是由你定义或维护的。
例如,如果上面的 New 函数除了读取内容之外,还需要获取文件名,那么它就需要访问 Name 方法。我们可以这样写:
type Source interface {
io.Reader
Name() string
}
var _ Source = (*os.File)(nil)
func New(src Source) (*Parser, error) {
// ...
}
这行代码会在编译期验证 os.File 是否实现了该接口。参见 Verify Interface Compliance。
上面的函数既可以接受 *os.File,也可以接受任何自定义的、实现了 Source 接口的类型。
说到接口,库作者尤其应该牢记下面这一点:
接口一旦发布,就很难再改
任何对已导出接口的修改,都是破坏性变更。
你几乎无论怎么修改一个接口,都可能破坏某些使用者的代码。
- 添加方法:会破坏那些不属于你这个库、但实现了该接口的外部实现。示例可参见:向接口中添加方法是一项破坏性变更。
- 删除方法:会破坏那些接收该接口并调用了这个方法的函数或代码。
- 修改方法签名:两边都会被破坏:既会影响接口的外部实现,也会影响调用该方法的代码。
那么,在这种情况下,我们该如何保持灵活性?又该如何为一个抽象增加新的功能呢?接下来看看……
Return structs
为了让你的库保持灵活,针对库中的核心概念,应当返回结构体,而不是接口。这样一来,你就可以在完全保持向后兼容的情况下,为该对象添加新的方法。
例如:
不推荐:
很多包都会采用下面这种设计:
type Client interface {
Set(k string, v []byte) error
Get(k string) ([]byte, error)
}
type clientImpl struct {
// ...
}
func (c *clientImpl) Set(...) error
func (c *clientImpl) Get(...) ([]byte, error)
func New(/* ... */) Client {
return &clientImpl{
// ...
}
}
- 包的核心 API 被定义成一个接口。
- 具体行为由一个私有类型来实现。
- 构造函数返回这个私有类型的实例,但对外暴露的是接口类型。
这种设计缺乏灵活性。其中一个重要原因是,我们之后几乎无法再修改 Client 接口——因为向接口中添加一个方法就是破坏性变更。
这种“公开接口、隐藏实现、构造函数返回接口”的设计在 Java 和 C++ 中较为常见,一个关键原因是它们的接口并非隐式实现。Java 的具体类型必须通过
implements显式声明接口实现;C++ 中的接口通常由抽象基类表示,具体类型必须显式继承该基类。换言之,具体类型若要被当作某种抽象使用,该抽象必须预先定义,实现关系也必须显式建立。这种机制促使生产者先定义接口,再让私有类型实现它。Go 则不同:只要方法集匹配,具体类型就会自动满足接口(Ducking Type),因此生产者无须为消费者专门提供结构,消费者可以在真正产生需求时自己定义最小接口。
推荐:
更好的做法是直接导出具体实现,并让构造函数返回该具体类型的实例。
type Client struct {
// ...
}
func (c *Client) Set(...) error
func (c *Client) Get(...) ([]byte, error)
func New(/* ... */) *Client {
return &Client{
// ...
}
}
- 删除接口,改为直接导出提供这些核心方法的结构体。
- 构造函数返回指向该结构体的指针。
这种做法的优势是:可以继续给 Client 添加新的方法,而不会破坏现有调用方的代码。
如果使用者需要对这个结构体进行 mock、包装,或者用其他实现来替代它,仍然可以自行声明所需的接口。
规则总结
接口应尽量设计得小,以便更容易实现和组合(参见 GoTip #78: Minimal Viable Interfaces)。同时,应为接口编写恰当的文档,包括它的行为契约、边界情况以及预期可能返回的错误。如果某个接口只在包内部使用,就应保持其为未导出类型。
接口应由它的使用方来定义,而不是由实现该接口的包来定义,以确保接口中只包含调用方实际使用的方法。不过,如果“接口本身就是产品”,例如它代表一种公共协议,那么生产方的包也可以导出该接口,以避免各个使用方重复定义相同接口而造成冗余。
有一句常见的经验法则:函数应接受接口作为参数,但返回具体类型(GoTip #49: Accept Interfaces, Return Concrete Types)。
返回具体类型,可以让调用方访问这个具体实现所提供的全部公开方法和字段,而不只是某个预先选定接口中定义的那部分方法。与此同时,调用方仍然可以把这个具体返回值传递给任何接受相应接口作为参数的函数。
在某些情况下,为了实现封装,返回接口也是合理的,例如 error 接口;此外,在命令模式、链式调用、工厂模式以及策略模式等特定设计中,返回接口也可能是合适的。
关于接口更深入的讨论,可以参阅 Best Practices 中关于 interfaces 的章节。
消费者定义接口:接口是消费者对依赖能力的声明——“我需要一个能够完成 X 的对象”,而不是对具体类型或实现方式的规定。它描述的是消费者实际依赖的最小行为集合,而非生产者能够提供的全部能力。
生产者返回具体类型:具体类型是生产者对自身能力的完整交付——“这是我提供的 X”,而不是预先替消费者限定其使用方式。它保留了全部公开能力,也允许生产者在不破坏现有调用方的前提下继续扩展方法。
对实际开发过程中的指导是:
- 需要一个能够完成 X 的对象,那就定义一个接口;
- 如果正在实现一个提供 X 的对象,除非明确想要外部实现接口,否则不要提供接口,而是直接提供具体实现。
如果你是从其他语言转到 Go 的——尤其不是从动态语言转过来的——那么你可能已经习惯了这样一种机制:编译器会在编译期间生成静态分发表,因此需要你显式声明某个类型要实现哪些接口。编译器正是依靠这些信息来生成 vtable(虚函数表),其中保存了指向所有可用虚函数的指针。
如果你有 C++ 或 Java 的开发背景,那么你很可能还带着一些原有的设计习惯:一开始就用抽象类型搭建整个代码库,然后再把具体实现作为后续工作逐步补上。
在 Go 中,并不是这样做的。
应该优先引入具体实现,并且除非你确实需要鼓励外部包去实现某个接口,否则不要创建和导出接口。
io 包是学习这方面最佳实践的一个很好的起点。它之所以会导出接口,是因为它同时还需要提供像 Copy 这样具有通用用途的函数。
// io 包导出了接口
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// io 包也导出了通用函数
func Copy(dst Writer, src Reader) (written int64, err error) {
// ...
}
因为 Copy 这样的通用函数需要接受"任何能读的东西"和"任何能写的东西"。为了让这个函数能被任何人使用,它的参数类型(Reader / Writer)必须是导出的接口。
Go 并没有传统意义上的分发表(dispatch table),而是在方法分派时依赖接口值(interface value)本身。
更准确地说,它更像是一种比较灵活的动态分派机制:当一个具体值被赋给接口值时,运行时会做一些额外工作,为该接口值所指向的具体类型生成一个很小的查找表,用于后续的方法调用。这种赋值操作的成本并没有高到难以接受,因此,用这点开销换取一个更灵活、更易用的类型系统,通常是值得的。如果你想进一步了解其中的内部实现机制,Ian Lance Taylor 写过一篇很不错的相关文章,可以作为延伸阅读。
如果用户需要某种程度的“控制反转(即高层不想直接依赖低层模块,而是依赖于抽象)”,在他们自己的作用域中即时定义接口就能解决问题。
依赖反转:
- 高层模块不应该依赖低层具体模块,二者都应该依赖抽象:高层只定义自己需要什么,低层负责怎么实现(依赖反转了),双方通过抽象连接。
- 抽象不应该依赖具体实现,具体实现应该依赖抽象:接口描述业务需求,而不是迁就具体技术;具体技术反过来实现并遵守这个接口(依赖反转了)。
Go 接口的隐式实现机制,最大限度地减少了你对包被使用方式的预设,以及你需要处理的初始抽象(这在 Java 中经常存在——包实现者需要预判抽象、定义和导出接口)。
这同样适用于可测试性方面的考量。你并不需要为了帮助用户编写自己的 stub(桩实现)而专门提供接口。
就在今天早些时候,我收到一个请求,希望从 pubsub 包中导出一个接口,以便让它更容易被 mock——实际上不需要,相比之下,更可取的做法是告诉用户:由他们自己定义一个接口,并且只包含他们真正想要为之编写 stub 的那些方法。然后,通过接口值来引用实际的具体实现。
// 测试时自定义的接口
type acknowledger interface {
Ack(sub string, id ...string) error
}
type mockClient struct{}
func (c *mockClient) Ack(sub string, id ...string) error {
return nil
}
// 可以把实际的 pubsub client 赋值给 acknowledger 接口
var acker acknowledger = pubsub.New(...)
// 在测试包中,可以把真实实现替换成 mockClient
acker = &mockClient{}
值得注意的是,在 Go 中,标准库定义了许多非常小的接口,而你的类型往往无需额外工作就已经自然地实现了它们。标准库在这方面做得很好:它鼓励开发者编写能够与标准库其他部分,以及其他第三方包良好协作的代码。
因此,只要有可能,就应该优先采用标准库中已经存在的接口,并在文档中相应地说明这一点。
How To Use Go Interfaces
Don’t Do This
package animals
type Animal interface {
Speaks() string
}
// implementation of Animal
type Dog struct{}
func (a Dog) Speaks() string { return "woof" }
package circus
import "animals"
func Perform(a animal.Animal) string { return a.Speaks() }
这就是所谓的“Java 风格”的接口使用方式。它的步骤大致如下:
- 定义一个接口
- 定义一个实现该接口的类型
- 定义相应的方法,让这个类型满足接口要求
Java 中这样写的原因是因为接口必须被具体类型显示实现,因此具体类型的实现者必须提前抽象出接口。
这种代码有很明显的特征:通常只有一个类型实现了这个接口,而且看不出未来有什么明确的扩展需求。
Do This Instead
Go 的接口鼓励开发者“懒一点”,而这其实是一件好事。
不要为了满足接口而编写类型,优先提供具体类型,不要进行抽象,由具体类型的使用方根据实际使用需求来定义接口。
例如:不要在 animals package 中定义 Animal 接口,而是在真正使用这个接口的地方——也就是 circus package 中——定义它 *****。
package animals
type Dog struct{}
func (a Dog) Speaks() string { return "woof" }
package circus
type Speaker interface {
Speaks() string
}
func Perform(a Speaker) string { return a.Speaks() }
更符合 Go 惯用写法的思路是:
- 先定义具体类型
- 在使用这些类型的地方定义接口
这种方式可以减少代码对 animals package 中具体组件的依赖。
而减少依赖,正是构建健壮软件的重要方式。
Postel’s Law
编写优秀软件时,有一条很值得遵循的准则,就是 Postel’s Law(Postel 定律)。它通常被表述为:
“对自己所做的事情要保守,对自己所接受的东西要宽容。”
翻译成 Go 语言中的说法,就是:
“接收接口,返回结构体(Accept interfaces, return structs)。”
总体来说,这是一个非常适合用来设计健壮软件的原则。
在 Go 中,函数是最主要的代码单元。在设计函数或方法时,可以遵循下面这种模式:
func funcName(a INTERFACETYPE) CONCRETETYPE
这里可以看到,函数接受任何实现了某个接口的值——这个接口可以是任意具体接口,也可以是空接口——然后返回一个具体类型的值。
当然,对参数 a 能接受什么进行一定约束是有价值的。正如 Go Proverbs 中所说:
“空接口什么也没表达。”
—— Rob Pike
因此,通常最好不要让函数直接接收 interface{}。
Use Case: Mocking
Postel 定律的一个非常典型的应用场景,就是测试。
假设你有一个这样的函数:
func Takes(db Database) error
如果 Database 是一个接口,那么在测试代码中,你只需要提供一个 Database 的 mock 实现即可,而不必真的传入一个数据库对象。
When Is It Acceptable To Define An Interface Upfront
说实话,编程这件事本身是相当自由的,并不存在什么绝对不可违反的硬性规则。
你当然可以提前定义接口。不会有什么“代码正确性警察”跑过来把你抓走。
比如在一个 package 中,如果你已经明确知道这个 package 提供的一组函数都会围绕某种接口进行操作,那么直接提前定义这个接口完全合理。Go 标准库中的 io.Reader 和 io.Writer 就是典型例子:io 包先定义了“可读”“可写”这两种行为抽象,然后 io.Copy、io.ReadAll 等函数围绕这些接口工作。
例如前面 io 包的例子
package io type Reader interface { Read(p []byte) (n int, err error) } type Writer interface { Write(p []byte) (n int, err error) } func Copy(dst Writer, src Reader) (written int64, err error) { // ... }这里
Reader和Writer确实是在 io 包中提前定义的,但它们不是实现方为了让某个具体类型去满足接口而定义的,而是因为 io 包明确知道自己的一组 API 都会围绕某种行为抽象来工作
不过,提前定义接口通常是一种“过度设计”的代码味道。
当然,也确实存在一些必须提前定义接口的情况。我能想到的有:
- 封闭接口(Sealed Interfaces)
- 抽象数据类型(Abstract Data Types)
- 递归接口(Recursive Interfaces)
下面分别简单说一下。
Sealed Interfaces(封闭接口)
封闭接口只有在涉及多个 package 的语境下才有意义。
所谓封闭接口,就是包含未导出方法的接口。
这意味着 package 外部的用户无法创建一个满足该接口的类型。
这种方式可以用来模拟 sum type(和类型),因为你可以穷举出所有能够满足这个接口的类型。
比如可以定义成这样:
type Fooer interface {
Foo()
sealed()
}
只有定义了 Fooer 的那个 package,才能创建合法的 Fooer 值。
为什么 Sealed Interfaces 无法被外部实现?
Go 里的未导出方法名,不仅包含方法名本身,还隐含了“所属 package”的身份
package bar type MyFoo struct{} func (MyFoo) Foo() {} func (MyFoo) sealed() {}看起来
MyFoo也有Foo()和sealed(),但实际上:foo.sealed和bar.sealed不是同一个方法。var x foo.Fooer = MyFoo{} // 编译失败原因就是
MyFoo缺少的是foo包自己的那个未导出方法sealed。而在bar包里,你又没有办法真正声明foo.sealed,因为sealed是未导出的,包外无法引用它。
因此,你就可以对它进行穷尽式的 type switch。
封闭接口还有一个好处:静态分析工具可以比较容易地发现没有穷尽所有情况的模式匹配。
事实上,BurntSushi 的 sumtypes package 做的正是这件事。
Abstract Data Types
另一个适合提前定义接口的场景,是创建抽象数据类型(Abstract Data Type,ADT)。
这种接口可以是封闭的,也可以不是封闭的。
Go 标准库里的 sort package 就是一个很好的例子。
它把“可排序集合”定义为:
type Interface interface {
// Len 返回集合中元素的数量。
Len() int
// Less 报告索引 i 对应的元素是否
// 应该排在索引 j 对应的元素之前。
Less(i, j int) bool
// Swap 交换索引 i 和 j 对应的元素。
Swap(i, j int)
}
这个设计让不少人感到不爽,因为如果你想使用 sort package,就必须实现这个接口里的这些方法。
而多数人的不满,基本上就是因为他们不得不多写三行代码。
不过,在我看来,这其实是 Go 中一种非常优雅的泛型形式,应该更多地鼓励这种设计。
另一个同样优雅的替代方案,则需要 higher-kinded types(高阶类型构造)。
这篇博客就不展开讨论这个问题了。
Recursive Interfaces(递归接口)
这大概又是一种代码味道,不过有些时候确实无法避免。
你可能会 在某个 Monad 里做点什么然后 最终得到这样一个接口:
type Fooer interface {
Foo() Fooer
}
显然,在递归接口这种模式中,接口必须提前定义。
因此,“在使用点定义接口”这条原则在这里并不适用。
这种模式适合用来创建某种“上下文”,让代码在这个上下文中执行。
大量依赖上下文的代码,通常会被封装在一个 package 内部,只把这些上下文对外导出——例如 tensor package 就是这种方式。
所以实际上,我并不经常看到这种写法。
关于这种 context-based pattern,我其实还有不少东西可以说,不过还是留到另一篇博客再讲吧。
Reference
Designing Go Libraries | Abhinav Gupta
Interface pollution in Go · rakyll.org
styleguide | Style guides for Google-originated open-source projects
评论