接口是 Go 语言设计和构建代码时的基石之一。然而,就像许多工具或概念一样,滥用它们通常不是一个好主意。接口污染是指用不必要的抽象概念压倒我们的代码,使代码更难理解。这是来自使用不同习惯的另一种语言的开发人员常犯的错误。在深入讨论这个话题之前,让我们回顾一下 Go 的接口。然后,我们将看看何时使用接口是合适的,何时可能被认为是污染。
2.5.1 概念
一个接口提供了一种指定对象行为的方法。我们使用接口来创建多个对象可以实现的共同抽象。Go 接口的独特之处在于它们是隐式满足的,没有像 implements 这样的显式关键字来标记对象 X 实现了接口 Y。
为了理解接口为何如此强大,我们将深入探讨标准库中的两个流行接口:io.Reader 和 io.Writer。io 包为 I/O 原语提供了抽象。在这些抽象中,io.Reader 与从数据源读取数据相关,而 io.Writer 与将数据写入目标相关,如图 2.3 所示。
io.Reader 包含了一个单一的 Read 方法:
type Reader interface {
Read(p []byte) (n int, err error)
}
自定义的 io.Reader 接口实现应该接受一个 byte 切片,用它的数据填充它,并返回读取的字节数或一个错误。
另一方面,io.Writer 定义了一个单一的方法,Write:
type Writer interface {
Write(p []byte) (n int, err error)
}
自定义的 io.Writer 实现应该将来自切片的数据写入目标,并返回写入的字节数或一个错误。因此,这两个接口都提供了基本的抽象:
- io.Reader 从源头读取数据。
- io.Writer 将数据写入目标。
在语言中拥有这两个接口的理由是什么?创建这些抽象的目的是什么?
假设我们需要实现一个函数,该函数应将一个文件的内容复制到另一个文件。我们可以创建一个特定的函数,它将接收两个 *os.File 作为输入。或者,我们可以选择使用 io.Reader 和 io.Writer 抽象来创建一个更通用的函数:
func copySourceToDest(source io.Reader, dest io.Writer) error {
// ...
}
这个函数可以与 *os.File 参数一起工作(因为 *os.File 实现了 io.Reader 和 io.Writer),也可以与实现这些接口的任何其他类型一起工作。例如,我们可以创建我们自己的 io.Writer,它将数据写入数据库,而代码将保持不变。它增加了函数的通用性;因此,提高了其可重用性。
此外,为这个函数编写单元测试更容易,因为我们不必处理文件,而是可以使用提供有用实现的 strings 和 bytes 包:
func TestCopySourceToDest(t *testing.T) {
const input = "foo"
source := strings.NewReader(input) // Creates an io.Reader
dest := bytes.NewBuffer(make([]byte, 0)) // Creates an io.Writer
err := copySourceToDest(source, dest) // Calls copySourceToDest from a *strings.Reader and a *bytes.Buffer
if err != nil {
t.FailNow()
}
got := dest.String()
if got != input {
t.Errorf("expected: %s, got: %s", input, got)
}
}
在示例中,source 是一个 *strings.Reader 类型,而 dest 是一个 *bytes.Buffer 类型。在这里,我们测试 copySourceToDest 的行为而无需创建任何文件。
在设计接口时,粒度(接口包含的方法数量)也是一个需要考虑的因素。在设计接口时,一个著名的 Go 语言谚语(www.youtube .com/watch?v=PAAkCSZUG1c&t=318s)与接口应该有多大有关:
接口越大,抽象越弱。
—— Rob Pike
确实,向接口添加方法可能会降低其可重用性。io.Reader 和 io.Writer 是强大的抽象,因为它们不能再更简单了。此外,我们还可以组合细粒度的接口来创建更高级别的抽象。io.ReadWriter 就是这样一个例子,它结合了读取和写入的行为:
type ReadWriter interface {
Reader
Writer
}
正如爱因斯坦所说,“事物应该尽可能简单,但不应过于简化。” 应用到接口上,这意味着为接口找到完美的粒度并不是一个简单的过程。
现在让我们讨论推荐使用接口的常见情况。
2.5.2 何时使用接口
在 Go 中我们应该何时创建接口?让我们来看看三个通常被认为可以带来价值的接口使用的具体情况。请注意,我们的目标并不是要面面俱到,因为增加的情况越多,它们就越依赖于上下文。然而,这三个案例应该能给我们一个大致的概念:
- 通用行为
- 解耦
- 限制行为
通用行为
我们要讨论的第一个选项是,当多个类型实现了一个通用行为时,使用接口来封装这一行为。在这种情况下,我们可以在一个接口中提取出这种行为。如果我们查看标准库,我们可以找到许多这样的用例。例如,对一个集合进行排序可以通过三种方法来抽象化:
- 检索集合中元素的数量
- 报告一个元素是否需要在另一个元素之前排序
- 交换两个元素
因此,sort 包中添加了以下接口:
type Interface interface {
Len() int // Number of elements
Less(i, j int) bool // Checks two elements
Swap(i, j int) // Swaps two elements
}
这个接口具有很强的可重用性潜力,因为它包含了基于索引的任何集合排序所需的通用行为。
在整个 sort 包中,我们可以找到几十种实现。如果在某个时候我们计算了一系列整数,并且我们想要对它进行排序,我们是否一定对实现类型感兴趣?排序算法是归并排序还是快速排序重要吗?在许多情况下,我们并不关心。因此,排序行为可以被抽象化,我们可以依赖于 sort.Interface 接口。
func IsSorted(data Interface) bool {
n := data.Len()
for i := n - 1; i > 0; i-- {
if data.Less(i, i-1) {
return false
}
}
return true
}
因为 sort.Interface 是正确的抽象级别,它因此变得非常有价值。
解耦
另一个重要的用例是将我们的代码与具体实现解耦。如果我们依赖于抽象而不是具体实现,那么实现本身可以被另一个实现替换,甚至不需要更改我们的代码。这是里氏替换原则(Liskov Substitution Principle,罗伯特·C·马丁的 SOLID 设计原则中的 "L")。
解耦的一个好处可以与单元测试相关。假设我们想要实现一个 CreateNewCustomer 方法,该方法创建一个新客户并将其存储。我们决定直接依赖于具体实现(假设是一个 mysql.Store 结构体):
type CustomerService struct {
store mysql.Store // 依赖于具体实现
}
func (cs CustomerService) CreateNewCustomer(id string) error {
customer := Customer{id: id}
return cs.store.StoreCustomer(customer)
}
如果我们想要测试这个方法,该怎么办?因为 customerService 依赖于实际的实现来存储一个 Customer,我们不得不通过集成测试来测试它,这需要启动一个 MySQL 实例(除非我们使用如 go-sqlmock 这样的替代技术,但这不属于本节讨论的范围)。尽管集成测试很有帮助,但并不总是我们想要做的。为了给我们更多的灵活性,我们应该将 CustomerService 从实际实现中解耦,这可以通过像下面这样的接口来实现:
type customerStorer interface { // 创建了一个存储抽象
StoreCustomer(Customer) error
}
type CustomerService struct {
storer customerStorer // 解耦了 CustomerService 与实际实现
}
func (cs CustomerService) CreateNewCustomer(id string) error {
customer := Customer{id: id}
return cs.storer.StoreCustomer(customer)
}
因为现在存储客户是通过接口完成的,这给了我们更多灵活性来决定如何测试这个方法。例如,我们可以:
- 通过集成测试使用具体实现
- 通过单元测试使用模拟对象(或任何类型的测试替身)
- 或者两者都用
限制行为
最后一个我们要讨论的用例乍一看可能相当违反直觉。它是关于将类型限制为特定行为。让我们想象一下,我们实现了一个自定义的配置包来处理动态配置。我们通过一个 IntConfig 结构体为整数配置创建了一个特定的容器,该结构体还公开了两个方法:Get 和 Set。以下是那段代码的样子:
type IntConfig struct {
// ...
}
func (c *IntConfig) Get() int {
// Retrieve configuration
}
func (c *IntConfig) Set(value int) {
// Update configuration
}
现在,假设我们收到一个包含某些特定配置的 IntConfig,例如阈值。然而,在我们的代码中,我们只对检索配置值感兴趣,并希望防止更新它。如果我们不想改变我们的配置包,我们如何从语义上强制这个配置是只读的?通过创建一个抽象,将行为限制为仅检索配置值:
type intConfigGetter interface {
Get() int
}
在我们的代码中,我们可以依赖 intConfigGetter 接口而不是具体的实现。
type Foo struct {
threshold intConfigGetter
}
func NewFoo(threshold intConfigGetter) Foo {
return Foo{threshold: threshold}
}
func (f Foo) Bar() {
threshold := f.threshold.Get()
// ...
}
在这个示例中,配置获取器被注入到 NewFoo 工厂方法中。它不会影响这个函数的客户端,因为客户端仍然可以传递一个实现了 intConfigGetter 接口的 IntConfig 结构体。然后,在 Bar 方法中,我们只能读取配置,而不能修改它。因此,我们也可以出于各种原因,比如语义执行,使用接口来限制类型只能展现特定的行为。
在本节中,我们看到了三个通常被认为可以带来价值的接口使用的潜在用例:提取通用行为、创建解耦以及限制类型为特定行为。再次强调,这个列表并不是详尽无遗的,但它应该能够让我们大致了解在 Go 中何时使用接口是有帮助的。
2.5.3 接口污染
在 Go 项目中过度使用接口是相当常见的。也许是因为开发者的背景是 C# 或 Java,他们发现在具体类型之前创建接口是很自然的。然而,这并不是 Go 中应该的工作方式。
正如我们所讨论的,接口是用来创建抽象的。当编程遇到抽象时,主要的注意事项是记住应该发现抽象,而不是创建抽象。这是什么意思?这意味着如果没有必要,我们不应该在代码中开始创建抽象。我们不应该用接口来设计,而是等待具体的需求。换句话说,我们应该在需要时创建接口,而不是在我们预见到可能需要它时。
如果我们过度使用接口,主要问题是什么?答案是它们使代码流程更加复杂。增加一个无用的间接层次不会带来任何价值;它创造了一个毫无价值的抽象,使代码更难阅读、理解,并进行推理。如果我们没有充分的理由添加一个接口,并且不清楚接口如何使代码变得更好,我们应该质疑这个接口的目的。为什么不直接调用实现呢?
我们通过接口调用方法时,也可能遇到性能开销。这需要在哈希表的数据结构中查找接口指向的具体类型。但在许多情况下,这并不是一个问题,因为开销是最小的。
总之,我们在代码中创建抽象时应保持谨慎——应该发现抽象,而不是创造抽象。我们这些软件开发人员常常通过尝试猜测完美的抽象层次,基于我们认为以后可能需要的东西,来过度工程化我们的代码。应该避免这个过程,因为在大多数情况下,它会用不必要的抽象污染我们的代码,使其阅读起来更加复杂。
不要设计接口,而是发现它们。 ——— Rob Pike
不要试图抽象地解决问题,而是解决当前需要解决的问题。最后但同样重要的是,如果不清楚接口如何使代码变得更好,我们可能应该考虑删除它,使我们的代码更简单。