2.5#5:避免接口污染
在设计和构造代码时,接口是 Go 语言的基石之一。然而,就像许多工具或概念一样,滥用它们通常不是一个好主意。接口污染就是用不必要的抽象使我们的代码变得难以理解。这是来自另一种编程语言具有不同习惯的开发人员经常犯的错误。在深入讨论这个话题之前,让我们重新思考一下 Go的接口。然后,我们将看到什么时候使用接口是合适的,什么时候可能被认为是污染。
2.5.1 概念
接口提供了一种方法来指定对象的行为。我们使用接口来创建多个对象实现的公共抽象。Go 接口与其他一些接口的不同之处在于它们是隐式的,没有像 implements 这样的显式关键字来标记对象 X 实现了接口 Y。
为了理解是什么使接口如此强大,我们将深入研究标准库中的两个流行接口:io.Reader和io.Writer。io 包为 I/O 原语提供抽象。在这些抽象概念中,io.Reader涉及从数据源读取数据,io.Writer 向目标写入数据,如图2.3所示。
图2.3 io.Reader从数据源读取到数据并填充到字节切片,而io.Writer从字节切片将数据写入目标
io.Reader 只包含一个Read 方法:
io.Reader 接口的自定义实现是接收了一个字节切片,用接收的数据填充字节切片,并返回读取的字节数或一个错误。
另一方面,io.Writer定义了一个方法Write:
io.Writer 接口的自定义实现是将来自字节片的数据写入目标,并返回写入的字节数或一个错误。因此,这两个接口都提供了基本的抽象:
■ io.Reader 从源读取数据。
■ io.Writer 将数据写入目标。
在该语言中使用这两个接口的基本原理是什么?创建这些抽象的意义是什么?
假设我们需要实现一个函数,该函数将一个文件的内容复制到另一个文件。我们可以创建一个特定的函数,将两个*os.Files 作为输入。或者,我们可以选择使用io.Reader和 io.Writer 抽象创建一个更泛型的函数:
这个函数可以与参数*os.File(如*os.File 同时实现 io.Reader和io.Writer)及实现这些接口的任何其他类型一起工作。例如,我们可以创建自己的写入数据库的io.Writer,代码将保持不变。它增加了函数的泛型;因此,它具有可重用性。
此外,为这个函数编写单元测试更容易,因为不必处理文件,我们可以使用有助于实现的Strings和bytes包:
在本例中,source 是一个*strings.Reader,而 dest 是一个*bytes.Buffer。在这里,我们在不创建任何文件的情况下测试 copySourceToDest的行为。
在设计接口时,粒度(接口包含多少方法)也是需要被重点考虑的。Go 中有一句谚语(参见链接9)是关于接口应该是多大的:
接口越大,抽象越弱。
—Rob Pike
实际上,向接口添加方法会降低其可重用性水平。io.Reader和io.Writer 是强大的抽象,因为它们不能再简单了。此外,我们还可以结合细粒度的接口来创建更高级别的抽象。io.ReadWriter 就是这样的,它结合了读和写的行为:
注意 正如一句名言所说:一切都应该尽可能地简单,但不应该过于简单。应用于接口,这意味着为接口找到完美的粒度不一定是一个简单的过程。
现在让我们讨论推荐的使用接口的常见情况。
2.5.2 何时使用接口
我们应该什么时候在Go 中创建接口呢?让我们看三个具体的例子,接口通常被认为可以带来价值。注意,我们的目标不是穷尽无遗,因为我们添加的例子越多,它们就越依赖于上下文。不过,这三个例子应该能给我们一个大致的概念:
■ 常见的行为
■ 解耦
■ 限制行为
常见的行为
我们讨论的第一个用例是,在多个类型实现一个公共行为时使用接口。在这种情况下,我们可以分解出接口内部的行为。如果查看标准库,可以找到许多这种用例。例如,可以通过以下三种方法对排序进行分解:
■ 检索集合中元素的数量。
■ 报告一个元素是否必须排在另一个元素之前。
■ 交换两个元素。
因此,下面的接口被添加到sort序包中:
这个接口具有很强的可重用性,因为它包含了对任何基于索引的集合进行排序的常见行为。
在整个sort包中,我们可以找到几十个实现。例如,如果在某个时刻计算一个整数集合,并想对它排序,那么我们是否一定要对实现类型感兴趣呢?排序算法是归并排序还是快速排序重要吗?在很多情况下,我们不在乎。因此,排序行为可以被抽象,并且可以依赖sort.interface。
找到正确的抽象来分解一个行为也会带来很多好处。例如,sort 包提供了同样依赖于sort.Interface的实用函数,例如检查集合是否已经被排序。例如,
因为 sort.Interface 是正确的抽象级别,所以它非常有价值。
现在让我们看看使用接口时的另一个主要用例。
解耦
另一个重要的用例是关于将代码与实现分离的。如果我们依赖抽象而不是具体的实现,实现本身就可以用另一个实现替换,甚至不需要更改代码。这就是 Liskov 替换原则(Robert C.Martin的SOLID 设计原则中的L)。
解耦的一个好处与单元测试有关。假设我们想要实现一个CreateNewCustomer 方法,该方法创建一个新消费者并存储它。我们决定直接依赖具体实现(比如 mysql.Store结构体):
现在,如果我们想测试这个方法呢?因为 customerService 依赖于实际实现来存储Customer,所以必须通过集成测试来测试它,这需要启动一个MySQL 实例(除非使用go-sqlmock 等替代技术,但这不在本节的讨论范围内)。尽管集成测试很有帮助,但这并不总是我们想要做的。为了提供更多的灵活性,应该将 CustomerService 从实际的实现中分离出来,可以通过这样的接口来完成:
因为存储消费者现在是通过接口来完成的,所以这在测试方法的实现方式上给了我们更大的灵活性。例如,可以
■ 通过集成测试使用具体实现。
■ 通过单元测试使用模拟(或任何类型的双重测试)。
■ 以上两者都可以。
现在让我们讨论另一个用例:限制行为。
限制行为
我们将要讨论的最后一个用例乍一看可能非常不可思议。它是关于将一种类型限制到特定的行为的。假设我们实现了一个自定义配置包来处理动态配置。我们通过一个IntConfig结构体为 int 配置创建一个特定的容器,该结构体还公开了两个方法:Get和Set。下面是代码:
现在,假设我们收到一个IntConfig,它包含一些特定的配置,比如阈值。然而,在代码中,我们只对检索配置值感兴趣,并且希望阻止它的更新。如果不想改变配置包,我们将如何从语义上强制这个配置是只读的?可通过创建一个抽象来限制行为只检索配置值:
然后,在代码中,我们可以依赖 intConfigGetter 而不是具体的实现:
在本例中,配置 getter 被注入 NewFoo 工厂方法。它不会影响这个函数的客户端,因为它仍然可以在实现 intConfigGetter 时传递一个IntConfig 结构体。然后,我们只能读取 Bar 方法中的配置,而不能修改它。因此,我们也可以使用接口将类型限制为特定的行为,这样做的原因有很多,比如语义强制。
在本节中,我们看到了三个潜在的用例,在这些用例中,接口通常被认为是有价值的:分解出公共行为、创建一些解耦及将类型限制为特定的行为。同样,这个列表并不是详尽的,但它能让我们大致了解接口在Go 中什么时候是有用的。
现在,让我们结束这一节,讨论接口污染问题。
2.5.3 接口污染
在Go项目中过度使用接口是很常见的。也许开发人员的工作背景是 C#或 Java,他们发现在创建具体类型之前创建接口是很自然的。然而,在Go 中是不应该这样工作的。
正如我们所讨论的,创建接口是为了创建抽象。当编程时遇到抽象,主要的注意事项是记住应该发现抽象,而不是创建抽象。这是什么意思?这意味着如果没有直接的理由,不应该在代码中创建抽象。我们不应该用接口来设计,而应该等待具体的需求。换句话说,应该在需要的时候创建一个接口,而不是在预见到可能需要它的时候。
如果过度使用接口,带来的主要问题是什么?答案是,它们使代码流更加复杂。添加一个无用的间接层面并不会带来任何价值;它创建了一个毫无价值的抽象,使代码更难以阅读、理解和推理。如果没有充分的理由添加接口,也不清楚接口如何使代码变得更好,那么我们应该质疑这个接口的目的。为什么不直接调用实现呢?
注意 当通过接口调用方法时,也可能会遇到性能开销。这需要在哈希表的数据结构中查找,以找到接口所指向的具体类型。但这在很多情况下都不是问题,因为开销很小。
总之,当我们在代码中创建抽象时,应该谨慎,应该发现抽象,而不是创建抽象。对于软件开发人员来说,过度设计代码是很常见的,因为我们总是试图根据我们认为以后可能需要的东西来猜测完美的抽象级别是什么。应该避免这个过程,因为在大多数情况下,它会用不必要的抽象污染我们的代码,使代码变得更加复杂。
不要用接口进行设计,要发现它们。
—Rob Pike
不要试图抽象地解决问题,而是应解决现在必须解决的问题。最后,但同样重要的是,如果不清楚接口如何使代码变得更好,我们可能应该考虑删除它,以使代码更简单。
下一节继续讨论这个线程,并讨论一个常见的接口错误:在生产者端创建接口。