Published: Dec 29, 2019
Go 语言中的接口(interface)是其最具特色的功能之一。与许多其他语言不同,在 Go 中,类型不需要显式声明实现某个接口。只要一个类型定义了接口所需的方法,它就自动实现了该接口。
然而,编写良好的接口并不容易。不恰当地暴露宽泛或不必要的接口,容易污染包的 API。本文将解释现有接口设计准则背后的逻辑,并结合标准库中的示例进行说明。
「接口越大,抽象越弱」
大型接口往往难以找到多个实现类型。因此,Go 代码中常见的接口通常只包含一两个方法。与其声明大型公共接口,不如依赖或返回具体类型。
例如,io.Reader 和 io.Writer 是强大接口的典范:
type Reader interface {
Read(p []byte) (n int, err error)
}
在标准库中,有 81 个结构体实现了 io.Reader 接口,分布在 30 个包中;有 99 个方法或函数在 39 个包中使用了该接口。
「接口应定义在使用它们的包中,而非实现它们的包」
在真正使用接口的包中定义接口,可以让客户端定义抽象,而不是由提供者强行规定所有客户端的抽象。
例如,io.Copy 函数接受 Writer 和 Reader 接口作为参数,这些接口都在同一个包中定义:
func Copy(dst Writer, src Reader) (written int64, err error)
「如果一个类型仅用于实现接口,且不会有超出该接口的导出方法,则无需导出该类型本身」
根据 CodeReviewComments 的指南:
实现包应返回具体类型(通常是指针或结构体):这样可以在不需要大量重构的情况下向实现中添加新方法。
结合 EffectiveGo 的说法,我们可以全面理解,在生产者包中定义接口是可以接受的:
如果一个类型仅用于实现接口,且不会有超出该接口的导出方法,则无需导出该类型本身。
例如,rand.Source 接口由 rand.NewSource 返回。构造函数中的底层结构体 rngSource 仅导出 Source 和 Source64 接口所需的方法,因此该类型本身未被导出。
package rand
type Source interface {
Int63() int64
Seed(seed int64)
}
func NewSource(seed int64) Source {
return newSource(seed)
}
func newSource(seed int64) *rngSource {
var rng rngSource
rng.Seed(seed)
return &rng
}
rand 包还有两个实现该接口的类型:lockedSource 和 Rand(后者被导出,因为它有其他公共方法)。
那么返回接口而不是具体类型有什么好处呢?
返回接口允许函数返回多个具体类型。例如,aes.NewCipher 构造函数返回 cipher.Block 接口。如果查看构造函数内部,可以看到返回了两种不同的结构体:
func newCipher(key []byte) (cipher.Block, error) {
...
c := aesCipherAsm{aesCipher{make([]uint32, n), make([]uint32, n)}}
...
if supportsAES && supportsGFMUL {
// 返回类型是 aesCipherGCM。
return &aesCipherGCM{c}, nil
}
// 返回类型是 aesCipherAsm。
return &c, nil
}
需要注意的是,在前面的例子中,接口是在生产者包 rand 中定义的。然而在这个例子中,返回的类型是在另一个包 cipher 中定义的。
在我的短暂经历中…
这一模式比前几种更难执行。开发初期,客户端包的需求快速演变,导致频繁修改生产者包。如果返回类型是接口,它可能逐渐变得过于庞大,最终返回具体类型反而更合理。
我认为该模式的运作机制是:
「考虑创建一个仅包含接口的独立包以统一命名空间和标准化」
虽然这不是 Go 团队的官方指南,但在标准库中,包含仅接口的包是一种常见模式。
例如,hash.Hash 接口由 hash/ 子目录下的包(如 hash/crc32 和 hash/adler32)实现。hash 包仅暴露接口:
package hash
type Hash interface {
...
}
type Hash32 interface {
Hash
Sum32() uint32
}
type Hash64 interface {
Hash
Sum64() uint64
}
将接口移到单独的包中,而不是在子目录中暴露,可能有两个好处:
另一个仅包含接口的包是 encoding:
package encoding
type BinaryMarshaler interface {
MarshalBinary() (data []byte, err error)
}
type BinaryUnmarshaler interface {
UnmarshalBinary(data []byte) error
}
type TextMarshaler interface {
MarshalText() (text []byte, err error)
}
type TextUnmarshaler interface {
UnmarshalText(text []byte) error
}
标准库中有许多结构体实现了这些 encoding 接口。然而,与 hash 不同,标准库中没有函数接受或返回 encoding 接口。
那么为什么要暴露它们呢?
可能是为了向开发者提示二进制序列化的标准方法签名。如果一个新结构体实现了 encoding.BinaryMarshaler 接口,现有的包在测试值是否实现该接口时,无需更改其实现:
if m, ok := v.(encoding.BinaryMarshaler); ok {
return m.MarshalBinary()
}
值得注意的是,这种模式并未在 compress/zlib 和 compress/flate 包中的 Resetter 接口中遵循,因为它在两个包中都被重复定义。然而,这似乎是 Go 维护者之间讨论的一个话题。
最后,私有接口无需处理上述考虑,因为它们不会被暴露。我们可以拥有较大的接口,例如 encoding/gob 包中的 gobType,而无需担心其内容。接口可以在多个包中重复,例如 os 和 net 包中都存在的 timeout 接口,而无需考虑将它们放置在单独的位置。
type timeout interface {
Timeout() bool
}
本文翻译自《Exposing interfaces in Go》,并在此基础上进行了总结与概括。如需了解更多细节,欢迎查阅原文:https://www.efekarakus.com/golang/2019/12/29/working-with-interfaces-in-go.html
References
https://www.efekarakus.com/golang/2019/12/29/working-with-interfaces-in-go.html