Skip to content

Latest commit

 

History

History
146 lines (90 loc) · 9.05 KB

18 定义小接口是 Go 的惯例.md

File metadata and controls

146 lines (90 loc) · 9.05 KB

18 定义小接口是 Go 的惯例

定义小接口是 Go 的惯例

接口越大,抽象程度越弱 - Rob Pike,Go 语言之父

1. Go 推荐定义“小接口”

接口背后的概念是通过将对象的行为抽象为契约来允许重用。契约有繁有简,Go 选择了去繁就简,这主要体现在以下两点上:

  • 契约的自动遵守:Go 语言中接口与其实现者之间的关系是隐式的,无需像其他语言(比如:Java)那样要求实现者显式放置"implements"声明;实现者仅需实现接口方法集中的全部方法,便算是自动遵守了契约,实现了该接口;
  • 小契约:契约繁了便束缚了手脚,缺少了灵活性,抑制了表现力。Go 选择了使用 ”小契约“,表现在代码上便是尽量定义小接口

下面是 Go 标准库中的一些常用接口的定义:

// $GOROOT/src/builtin/builtin.go
type error interface {
    Error() string
}

// $GOROOT/src/io/io.go
type Reader interface {
    Read(p []byte) (n int, err error)
}

// $GOROOT/src/net/http/server.go
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

type ResponseWriter interface {
    Header() Header
    Write([]byte) (int, error)
    WriteHeader(int)
} 

我们看到上述这些接口的方法数量在 1~3 个之间,这种”小接口“的 Go 最佳实践已被 Go 程序员和各个社区项目广泛采用。下面是对 Go 标准库(go 1.13 版本)、Docker 项目(docker 19.03 版本)以及Kubernetes 项目(k8s 1.17 版本)中定义的接口的方法数的统计1数据折线图(X 轴为方法数量,Y 轴是接口数量):

18 定义小接口是 Go 的惯例

图5-1-1:接口方法数统计数据

从图中我们可以看到无论是标准库,还是社区项目,都遵循了”尽量定义小接口“的建议,接口方法数量在 1~3 范围内的接口占了绝大多数。下面是每个项目的接口方法数量占比的柱状图,对比起来更直观一些:

18 定义小接口是 Go 的惯例

图5-1-2:接口方法数占比数据

2. 小接口的优势

“小接口”受到 Gopher 青睐是因为它有以下几点优势:

a) 接口越小,抽象程度越高,被接纳程度越好

计算机程序本身就是对真实世界的抽象与再建构。抽象是对同类事物去除其现象的、次要的方面,抽取其相同的、主要的方面的方法。不同的抽象程度,会导致抽象出的概念对应的事物的集合不同。抽象程度越高,对应的集合空间越大;抽象程度越低(即越具像化,更接近事物真实面貌),对应的集合空间越小。下面的示意图就是对不同抽象程度的形象诠释:

image-20220113175341349

图5-1-3:抽象概念示意图

上图中我们分别建立了三个抽象:

  • 会飞的 - 对应的事物集合包括:蝴蝶、蜜蜂、麻雀、天鹅、鸳鸯、海鸥和信天翁;
  • 会游泳的 - 对应的事物集合包括:鸭子、海豚、人类、天鹅、鸳鸯、海鸥和信天翁;
  • 会飞会游泳的 - 对应的事物集合包括:天鹅、鸳鸯、海鸥和信天翁。

我们看到“会飞的”、“会游泳的”这两个抽象对应的事物集合要大于“会飞会游泳的”所对应的事物集合空间,也就是说“会飞的”、“会游泳的”这两个抽象程度更高。

我们将上面的抽象转换为 Go 代码:

// 会飞的
type Flyable interface {
	Fly()
}

// 会游泳的
type Swimable interface {
	Swim()
}

// 会飞会游泳的
type FlySwimable interface {
	Flyable
	Swimable
} 

我们用上述定义的接口替换上图中的抽象得到下面示意图:

image-20220113175435179

图5-1-4:接口抽象示意图

我们可以直观看到:接口越小(接口方法少),抽象程度越高,对应的事物集合越大,即被事物的接受程度越高。而这种情况的极限恰是无方法的空接口 interface{},空接口的这个抽象对应的事物集合空间包含了 Go 语言世界的所有事物。

b) 易于实现和测试

这是一个显而易见的优点。小接口拥有较少的方法,一般情况仅一个方法。要想满足这一接口,我们仅需实现一个方法或少数几个方法即可,这显然要比实现拥有较多方法的接口要容易的多。尤其是在单元测试环节,构建类型去实现仅有少量方法的接口要比实现拥有较多方法的接口(快速实现拥有较多方法的接口以满足测试的技巧见条目 25)付出的努力要少许多。

###c) 契约职责单一,易于复用组合

Go 的设计原则推崇通过组合的方式构建程序。Go 开发人员一般会首先尝试通过嵌入其他已有接口类型的方式来构建新接口类型,就像通过嵌入 io.Reader 和 io.Writer 构建 io.ReadWriter 那样。

如果有众多候选接口类型供我们选择,我们会怎么选择呢?显然,我们会选择那些新接口类型需要的契约职责,但又要求不要引入我们不需要的契约职责。在这样的情况下,拥有单一或少数方法的小接口便更有可能成为我们的目标,而那些拥有较多方法的大接口则多会因引入了诸多不需要的契约职责而被放弃。因此可见,小接口更契合 Go 的组合思想,也更容易发挥出组合的威力。

3. 定义小接口,你可以遵循的几点

保持简单有时候比复杂更难。小接口虽好,但如何定义出小接口是摆在所有 Gopher 面前的一道难题。这道题没有标准答案,但有一些点可供大家实践中考量遵循。

  • 先抽象出接口

要设计和定义出小接口,需要先有接口。

Go 语言还比较年轻,其设计哲学和推崇的编程理念可能还未被广大 Gopher 100%理解、接纳和应用于实践当中,尤其是 Go 所推崇的基于接口的组合思想。尽管接口不是 Go 独有的,但专注于接口是编写强大而灵活的 Go 代码的关键。因此,在定义小接口之前,我们需要首先针对问题领域进行深入理解,聚焦抽象并发现接口。

image-20220113175513877

图5-1-5:由问题域抽象出接口

初期先不要介意接口的大小,因为对问题域的理解是循序渐进的,在第一版代码中直接定义出小接口可能并不现实。标准库中的 io.Reader 和 io.Writer 也不是在 Go 刚诞生时就有的,而是在发现对网络、文件、其他字节数据处理的实现十分相似之后才抽象出来的。并且越偏向业务层,抽象难度越高,这或许也是前面图中 Go 标准库小接口(1~3 个方法)占比略高于 Docker 和 Kubernetes 的原因。

  • 将大接口拆分为小接口

有了接口后,我们就会看到接口被用在代码各个地方。一段时间后,我们就来分析哪些场合使用了接口的哪些方法,是否可以将这些场合使用的接口的方法提取出来放入一个新的小接口中,就像下面图示中的那样:

image-20220113175614958

图5-1-6:大接口拆分为小接口

上图中大接口 1 定义了多个方法,一段时间后,我们发现方法 1 和方法 2 经常用在场合 1 中;方法 3 和方法 4 经常用在场合 2 中;方法 5 和方法 6 经常用在场合 3 中,大接口 1 的方法呈现出一种按业务逻辑自然分组的状态。于是我们将这三组方法分别提取出来放入三个小接口中,即将大接口 1 拆分为三个小接口 A、B 和 C。拆分后,原应用场合 1~3 使用接口 1 的地方可以无缝替换为接口 A、B、C 了。

  • 接口的单一契约职责

上面已经被拆分成的小接口是否需要进一步拆分直至每个接口只有一个方法呢?这个依然没有标准答案,不过大家依然可以考量一下现有小接口是否需要满足单一契约职责,就像 io.Reader 那样。如果需要,则可进一步拆分,提升抽象程度。

4. 小结

本节要点:

  • 接口是通过将对象的行为抽象为用于重用的契约;
  • Go 惯用法青睐定义“小接口”,即方法数量 1 到 3 个,通常为一个的接口;这种最佳实践被 Go 社区项目广泛采纳;
  • “小接口”抽象程度高,被接纳程度好,易于实现和测试,易于复用组合;
  • 先抽象出接口,再拆分为小接口;接口的契约职责尽可能保持单一。