什么是Go的接口
首先,Go的接口是整个语言的一个基石内容,它与channel合为Go的两大战术级特性,channel是解决并发通信问题,而接口则解决行为抽象问题。
"Go 的接口不是为了让你描述一个对象是什么,而是为了让你描述它能做什么。"
那么它究竟是什么呢,与其他编程语言的接口又有什么区别呢?
其实接口的作用就是,为了代码复用,它的宏观目的是:如果一个行为,很多对象都可以做,那么我就声明出来,任何对象只要存在这个接口,那么它就一定可以做这个行为。
而Go的接口有什么特点呢,根据规范来说它具有:隐式实现、小接口、值语义等
它有什么特性呢?
- 方法集契约:任何类型只要实现了接口的全部方法,就会隐式满足这个接口,这就是鸭子类型
- 动态值+动态类型:接口变量可以持有任何实现该接口的具体类型的值
- 动态派发:通过被赋值的接口变量执行方法时,调用的具体类型的方法
- 类型断言:从接口可以取出具体类型的值,如
v,ok := x.(T) - 类型切换:
switch v := x.(type)多分判断接口的具体类型 - 空接口
interface{}:可以持有任意类型的值(因为它没有实现任何接口也就是any类型) - 零值可用:未显示初始化的接口变量为nil(注意这里的存在一些细节问题后文会讲),调用这些方法会panic。
Go的接口长什么样子
我们可以在Go的源码(src/runtime/runtime2.go)中,找到接口的实现,具体的长这个样子:
go
type eface struct {
_type *_type // 第一个指针:指向变量的动态类型元数据
data unsafe.Pointer // 第二个指针:指向实际的数据内存地址
}
go
type iface struct {
tab *itab // 第一个指针:指向接口表(Interface Table)
data unsafe.Pointer // 第二个指针:指向实际的数据内存地址
}
其中eface表示为一个不含任何方法的空接口,而iface表示我们常规时候定义的接口,可以看到接口就是一个接口指针+数据内容指针,它们共同协作完成接口工作,并且是实现动态派发和类型安全的核心。
在64位系统上,一个接口值固定占用16字节,就是上述的itab和data两个指针,注意这里是固定占用16字节,无论接口中包含多少方法,就是16字节,这里也区分像C++等接口等实现方式,后文细说。
itab指针:指向runtime.itab,它描述了接口类型和赋值这个接口的具体结构体类型,同时还包含了这个具体结构体对应的真实函数指针数组,它按照接口声明的方法顺序,存放着具体结构体中每一个对应方法的真实地址。data word数据指针:这是接口第二部分,被赋值的接口此指针会指向那个真实的结构体地址或一个拷贝值的地址,而未赋值的接口这个指针会指向nil,这里有一个点:如果赋值接口时,赋值内容是一个指向结构体的指针,那么数据指针会指向它的真实地址,如果这个结构体是一个值,数据指针则会指向堆或栈上这个值的拷贝,这里后文会细说。
*itab
itab的表的主要作用是, 回答"类型 TTT 是如何实现接口 III 的"。它将具体类型(Type)的方法集映射到接口(InterfaceType)定义的方法位上,itab并非是根据每次进行接口赋值时被实例化的结构体,而是在每次初始赋值接口时,会根据接口类型、结构体类型进行判断,如果顺利则生成一个itab,并且存储在用(*InterfaceType, *Type)为Key全局缓存的itabTable表中,这样Go在后续的检查和调用时会飞快。
go
type itab = abi.ITab
type ITab struct {
Inter *InterfaceType
Type *Type
Hash uint32 // copy of Type.Hash. Used for type switches.
Fun [1]uintptr // variable sized. fun[0]==0 means Type does not implement Inter.
}
type InterfaceType struct {
Type
PkgPath Name // import path
Methods []Imethod // sorted by hash
}
在itab的具体实现中,Inter会被指向接口类型,也就是定义接口时的元数据,而Type是Go中所有类型的基础描述符,无论是int、struct还是指针,在运行时都有唯一对应的Type实例。Type是在Go中是非常重要的组成部分,这里不过多细说,只是在itab中,它的作用指的是接口赋值对应的那个结构体类型,Hash的作用是进行类型断言或者type switch是,Go会优先通过这个哈希值进行快速过滤,Fun是一个指向具体接口方法实现函数的指针列表。
Inter *InterfaceType
go
type InterfaceType struct {
Type
PkgPath Name // 导入路径,用于处理同名但不同包的接口
Methods []Imethod // 接口定义的方法列表,按方法名的哈希值排序
}
这是接口的元数据,也就是定义接口时,会产生这样的实例,这其中Type负责类型声明,PkgPath是为了严格区分不同包之间的命名,也就是A、B两个包中都实现的接口需要被严格区分,Methods是方法集列表,会按照方法名的哈希值进行排序,其中存储的是方法签名的元数据(方法名、参数类型、返回值类型)。
这里的 Methods 在编译期就已经严格按照哈希值字典序排序。当 Go 在运行时需要验证某个具体类型是否满足该接口时,这个有序列表就是用来与具体类型的方法表进行 O(N+M)O(N+M)O(N+M) 双指针比对的"标尺"。
它对于接口的主要作用就是,它就是接口本身,也就是要实现这个接口的话,需要让对应结构体实例满足方法集中的函数,也就是Methods,而这个过程Go实现方式是隐式实现。
隐式实现
Go的接口是鸭子类型,只要对应结构体实现了一个接口的所有方法,那么这个结构体就会自动实现这个接口,具体的是在编译时,将一个具体结构体类型赋值给接口时,接口会检查该类型是否完整实现了接口所有的方法。但是在实际运行时,Go会维护一个全局缓存的哈希表,也就是前文的itabTable,那么具体它是如何工作的呢。
在初次的赋值时,Runtime会遍历Inter.Methods(接口需要什么),也就是这个接口类型要求实现哪些方法,紧接着遍历遍历 Type 后面附加的方法表(实体有什么),如果它们顺利匹配,就会把Type中的函数实际地址按照顺序填入itab.Fun表中,注意这里的是结构体的Type,而不是具体指某一个实例的结构体,这样在后续实现中,只需要先查itabTable表,就可以顺利拿到函数的真实执行地址,若结构体没有匹配接口,则会在匹配时出现错误并且返回。
Go维护这个全局的itabTable的过程:
- 查询当代码执行 var r io.Reader = myStruct{} 时,系统会调用 runtime.getitab(inter, typ, canfail)。
- 命中: 如果缓存中已存在 (io.Reader, myStruct) 的组合,直接返回指针,耗时几乎为零。
- 生成(检查核心): 如果未命中,Go 会实时计算。由于接口的方法名和具体类型的方法名在编译期都已排序,Go 使用 O(N+M)O(N+M)O(N+M) 的双指针扫描算法来匹配方法。匹配成功后,将结果存入全局缓存。
动态类型实现
任何一个被结构体实例赋值的Go接口,都可以通过类型断言或者type switch的方式,拿回赋值接口的原结构体,而相同类型的接口可以承载任意不同类型、不同实例的结构体,并且Go不需要通过比较复杂的字符串、或者递归检查方法集,它只需要通过itab表中的Hash就可以快速过滤出原结构体的Type类型,这样就可以做到类型断言,整体速度非常快,结合iface会存储原结构体实例指针,让接口不仅可以快速进行类型断言,也可以直接返回原实例结构体。
接口的nil值
当我们定义了一个接口时,在未对它进行赋值前,它是指向的是nil,但是如果对这个接口赋值一个指向nil的结构体,那么此接口此时就不再是nil了
go
package main
import "fmt"
//接口1
type I interface {
M()
}
//接口2
type J interface {
N()
}
type T struct{}
//值方法
func (t T) M() {}
//指针方法
func (t *T) N() {}
func main() {
var i I
//只定义一个接口,但不进行任何赋值
fmt.Printf("i == nil: %v\n", i == nil)
var p *T = nil
//对接口赋值一个指向nil的结构体
var j J = p
fmt.Printf("j == nil: %v\n", j == nil)
}
############################################################
//执行结果
i == nil: true
j == nil: false
那么这其实非常好理解,就是当你只定义一个类型,这里是接口不做任何操作时,那么它一定是nil;相对的如果对定义的接口赋值一个指向nil的结构体,那么此时的接口就不再是nil了,原因是接口会对指向nil的结构体进行初始化,它会采取尽量赋值的策略,更新自己的itab表,所以此时接口已经有了形状,所以就不再是nil了
接口的赋值操作
在使用接口时我们需要将一个匹配的结构体赋值给它,它才能调用接口方法,此时存在两种情况,将结构体实例的值赋值给接口和将结构体实例的指针赋值给接口,它们的操作是完全相同的,但是会产生两种不同的效果,首先对于itab的表的获取,在全局缓存的getitab中查找的key是不同的,结构体Type对应的是一个值一个是指针类型,它们完全不同,所以会返回完全不同的itab;在一个就是生成itab的区别也是如此,itab中的Hash和Type也将是两个值,对应值类型和指针类型;再者就是对于data的存储也是完全不同的,如传入指针类型,则直接指向这个结构体的实例地址,如传入值,则data中指向的是栈或堆上对于对应结构体的值拷贝,那么这样产生的后果就是,值接口不可以修改原结构体中的数据,换句话说值接口的方法操作的是一个被拷贝的结构体,会根据逃逸分析被选择拷贝到栈还是堆上,如果它不在被后续引用,那么通常会被拷贝到栈上,在结束调用时消亡,反之则会拷贝到堆上去;再者这里提一点,如传入指针结构体的方式来自值结构体的取地符,若接口的方法存在逃逸则值结构体会被分配到堆上去。
"赋值时的值拷贝在栈还是堆,不由'值赋给接口'直接决定,而由逃逸分析决定;接口只是可能成为导致逃逸的一个传播途径。"
接口的值方法和指针方法
当一个接口被赋值结构体后,若结构体中存在指针方法,如func (t *T) M(),此时如果接口赋值操作的是一个值类型,那么将无法满足这个接口,编译器会提示你,这个结构体未实现这个接口,因为M带有指针接收器;但如若对一个接口指针赋值一个定义了值方法的结构体,在调用方法时,会自动加入&解引用这个结构体指针,正常执行方法。
- 如果接口方法需要的是 func (t T) M(),那么 T 和 *T 都可能满足
- 如果接口方法需要的是 func (t *T) M(),那么通常只有 *T 满足,T 不满足
总结
本文描述了关于Go的接口的基础底层部分,会在下篇章通过,嵌套接口、嵌套结构体以及具体的代码示例来描述整个接口和后续内容