Go语言中的接口暴露
【导读】golang编程中,接口怎么设计、怎么写?接口设计方面有什么社区推荐的方法和思路?本文作者结合实践经验做了详细介绍。
接口是Go语言里我最喜欢的特性。一个接口类型代表一组方法。与其他大多数语言不同,你不必明确声明一个类型实现了一个接口。如果一个结构体S
定义了I
所要求的方法,它就隐含地实现了接口I
。
把接口写得好并不容易。通过暴露广泛的或不必要的接口,很容易污染包的 "API"。在这篇文章中,我们将解释现有的接口指南背后的道理,并以标准库中的例子作为补充。
接口越大,抽象性越弱
你不太可能找到多个可以实现一个大型接口的类型。由于这个原因,"只有一到两个方法的接口在Go代码中很常见"。与其声明大型公共接口,不如考虑使用依赖或是返回一个确定类型。
io.Reader
和io.Writer
接口是强壮接口的比较好的例子:
type Reader interface {
Read(p []byte) (n int, err error)
}
检索了std库之后,我看到有30个包内81个结构体实现了io.Reader
,被99个方法在39个包里调用到。
Go接口一般属于使用接口类型值的包,而不是实现这些值的包
在实际使用接口的包内定义接口,这样可以让使用方定义抽象,而不是让提供接口的一方规定接口的所有抽象。io.Copy
方法就是个例子,它在可以接收Writer
和Reader
接口:
func Copy(dst Writer, src Reader) (written int64, err error)
另一个包里的例子是color.Color
接口。color.Palette
类型的Index
方法就是可以接收任意实现了Color
接口的结构体:
func (p Palette) Index(c Color) int
如果一个类型的存在只是为了实现一个接口,并且永远不会有超出该接口暴露方法,那么就没有必要暴露该类型本身
这条原则在代码review(https://github.com/golang/go/wiki/CodeReviewComments#interfaces)一文中提及:
实现包应该返回具体的(通常是指针或结构)类型:这样一来,新的方法可以被添加到实现中,而不需要大量的重构。
配合EffectiveGo
中的说明,我们可以看到在提供方的包内定义一个接口的全貌。
如果一个类型的存在只是为了实现一个接口,并且永远不会有超出该接口暴露方法,那么就没有必要暴露该类型本身。
rand.Source
接口是一个例子,它会作为rand.NewSource
方法的返回值使用,返回这个接口。其中的rngSource结构体(https://github.com/golang/go/blob/dcd3b2c173b77d93be1c391e3b5f932e0779fb1f/src/math/rand/rng.go#L180)只暴露了Source
和Source64
所需的方法,因此不再需要暴露这个结构体。
返回一个接口而不是返回某个类型对象,这样做有什么好处?
返回一个接口的做法能让你在函数里返回多个实际类型。比如aes.NewSiper
这个构造函数返回了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
例子里,接口就是在提供方包rand
内定义的。但是这个例子内接口跑到了另一个ciper
包里定义。
在我的实践中,在不同包中定义接口比在同一个包中定义接口要难维护得多。早期开发过程中对调用方的需求会快速迭代,迭代也会体现到定义方包的代码里。如果返回值只是个interface,慢慢地需求可能就会变成需要返回更加明确的类型了。
我假设比较好的实践是这样的:
-
返回的接口需要足够小,这样就可以返回多个实现的实际类型。 -
在你的包里有多个类型只实现了这个接口之前,暂不返回interface。
创建一个单独的接口专用包,实现命名和标准化。
注意这并不是Go语言团队推荐的做法,这是我的一个观察结果。在标准库里有很多创建一个包只放接口的做法。
hash.Hash
接口就是一个例子,它就是一只暴露接口的包:https://golang.org/pkg/hash/
package hash
type Hash interface {
...
}
type Hash32 interface {
Hash
Sum32() uint32
}
type Hash64 interface {
Hash
Sum64() uint64
}
我认为把接口放进一个独立包有以下两方面原因:
-
给接口提供一个更好的命名空间。 -
把实现功能这件事标准化。一个只有接口的独立包暗示着哈希函数应该有 hash.Hash
接口所要求的方法。
另一个例子是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
}
在std库中有很多实现了encoding
接口的结构体。与被crypto/
下的包使用的hash接口不同,std库中没有接受或返回编码接口的函数。
那么为什么还要暴露这个接口呢?
我认为这是encoding
包想要给开发者提供一种比较标准化的接口定义方式,希望所有序列化反序列化都如此实现。如果一个新的结构实现了该接口,下面这种写法判断是否实现了encoding.BinaryMarshaler
的逻辑都不需要改变其实现:
if m, ok := v.(encoding.BinaryMarshaler); ok {
return m.MarshalBinary()
}
值得注意的是,在compress/zlib
和compress/flate
软件包中的Resetter
接口没有遵循这个模式,因为它在这两个软件包中是重复的。关于这点Go语言维护者也有过相关讨论(https://codereview.appspot.com/97140043#msg27)。
私有接口不需要考虑前面提到的这些问题,反正这些接口也不会暴露出来
我们可以写更大的接口而不用担心其内容,比如encoding/gob
包的gobType
。接口可以在不同的包中重复使用,比如同时存在于os
包和net
包中的timeout
接口,而不用考虑把它们放在一个单独的位置。
经验之谈
建议最后再写接口,这时候一般你会对接口有一个比较好的理解和抽象的设计。
对于一个方法提供方,一个好的信号就是多个类型都实现了同一组方法,这时候重构出一个接口就很合适。对于接口的实现方,要保持接口足够小、足以让多种结构体都实现这些方法。
- EOF -
1、
2、
3、
Go 开发大全
参与维护一个非常全面的Go开源技术资源库。日常分享 Go, 云原生、k8s、Docker和微服务方面的技术文章和行业动态。
关注后获取
回复 Go 获取6万star的Go资源库
分享、点赞和在看
支持我们分享更多好文章,谢谢!