Go 语言推崇面向组合编程,而接口是 Go 语言中实践组合编程的重要手段。这篇文章深入介绍 Go 的接口。
了解接口类型变量的内部表示
接口是 Go 这门静态类型语言中唯一 动静兼备
的语言特性。接口的静态特性:
- 接口类型变量具有静态类型,例如
var e error
中 e 的静态类型为 error - 支持在编译阶段的类型检查:当一个接口类型变量被赋值时,编译器会检查右值的类型是否实现了该接口方法集合中的所有方法
接口的动态特性:
- 接口类型变量兼具动态类型,即在运行时存储在接口类型变量中的值的真实类型。
- 接口类型变量在程序运行时可以被复制为不同的动态类型变量,从而支持运行时多态。接口的动态特性让 Go 语言可以像纯动态语言(如 Python)一样拥有
鸭子类型
的灵活性,同时接口的静态特性还能保证动态特性
使用时的安全性
nil error 值 != nil
首先来看一段代码:
1 | package main |
1 | # ./main |
接口类型变量的内部表示
接口类型变量在运行时的内部表示如下:
1 | type eface struct { |
可以看到,在运行时层面接口类型变量有两种内部表示:
- eface:用于表示没有方法的空接口(empty interface)类型变量,即 interface{} 类型的变量
- iface:用于表示其余拥有方法的接口类型变量
未显示初始化的接口类型的变量的值为 nil,即该变量的 _type/tab
和 data 都为 nil。这样我们要判断两个接口类型变量是否相同,只需要判断 _type/tab
是否相同以及 data 指针所指向的内存空间所存储的数据是否相同即可(注意不是 data 指针的值)。
如下是一个实例程序:
1 | package main |
1 | # ./main |
- 无论是空接口类型变量还是非空接口类型变量,一旦变量值为 nil,那么它的内部表示均为 (0x0, 0x0),即类型信息和数据信息都为 nil,因此
i == err
为 true - 对于空接口类型变量,只有在 _type 和 data 所指向数据内容一致(注意不是数据指针的值一致)的情况下,两个空接口类型变量之间才相等
- 与空接口类型变量一样,只有在 tab 和 data 所指向的数据内容一致的情况下,两个非空接口才相同。
- 对于
err1 = (*T)(nil)
赋值情况,*此时非空接口的类型信息不为空(对应 T 类型信息),因此也就和 nil(0x0, 0x0) 不同 - 空接口类型变量和非空接口类型变量内部结构虽然不同,但是 Go 在等值比较时,类型比较的是
eface._type
和iface.tab._type
,因此两者仍然可能相同
输出接口类型变量内部表示的详细信息
println 输出接口类型变量的内部表示信息时一般是足够的,但是有时我们想要更详细的信息。虽然 eface
和 iface
都是非导出结构,无法直接在外部引用它们,但是可以通过复制代码的方式将它们的定义复制到我们自己的代码中,再通过类似于 (*eface)unsafe.Pointer(&i)
的方式来打印它们的内部数据。
接口类型的装箱原理
装箱(boxing)是编程语言领域的一个基础概念,一般是指把值类型转换成引用类型。在 Go 语言中,将任意类型赋值给一个接口类型变量都是装箱操作。Go 中接口类型装箱实则就是创建一个 eface 或 iface 的过程。
经过装箱操作后,箱内的数据(存放在新分配的内存空间中)与原变量就再无瓜葛(除非是指针类型)。如下是一个例子:
1 | # cat main.go |
1 | # ./main |
尽量定义小接口
总体来说,接口越大,抽象程度越低。
Go 推荐定义小接口
接口就是将对象的行为进行抽象而形成的契约。Go 中接口与其实现者之间的关系是隐式的,实现者仅需要实现接口方法集合中的全部方法,便实现了该接口。契约越繁琐,灵活性就越差,因此 Go 选择小契约,表现在代码上便是尽量定义小接口。
小接口的优势
小接口有如下几点优势:
- 接口越小,抽象程度越高,被接纳程度越高(或者说对应的事物集合越大)。一个极端就是无方法的空接口
interface{}
,空接口这个抽象对应的事物集合空间包含了 Go 中所有事物 - 易于实现与测试
- 契约职责单一,易于复用组合:Go 设计原则推崇通过组合的方式来构建程序,Go 开发人员一般会首先尝试通过嵌入其他已有接口类型的方式来构建新接口类型。小接口更契合 Go 的组合思想,也更容易发挥出组合的威力(大接口会因为引入诸多不需要的契约职责而被放弃)
定义小接口可以遵循的一些特点
- 抽象出接口:在定义小接口之前,需要深入理解问题域,聚焦抽象并发现接口
- 将大接口拆分成小接口
- 接口的单一契约职责
尽量避免使用空接口作为函数参数类型
上文讲过,Go 的接口具有 动静兼备
的特性。接口具有静态类型,能够支持编译器的类型检查。
但是空接口不提供任何信息,如果函数或方法的参数类型为空接口 interface{}
,意味着没有为 Go 编译器提供关于传入实参数据的任何信息,因此我们会失去静态类型语言安全检查的保护屏障。
因此建议尽可能抽象出带有一定行为契约的接口,并将其作为函数参数类型,尽量不要使用可以逃过编译器类型安全检查的空接口类型(interface{})。仅在需要处理未知类型数据时使用空接口类型。
使用接口作为程序水平组合的连接点
偏好组合,正交解耦
是 Go 语言的重要设计哲学之一。
一切皆组合
组合是 Go 程序内各组件间的主要耦合方式,也是搭建 Go 程序静态结构的主要方式。Go 语言主要有有两种组合方式:
- 垂直组合(类型组合):Go 语言主要通过类型嵌入机制实现垂直组合,进而实现方法的复用、接口定义重用等
- 水平组合:通常 Go 程序以接口类型变量作为程序水平组合的连接点,接口是水平组合的关键(如同程序肌体上的关节,给予连接关节的两个部分或者多个部分各自自由活动的能力,而整体又实现了某种功能)
垂直组合
Go 语言没有类型体系的概念,相反,Go 通过类型的垂直组合而不是继承让单一类型承载更多功能。调用方法时,方法的匹配取决于方法名称,而不是类型。
Go 语言通过类型嵌入实现垂直组合,组合方式莫过于以下 3 种:
- 通过嵌入接口构建接口:通过在接口定义中嵌入其他接口类型实现接口行为聚合,组合在成大接口
- 通过嵌入接口构建结构体:嵌入接口类型的结构体类型自动实现了相应的接口
- 通过嵌入结构体构建新的结构体:这样新结构体类型 “继承” 了被嵌入结构体的实现。但实质上在结构体嵌入接口类型和在结构体中嵌入其他结构体都是
委派模式
的一种应用。对新结构体类型的方法调用可能会被委派
给该结构体内部嵌入的结构体实例
以接口为连接点的水平组合
以接口为连接点的水平组合方式可以将各个垂直组合出的类型耦合在一起,从而编制出程序的静态骨架。而通过接口进行水平组合的一种常见模式是使用接受接口类型参数的函数或方法。下面介绍几种以接口为连接点的水平组合的几种惯用形式:
- 水平组合的基本形式是接受接口类型参数的函数或者方法:
1 | func YourFuncName(param YourInterfaceType) |
接口类型与其实现者之间的隐式关系在不经意间满足了依赖抽象、里氏替换原则、接口隔离等代码设计原则。
- 包裹函数接受接口类型参数,并返回与其参数类型相同的返回值,其代码如下:
1 | func YourWrapperFunc(param YourInterfaceType) YourInterfaceType |
通过包裹可以实现对输入数据的过滤、装饰、变换等操作,并将结果再次返回给调用者。由于包裹函数的返回值类型与参数类型相同,因此我们可以将多个接受同一接口类型参数的包裹函数组合成一条链来调用。例如:
1 | YourWrapperFunc1(YourWrapperFunc2(YourWrapperFunc3(param))) |
适配器函数类型是一个辅助水平组合实现的
工具
类型。它是一个类型,可以将一个满足特点函数签名的普通函数显式转换为自身类型的实例,转换后的实例同时也是某个单方法接口类型的实现者。例子就是之前介绍过的http.HandlerFunc
中间接(middleware) 在 Go web 编程中常常值得就是一个实现了
http.Handler
接口的http.HandlerFunc
类型实例,实质上,这里的中间件是包裹函数和适配器函数类型结合的产物。所谓的中间件,本质上就是一个包裹函数(支持链式调用),但是其内部利用了适配器函数类型将一个普通函数转换为实现了接口类型的实例,并将其作为返回值返回
1 | func main() { |
使用接口提高代码的可测试性
Go 在诞生的时候就自带单元测试框架(包括 go test 命令以及 testing 包),Go 的一个惯例是让单元测试代码时刻伴随你编写的 Go 代码。写出好测试代码与写好代码同等重要(Go 标准库中测试代码约战 Go 正常代码的 1/4)。
为一段代码编写测试代码的前提是这段代码具有可测试性。单元测试是自包含的自运行的,运行时一般不依赖外部资源,并且具备跨环境的可重复性。为了提高代码的可测试性,需要降低代码的耦合度,管理被测试代码对外部资源的依赖。我们可以利用接口来提高代码的可测试性。
接口本来就是契约,天然具有降低耦合的作用。适当抽取接口,让接口成为好代码与单元测试之间的桥梁是 Go 语言的一种最佳实践。