vlambda博客
学习文章列表

Go语言中的接口暴露

【导读】golang编程中,接口怎么设计、怎么写?接口设计方面有什么社区推荐的方法和思路?本文作者结合实践经验做了详细介绍。

接口是Go语言里我最喜欢的特性。一个接口类型代表一组方法。与其他大多数语言不同,你不必明确声明一个类型实现了一个接口。如果一个结构体S定义了I所要求的方法,它就隐含地实现了接口I

把接口写得好并不容易。通过暴露广泛的或不必要的接口,很容易污染包的 "API"。在这篇文章中,我们将解释现有的接口指南背后的道理,并以标准库中的例子作为补充。

接口越大,抽象性越弱

你不太可能找到多个可以实现一个大型接口的类型。由于这个原因,"只有一到两个方法的接口在Go代码中很常见"。与其声明大型公共接口,不如考虑使用依赖或是返回一个确定类型。

io.Readerio.Writer接口是强壮接口的比较好的例子:

type Reader interface {
 Read(p []byte) (n int, err error)
}

检索了std库之后,我看到有30个包内81个结构体实现了io.Reader,被99个方法在39个包里调用到。

Go接口一般属于使用接口类型值的包,而不是实现这些值的包

在实际使用接口的包内定义接口,这样可以让使用方定义抽象,而不是让提供接口的一方规定接口的所有抽象。io.Copy方法就是个例子,它在可以接收WriterReader接口:

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)只暴露了SourceSource64所需的方法,因此不再需要暴露这个结构体。

返回一个接口而不是返回某个类型对象,这样做有什么好处?

返回一个接口的做法能让你在函数里返回多个实际类型。比如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,慢慢地需求可能就会变成需要返回更加明确的类型了。

我假设比较好的实践是这样的:

  1. 返回的接口需要足够小,这样就可以返回多个实现的实际类型。
  2. 在你的包里有多个类型只实现了这个接口之前,暂不返回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
}

我认为把接口放进一个独立包有以下两方面原因:

  1. 给接口提供一个更好的命名空间。
  2. 把实现功能这件事标准化。一个只有接口的独立包暗示着哈希函数应该有 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资源库



分享、点赞和在看

支持我们分享更多好文章,谢谢!