本文翻译自:Exposing interfaces in Go


0. 概述

接口 是 Go 语言中我最喜欢的特性。所谓的接口,其实就是一系列方法的集合。和其他程序语言不同,你不需要特别申明说一种类型实现了某个接口,实现接口 I 的方式只需要在结构体 S 中实现所有定义在接口 I 中的方法即是。

编写一个良好的接口是非常困难的,你很容易就可能因为暴露一个太过宽泛的和不必要的接口,从而“污染”了 API。在这篇文章中,我将以标准库中接口为例,阐述接口设计的指导思想。

1. 接口越大,抽象越弱

你不大可能找到多种不同的对象,他们都实现了一个大的接口,因为,“在 Go 的代码中,接口通常只会包含一到两个方法”。所以,当你定义了一个包含很多公开方法的接口时,那么你应该考虑通过拆分依赖或者直接返回一个具体的类型来替代。

io.Readerio.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 仅仅只是暴露 SourceSource64 这两个接口的方法,所以它自身没有必要暴露。

rand package 中有两个其他类型也实现了这个接口:lockedSourceRand(这个是可以暴露的因为它还包含了其他的公开方法)。

通过接口返回具体类型的好处是什么?

返回一个接口可以让你的函数返回不同的具体类型。例如,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。如果返回类型是一个接口,慢慢地这个接口就会变得太大,从而到一个返回具体的类型可能更有意义的情况。

我关于这种模式机制的假设是:

4. 创建一个单独的只有 interface 的 package 作为命令空间和标准

这不是 Go team 的一个官方指导,只是我在标准库中观察到的一个通用模式,有些 package 仅仅包含了接口。

例如,hash.Hash 接口就被在 hash/ 下的 hash/crc32hash/adler32 实现,而 hash package 只暴露接口。

package hash

type Hash interface {
  ...
}

type Hash32 interface {
    Hash
    Sum32() uint32
}

type Hash64 interface {
    Hash
    Sum64() uint64
}

我认为将接口移到一个单独的 package,将不是放在子目录中有这些好处:

另外一个只包含接口的 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/zlibcompress/flate package 中的 Resetter 效仿,他们而是在各自的 package 中重复地定义了。但是,这已经在 Go 维护者们的讨论中了(见 CR comment#27)。

5. 要点总结

推迟,推迟,再推迟你编写接口的时机,除非你已经对必要的抽象已经有了一个不错的理解了。

对于生产者来说,一个良好的信号就是当你实现了相同方法签名的多个类型时,那么你可以将他们抽象并且返回一个接口。而作为消费者,应该让你的接口足够小,这样你可以拥有多个实现了它的不同类型。