接口定义
# 2. 接口的定义
Go 语言接口定义的惯例:尽量定义“小接口”。
# 2.1 定义“小接口”
接口类型的背后,是通过把类型的行为抽象成契约,建立双方共同遵守的约定,这种契约将双方的耦合降到了最低的程度。
和生活工作中的契约有繁有简,签署方式多样一样,代码间的契约也有多有少,有大有小,而且达成契约的方式也有所不同。 而 Go 选择了去繁就简的形式,这主要体现在以下两点上:
隐式契约,无需签署,自动生效
Go 语言中接口类型与它的实现者之间的关系是隐式的,不需要像其他语言(比如 Java)那样要求实现者显式放置“implements”进行修饰,实现者只需要实现接口方法集合中的全部方法便算是遵守了契约,并立即生效了。
更倾向于“小契约”
如果契约太繁杂了就会束缚了手脚,缺少了灵活性,抑制了表现力。所以 Go 选择了使用“小契约”,表现在代码上就是**尽量定义小接口,即方法个数在 1~3 个之间的接口。**Go 语言之父 Rob Pike 曾说过的“接口越大,抽象程度越弱”,这也是 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)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
上述这些接口的方法数量在 1~3 个之间,这种“小接口”的 Go 惯例也已经被 Go 社区项目广泛采用。
统计早期版本的 Go 标准库(Go 1.13 版本)、Docker 项目(Docker 19.03 版本)以及 Kubernetes 项目(Kubernetes 1.17 版本)中定义的接口类型方法集合中方法数量如下:
无论是 Go 标准库,还是 Go 社区知名项目,它们基本都遵循了“尽量定义小接口”的惯例,接口方法数量在 1~3 范围内的接口占了绝大多数。
# 2.2 小接口的优势
第一点:接口越小,抽象程度越高;
计算机程序本身就是对真实世界的抽象与再建构。抽象就是对同类事物去除它具体的、次要的方面,抽取它相同的、主要的方面。不同的抽象程度,会导致抽象出的概念对应的事物的集合不同。
抽象程度越高,对应的集合空间就越大;抽象程度越低,也就是越具像化,更接近事物真实面貌,对应的集合空间越小。
下面示意图是对生活中不同抽象程度的形象诠释:
三个抽象:
会飞的:它对应的事物集合包括:蝴蝶、蜜蜂、麻雀、天鹅、鸳鸯、海鸥和信天翁;
会游泳的:它对应的事物集合包括:鸭子、海豚、人类、天鹅、鸳鸯、海鸥和信天翁;
会飞且会游泳的:它对应的事物集合包括:天鹅、鸳鸯、海鸥和信天翁。
将上面的抽象转换为 Go 代码:
// 会飞的 type Flyable interface { Fly() } // 会游泳的 type Swimable interface { Swim() } // 会飞且会游泳的 type FlySwimable interface { Flyable Swimable }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15用上述定义的接口替换上图中的抽象,再得到这张示意图:
Flyable 只有一个 Fly 方法,FlySwimable 则包含两个方法 Fly 和 Swim。可以看到,具有更少方法的 Flyable 的抽象程度相对于 FlySwimable 要高,包含的事物集合(7 种动物)也要比 FlySwimable 的事物集合(4 种动物)大。
也就是说,**接口越小(接口方法少),抽象程度越高,对应的事物集合越大。**而这种情况的极限恰恰就是无方法的空接口 interface{},空接口的这个抽象对应的事物集合空间包含了 Go 语言世界的所有事物。
第二点:小接口易于实现和测试;
小接口拥有比较少的方法,一般情况下只有一个方法。所以要想满足这一接口,只需要实现一个方法或者少数几个方法就可以了,这显然要比实现拥有较多方法的接口要容易得多。尤其是在单元测试环节,构建类型去实现只有少量方法的接口要比实现拥有较多方法的接口付出的劳动要少许多。
第三点:小接口表示的“契约”职责单一,易于复用组合。
Go 推崇通过组合的方式构建程序。Go 开发人员一般会尝试通过嵌入其他已有接口类型的方式来构建新接口类型,就像通过嵌入 io.Reader 和 io.Writer 构建 io.ReadWriter 那样。那构建时,如果有众多候选接口类型供我们选择,我们会怎么选择呢?显然,我们会选择那些新接口类型需要的契约职责,同时也要求不要引入我们不需要的契约职责。在这样的情况下,拥有单一或少数方法的小接口便更有可能成为我们的目标,而那些拥有较多方法的大接口,可能会因引入了诸多不需要的契约职责而被放弃。由此可见,小接口更契合 Go 的组合思想,也更容易发挥出组合的威力。
# 2.3 定义接口的原则
首先,不管接口大小,先抽象出接口;
**专注于接口是编写强大而灵活的 Go 代码的关键。**因此,在定义小接口之前,需要先针对问题领域进行深入理解,聚焦抽象并发现接口。先针对领域对象的行为进行抽象,形成一个接口集合:
初期,先不要介意这个接口集合中方法的数量,因为对问题域的理解是循序渐进的,在第一版代码中直接定义出小接口可能并不现实。
而且,标准库中的 io.Reader 和 io.Writer 也不是在 Go 刚诞生时就有的,而是在发现对网络、文件、其他字节数据处理的实现十分相似之后才抽象出来的。并且越偏向业务层,抽象难度就越高,这或许也是 Go 标准库小接口(1~3 个方法)占比略高于 Docker 和 Kubernetes 的原因。
然后,将大接口拆分为小接口;
有了接口后,就会看到接口被用在了代码的各个地方。一段时间后,就来分析哪些场合使用了接口的哪些方法,是否可以将这些场合使用的接口的方法提取出来,放入一个新的小接口中,就像下面图示中的那样:
这张图中的大接口 1 定义了多个方法,一段时间后,发现方法 1 和方法 2 经常用在场合 1 中,方法 3 和方法 4 经常用在场合 2 中,方法 5 和方法 6 经常用在场合 3 中,大接口 1 的方法呈现出一种按业务逻辑自然分组的状态。这个时候我们可以将这三组方法分别提取出来放入三个小接口中,也就是将大接口 1 拆分为三个小接口 A、B 和 C。拆分后,原应用场合 1~3 使用接口 1 的地方就可以无缝替换为接口 A、B、C 了。
最后,要注意接口的单一契约职责。
那么,上面已经被拆分成的小接口是否需要进一步拆分,直至每个接口都只有一个方法呢?这个依然没有标准答案,不过你依然可以考量一下现有小接口是否需要满足单一契约职责,就像 io.Reader 那样。如果需要,就可以进一步拆分,提升抽象程度。