本文翻译自:Exposing interfaces in Go
0. 概述
接口 是 Go 语言中我最喜欢的特性。所谓的接口,其实就是一系列方法的集合。和其他程序语言不同,你不需要特别申明说一种类型实现了某个接口,实现接口 I 的方式只需要在结构体 S 中实现所有定义在接口 I 中的方法即是。
编写一个良好的接口是非常困难的,你很容易就可能因为暴露一个太过宽泛的和不必要的接口,从而“污染”了 API。在这篇文章中,我将以标准库中接口为例,阐述接口设计的指导思想。
1. 接口越大,抽象越弱
你不大可能找到多种不同的对象,他们都实现了一个大的接口,因为,“在 Go 的代码中,接口通常只会包含一到两个方法”。所以,当你定义了一个包含很多公开方法的接口时,那么你应该考虑通过拆分依赖或者直接返回一个具体的类型来替代。
io.Reader
和 io.Writer
接口通常被拿来做为优秀接口的示例:
type Reader interface {
Read(p []byte) (n int, err error)
}
通过过滤标准库,我发现有 30 个 package 总共包含 81 个结构体实现了 io.Reader
,同时,在 39 个 package 中,有 99 个函数或者方法使用了这个接口。
2. Go 接口通常属于使用它的位置,而不是实现它的位置
通过在使用接口的 package 中定义它,我们要让客户端来定义抽象,而不是让 provider 来为它的各种不同的客户端来定义抽象。
一个例子就是 io.Copy
函数,它既接收 Writer
也接收定义在同一个 package 中的 Reader
接口作为参数。
func Copy(dst Writer, src Reader) (written int64, err error)
另外一个例子是来自不同 package 的 color.Color
接口,color.Palette
类型的索引函数依赖于它,通过这个接口接收各种实现了这个接口的不同结构体。
func (p Palette) Index(c Color) int
3. 如果一个类型仅仅是实现接口,并且不暴露其他的方法,那么没必要暴露这个类型
在 CodeReviewComments 的导论中提到:
实现的 package 应该返回特定的类型(通常是指针或者结构体),这样,可以直接通过添加新方法来实现(新的接口)而不用重构来扩展。
和 EffectiveGo 中的描述对比,我们可以看到其实在生产者的 package 中定义接口也是可接收的一个全景视图。
如果一个类型仅仅是实现接口,并且不暴露其他的方法,那么没必要暴露这个类型。
例如,rand.NewSource
就是返回一个 rand.Source
接口,这个构造器底层的结构 rngSource
仅仅只是暴露 Source
和 Source64
这两个接口的方法,所以它自身没有必要暴露。
在 rand
package 中有两个其他类型也实现了这个接口: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 {
// Returned type is aesCipherGCM.
return &aesCipherGCM{c}, nil
}
// Returned type is aesCipherAsm.
return &c, nil
}
注意:在前面的示例中,接口是被定义在生产者的 rand package 中,然而,在这个例子中,返回类型却被定义在不同的 cipher package 中。
小结
这个模式比前面一个似乎会稍微会有些不同,在开发的早期阶段,客户端 package 进展很快,然而,这就导致了需要修改生产者的 package。如果返回类型是一个接口,慢慢地这个接口就会变得太大,从而到一个返回具体的类型可能更有意义的情况。
我关于这种模式机制的假设是:
- 返回的接口应当足够小,这样就可以有多种不同的实现
- 尽可能不返回一个接口除非在你的 package 中有接口的多个实现。当多个类型有共同的行为签名时,你需要考虑是否做了正确的抽象。
4. 创建一个单独的只有 interface 的 package 作为命令空间和标准
这不是 Go team 的一个官方指导,只是我在标准库中观察到的一个通用模式,有些 package 仅仅包含了接口。
例如,hash.Hash
接口就被在 hash/
下的 hash/crc32
和 hash/adler32
实现,而 hash
package 只暴露接口。
package hash
type Hash interface {
...
}
type Hash32 interface {
Hash
Sum32() uint32
}
type Hash64 interface {
Hash
Sum64() uint64
}
我认为将接口移到一个单独的 package,将不是放在子目录中有这些好处:
- 更好的接口命名空间,例如
hash.Hash
会比adler32.Hash
来理解; - 标准化如何实现一个功能,一个只包含接口的 package 可以用 hash.Hash 接口来提示 hash 函数应该包含哪些方法;
另外一个只包含接口的 package 是 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 只被 crypto
package 消费不同,在标准库中没有任何函数接受或者返回 encoding
接口。那为什么要暴露它呢?
我认为这是因为他们想提示开发者们将一个二进制和对象之间序列化和反序列化的标准方法签名是怎样的。例如已经存在的用来测试一个值是否实现了 encoding.BinaryMarshaler
接口的 package,当新的结构体实现了这个接口时,就不需要修改这个 package 的实现了:
if m, ok := v.(encoding.BinaryMarshaler); ok {
return m.MarshalBinary()
}
不幸的是这个模式并没有被 compress/zlib
和 compress/flate
package 中的 Resetter
效仿,他们而是在各自的 package 中重复地定义了。但是,这已经在 Go 维护者们的讨论中了(见 CR comment#27)。
5. 要点总结
推迟,推迟,再推迟你编写接口的时机,除非你已经对必要的抽象已经有了一个不错的理解了。
对于生产者来说,一个良好的信号就是当你实现了相同方法签名的多个类型时,那么你可以将他们抽象并且返回一个接口。而作为消费者,应该让你的接口足够小,这样你可以拥有多个实现了它的不同类型。