一文带你了解Golang中interface的设计与实现

目录
  • 前言
  • 接口是什么
  • iface 和 eface 结构体
  • _type 是什么
  • itab 是什么
  • 生成的 itab 是怎么被使用的
  • itab 关键方法的实现
  • 根据 interfacetype 和 _type 初始化 itab
  • 接口断言过程总览(类型转换的关键)
  • panicdottypeI 与 panicdottypeE
  • iface 和 eface 里面的 data 是怎么来的
  • convT* 方法
    • Java 里面的小整数享元模式
  • 总结

在上一篇文章《go interface 基本用法》中,我们了解了 go 中interface的一些基本用法,其中提到过接口本质是一种自定义类型,本文就来详细说说为什么说接口本质是一种自定义类型,以及这种自定义类型是如何构建起 go 的interface系统的。

本文使用的源码版本: go 1.19。另外本文中提到的 interface接口 是同一个东西。

前言

在了解 go interface 的设计过程中,看了不少资料,但是大多数资料都有生成汇编的操作,但是在我的电脑上指向生成汇编的操作的时候, 生成的汇编代码却不太一样,所以有很多的东西无法验证正确性,这部分内容不会出现在本文中。本文只写那些经过本机验证正确的内容,但也不用担心,因为涵盖了 go interface 设计与实现的核心部分内容,但由于水平有限,所以只能尽可能地传达我所知道的关于 interface 的一切东西。对于有疑问的部分,有兴趣的读者可以自行探索。

如果想详细地了解,建议还是去看看 iface.go,里面有接口实现的一些关键的细节。但是还是有一些东西被隐藏了起来, 导致我们无法知道我们 go 代码会是 iface.go 里面的哪一段代码实现的。

接口是什么

接口(interface)本质上是一种结构体。

我们先来看看下面的代码:

// main.go
package main

type Flyable interface {
 Fly()
}

// go tool compile -N -S -l main.go
func main() {
 var f1 interface{}
 println(f1) // CALL    runtime.printeface(SB)

 var f2 Flyable
 println(f2) // CALL    runtime.printiface(SB)
}

我们可以通过 go tool compile -N -S -l main.go 命令来生成 main.go 的伪汇编代码,生成的代码会很长,下面省略所有跟本文主题无关的代码:

// main.go:10 => println(f1)
0x0029 00041 (main.go:10)  CALL  runtime.printeface(SB)
// main.go:13 => println(f2)
0x004f 00079 (main.go:13)  CALL  runtime.printiface(SB)

我们从这段汇编代码中可以看到,我们 println(f1) 实际上是对 runtime.printeface 的调用,我们看看这个 printeface 方法:

func printeface(e eface) {
 print("(", e._type, ",", e.data, ")")
}

我们看到了,这个 printeface 接收的参数实际上是 eface 类型,而不是 interface{} 类型,我们再来看看 println(f2) 实际调用的 runtime.printiface 方法:

func printiface(i iface) {
 print("(", i.tab, ",", i.data, ")")
}

也就是说 interface{} 类型在底层实际上是 eface 类型,而 Flyable 类型在底层实际上是 iface 类型。

这就是本文要讲述的内容,go 中的接口变量其实是用 ifaceeface 这两个结构体来表示的:

  • iface 表示某一个具体的接口(含有方法的接口)。
  • eface 表示一个空接口(interface{}

iface 和 eface 结构体

ifaceeface 的结构体定义(runtime/iface.go):

// 非空接口(如:io.Reader)
type iface struct {
 tab  *itab          // 方法表
 data unsafe.Pointer // 指向变量本身的指针
}

// 空接口(interface{})
type eface struct {
 _type *_type         // 接口变量的类型
 data  unsafe.Pointer // 指向变量本身的指针
}

go 底层的类型信息是使用 _type 结构体来存储的。

比如,我们有下面的代码:

package main

type Bird struct {
 name string
}

func (b Bird) Fly() {
}

type Flyable interface {
 Fly()
}

func main() {
 bird := Bird{name: "b1"}
 var efc interface{} = bird // efc 是 eface
 var ifc Flyable = bird // ifc 是 iface

 println(efc) // runtime.printeface
 println(ifc) // runtime.printiface
}

在上面代码中,efceface 类型的变量,对应到 eface 结构体的话,_type 就是 Bird 这个类型本身,而 data 就是 &bird 这个指针:

类似的,ifciface 类型的变量,对应到 iface 结构体的话,data 也是 &bird 这个指针:

_type 是什么

在 go 中,_type 是保存了变量类型的元数据的结构体,定义如下:

// _type 是 go 里面所有类型的一个抽象,里面包含 GC、反射、大小等需要的细节,
// 它也决定了 data 如何解释和操作。
// 里面包含了非常多信息:类型的大小、哈希、对齐及 kind 等信息
type _type struct {
    size       uintptr // 数据类型共占用空间的大小
    ptrdata    uintptr // 含有所有指针类型前缀大小
    hash       uint32  // 类型 hash 值;避免在哈希表中计算
    tflag      tflag   // 额外类型信息标志
    align      uint8   // 该类型变量对齐方式
    fieldAlign uint8   // 该类型结构体字段对齐方式
    kind       uint8   // 类型编号
    // 用于比较此类型对象的函数
    equal func(unsafe.Pointer, unsafe.Pointer) bool
    // gc 相关数据
    gcdata    *byte
    str       nameOff // 类型名字的偏移
    ptrToThis typeOff
}

这个 _type 结构体定义大家随便看看就好了,实际上,go 底层的类型表示也不是上面这个结构体这么简单。

但是,我们需要知道的一点是(与本文有关的信息),通过 _type 我们可以得到结构体里面所包含的方法这些信息。具体我们可以看 itabinit 方法(runtime/iface.go),我们会看到如下几行:

typ := m._type
x := typ.uncommon() // 结构体类型

nt := int(x.mcount)   // 实际类型的方法数量
// 实际类型的方法数组,数组元素为 method
xmhdr := (*[1 << 16]method)(add(unsafe.Pointer(x), uintptr(x.moff)))[:nt:nt]

在底层,go 是通过 _type 里面 uncommon 返回的地址,加上一个偏移量(x.moff)来得到实际结构体类型的方法列表的。

我们可以参考一下下图想象一下:

itab 是什么

我们从 iface 中可以看到,它包含了一个 *itab 类型的字段,我们看看这个 itab 的定义:

// 编译器已知的 itab 布局
type itab struct {
 inter *interfacetype // 接口类型
 _type *_type
 hash  uint32
 _     [4]byte
 fun   [1]uintptr // 变长数组. fun[0]==0 意味着 _type 没有实现 inter 这个接口
}

// 接口类型
// 对应源代码:type xx interface {}
type interfacetype struct {
    typ     _type     // 类型信息
    pkgpath name      // 包路径
    mhdr    []imethod // 接口的方法列表
}

根据 interfacetype 我们可以得到关于接口所有方法的信息。同样的,通过 _type 也可以获取结构体类型的所有方法信息。

从定义上,我们可以看到 itab*interfacetype*_type 有关,但实际上有什么关系从定义上其实不太能看得出来, 但是我们可以看它是怎么被使用的,现在,假设我们有如下代码:

// i 在底层是一个 interfacetype 类型
type i interface {
 A()
 C()
}

// t 底层会用 _type 来表示
// t 里面有 A、B、C、D 方法
// 因为实现了 i 中的所有方法,所以 t 实现了接口 i
type t struct {}
func (t) A()  {}
func (t) B()  {}
func (t) C()  {}
func (t) D()  {}

下图描述了上面代码对应的 itab 生成的过程:

说明:

  • itab 里面的 inter 是接口类型的指针(比如通过 type Reader interface{} 这种形式定义的接口,记录的是这个类型本身的信息),这个接口类型本身定义了一系列的方法,如图中的 i 包含了 AC 两个方法。
  • _type 是实际类型的指针,记录的是这个实际类型本身的信息,比如这个类型包含哪些方法。图中的 i 实现了 ABCD 四个方法,因为实现了 i 的所有方法,所以说 t 实现了 i 接口。
  • 在底层做类型转换的时候,比如 t 转换为 i 的时候(var v i = t{}),会生成一个 itab,如果 t 没有实现 i 中的所有方法,那么生成的 itab 中不包含任何方法。
  • 如果 t 实现了 i 中的所有方法,那么生成的 itab 中包含了 i 中的所有方法指针,但是实际指向的方法是实际类型的方法(也就是指向的是 t 中的方法地址)
  • mhdr 就是 itab 中的方法表,里面的方法名就是接口的所有方法名,这个方法表中保存了实际类型(t)中同名方法的函数地址,通过这个地址就可以调用实际类型的方法了。

所以,我们有如下结论:

  • itab 实际上定义了 interfacetype_type 之间方法的交集。作用是什么呢?就是用来判断一个结构体是否实现某个接口的。
  • itab 包含了接口的所有方法,这里面的方法是实际类型的子集。
  • itab 里面的方法列表包含了实际类型的方法指针(也就是实际类型的方法的地址),通过这个地址可以对实际类型进行方法的调用。
  • itab 在实际类型没有实现接口的所有方法的时候,生成失败(失败的意思是,生成的 itab 里面的方法列表是空的,在底层实现上是用 fun[0] = 0 来表示)。

生成的 itab 是怎么被使用的

go 里面定义了一个全局变量 itabTable,用来缓存 itab,因为在判断某一个结构体是否实现了某一个接口的时候, 需要比较两者的方法集,如果结构体实现了接口的所有方法,那么就表明结构体实现了接口(这也就是生成 itab 的过程)。 如果在每一次做接口断言的时候都要做一遍这个比较,性能无疑会大大地降低,因此 go 就把这个比较得出的结果缓存起来,也就是 itab。 这样在下一次判断结构体是否实现了某一个接口的时候,就可以直接使用之前的 itab,性能也就得到提升了。

// 表里面缓存了 itab
itabTable     = &itabTableInit
itabTableInit = itabTableType{size: itabInitSize}

// 全局的 itab 表
type itabTableType struct {
    size    uintptr             // entries 的长度,2 的次方
    count   uintptr             // 当前 entries 的数量
    entries [itabInitSize]*itab // 保存 itab 的哈希表
}

itabTableType 里面的 entries 是一个哈希表,在实际保存的时候,会用 interfacetype_type 这两个生成一个哈希表的键。 也就是说,这个保存 itab 的缓存哈希表中,只要我们有 interfacetype_type 这两个信息,就可以获取一个 itab

具体怎么使用,我们可以看看下面的例子:

package main

type Flyable interface {
 Fly()
}

type Runnable interface {
 Run()
}

var _ Flyable = (*Bird)(nil)
var _ Runnable = (*Bird)(nil)

type Bird struct {
}

func (b Bird) Fly() {
}

func (b Bird) Run() {
}

// GOOS=linux GOARCH=amd64 go tool compile -N -S -l main.go > main.s
func test() {
 // f 的类型是 iface
 var f Flyable = Bird{}
 // Flyable 转 Runnable 本质上是 iface 到 iface 的转换
 f.(Runnable).Run() // CALL runtime.assertI2I(SB)
 // 这个 switch 里面的类型断言本质上也是 iface 到 iface 的转换
 // 但是 switch 里面的类型断言失败不会引发 panic
 switch f.(type) {
 case Flyable: // CALL runtime.assertI2I2(SB)
 case Runnable: // CALL runtime.assertI2I2(SB)
 }
 if _, ok := f.(Runnable); ok { // CALL runtime.assertI2I2(SB)
 }

 // i 的类型是 eface
 var i interface{} = Bird{}
 // i 转 Flyable 本质上是 eface 到 iface 的转换
 i.(Flyable).Fly() // CALL runtime.assertE2I(SB)
 // 这个 switch 里面的类型断言本质上也是 eface 到 iface 的转换
 // 但是 switch 里面的类型断言失败不会引发 panic
 switch i.(type) {
 case Flyable: // CALL runtime.assertE2I2(SB)
 case Runnable: // CALL runtime.assertE2I2(SB)
 }
 if _, ok := i.(Runnable); ok { // CALL runtime.assertE2I2(SB)
 }
}

我们对上面的代码生成伪汇编代码:

GOOS=linux GOARCH=amd64 go tool compile -N -S -l main.go > main.s

然后我们去查看 main.s,就会发现类型断言的代码,本质上是对 runtime.assert* 方法的调用(assertI2IassertI2I2assertE2IassertE2I2), 这几个方法名都是以 assert 开头的,assert 在编程语言中的含义是,判断后面的条件是否为 true,如果 false 则抛出异常或者其他中断程序执行的操作,为 true 则接着执行。 这里的用处就是,判断一个接口是否能够转换为另一个接口或者另一个类型

但在这里有点不太一样,这里有两个函数最后有个数字 2 的,表明了我们对接口的类型转换会有两种情况,我们上面的代码生成的汇编其实已经很清楚了,一种情况是直接断言,使用 i.(T) 这种形式,另外一种是在 switch...case 里面使用,。

我们可以看看它们的源码,看看有什么不一样:

// 直接根据 interfacetype/_type 获取 itab
func assertE2I(inter *interfacetype, t *_type) *itab {
 if t == nil {
  // 显式转换需要非nil接口值。
  panic(&TypeAssertionError{nil, nil, &inter.typ, ""})
 }
 // getitab 的第三个参数是 false
 // 表示 getiab 获取不到 itab 的时候需要 panic
 return getitab(inter, t, false)
}

// 将 eface 转换为 iface
// 因为 e 包含了 *_type
func assertE2I2(inter *interfacetype, e eface) (r iface) {
 t := e._type
 if t == nil {
  return
 }
 // getitab 的第三个参数是 true
 // 表示 getitab 获取不到 itab 的时候不需要 panic
 tab := getitab(inter, t, true)
 if tab == nil {
  return
 }
 r.tab = tab
 r.data = e.data
 return
}

getitab 的源码后面会有。

从上面的代码可以看到,其实带 2 和不带 2 后缀的关键区别在于:getitab 的调用允不允许失败。 这有点类似于 chan 里面的 selectchanselect 语句中读写 chan 不会阻塞,而其他地方会阻塞。

assertE2I2 是用在 switch...case 中的,这个调用是允许失败的,因为我们还需要判断能否转换为其他类型; 又或者 v, ok := i.(T) 的时候,也是允许失败的,但是这种情况会返回第二个值给用户判断是否转换成功。 而直接使用类型断言的时候,如 i.(T) 这种,如果 i 不能转换为 T 类型,则直接 panic

对于 go 中的接口断言可以总结如下:

  • assertI2I 用于将一个 iface 转换为另一个 iface zhong,转换失败的时候 panic
  • assertI2I2 用于将一个 iface 转换为另一个 iface,转换失败的时候不会 panic
  • assertE2I 用于将一个 eface 转换为另一个 iface,转换失败的时候 panic
  • assertE2I2 用于将一个 eface 转换为另一个 iface,转换失败的时候不会 panic
  • assert 相关的方法后缀的 I2IE2E 里面的 I 表示的是 ifaceE 表示的是 eface
  • 2 后缀的允许失败,用于 v, ok := i.(T) 或者 switch x.(type) ... case
  • 不带 2 后缀的不允许失败,用于 i.(T) 这种形式中

当然,这里说的转换不是说直接转换,只是说,在转换的过程中会用到 assert* 方法。

如果我们足够细心,然后也去看了 assertI2IassertI2I2 的源码,就会发现,这几个方法本质上都是, 通过 interfacetype_type 来获取一个 itab 然后转换为另外一个 itab 或者 `iface。

同时,我们也应该注意到,上面的转换都是转换到 iface 而没有转换到 eface 的操作,这是因为,所有类型都可以转换为空接口(interface{},也就是 eface)。根本就不需要断言。

上面的内容可以结合下图理解一下:

itab 关键方法的实现

下面,让我们再来深入了解一下 itab 是怎么被创建出来的,以及是怎么保存到全局的哈希表中的。我们先来看看下图:

这个图描述了 go 底层存储 itab 的方式:

  • 通过一个 itabTableType 类型来存储所有的 itab
  • 在调用 getitab 的时候,会先根据 inter_type 计算出哈希值,然后从 entries 中查找是否存在,存在就返回对应的 itab,不存在则新建一个 itab
  • 在调用 itabAdd 的时候,会将 itab 加入到 itabTableType 类型变量里面的 entries 中,其中 entries 里面的键是根据 inter_type 做哈希运算得出的。

itab 两个比较关键的方法:

  • getitab 让我们可以通过 interfacetype_type 获取一个 itab,会现在缓存中找,找不到会新建一个。
  • itabAdd 是在我们缓存找不到 itab,然后新建之后,将这个新建的 itab 加入到缓存的方法。

getitab 方法的第三个参数 canfail 表示当前操作是否允许失败,上面说了,如果是用在 switch...case 或者 v, ok := i.(T) 这种是允许失败的。

// 获取某一个类型的 itab(从 itabTable 中查找,键是 inter 和 _type 的哈希值)
// 查找 interfacetype + _type 对应的 itab
// 找不到就新增。
func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
 if len(inter.mhdr) == 0 {
  throw("internal error - misuse of itab")
 }

 // 不包含 Uncommon 信息的类型直接报错
 if typ.tflag&tflagUncommon == 0 {
  if canfail {
   return nil
  }
  name := inter.typ.nameOff(inter.mhdr[0].name)
  panic(&TypeAssertionError{nil, typ, &inter.typ, name.name()})
 }

 // 保存返回的 itab
 var m *itab

 // t 指向了 itabTable(全局的 itab 表)
 t := (*itabTableType)(atomic.Loadp(unsafe.Pointer(&itabTable)))
 // 会先从全局 itab 表中查找,找到就直接返回
 if m = t.find(inter, typ); m != nil {
  goto finish
 }

 // 没有找到,获取锁,再次查找。
 // 找到则返回
 lock(&itabLock)
 if m = itabTable.find(inter, typ); m != nil {
  unlock(&itabLock)
  goto finish
 }

 // 没有在缓存中找到,新建一个 itab
 m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.mhdr)-1)*goarch.PtrSize, 0, &memstats.other_sys))
 // itab 的
 m.inter = inter
 m._type = typ
 m.hash = 0
 // itab 初始化
 m.init()
 // 将新创建的 itab 加入到全局的 itabTable 中
 itabAdd(m)
 // 释放锁
 unlock(&itabLock)
finish:
 // == 0 表示没有任何方法
 // 下面 != 0 表示有 inter 和 typ 有方法的交集
 if m.fun[0] != 0 {
  return m
 }
 // 用在 switch x.(type) 中的时候,允许失败而不是直接 panic
 // 但在 x.(Flyable).Fly() 这种场景会直接 panic
 if canfail {
  return nil
 }

 // 没有找到有方法的交集,panic
 panic(&TypeAssertionError{concrete: typ, asserted: &inter.typ, missingMethod: m.init()})
}

itabAdd 将给定的 itab 添加到 itab 哈希表中(itabTable)。

注意:itabAdd 中在判断到哈希表的使用量超过 75% 的时候,会进行扩容,新的容量为旧容量的 2 倍。

// 必须保持 itabLock。
func itabAdd(m *itab) {
 // 正在分配内存的时候调用的话报错
 if getg().m.mallocing != 0 {
  throw("malloc deadlock")
 }

 t := itabTable
 // 容量已经超过 75% 的负载了,hash 表扩容
 if t.count >= 3*(t.size/4) {
  // 75% load factor(实际上是:t.size *0.75)
  // 扩展哈希表。原来 2 倍大小。
  // 我们撒谎告诉 malloc 我们需要无指针内存,因为所有指向的值都不在堆中。
  // 2 是 size 和 count 这两个字段需要的空间
  t2 := (*itabTableType)(mallocgc((2+2*t.size)*goarch.PtrSize, nil, true))
  t2.size = t.size * 2

  // 复制条目。
  // 注意:在复制时,其他线程可能会查找itab,但找不到它。
  // 没关系,然后它们会尝试获取itab锁,因此等待复制完成。
  iterate_itabs(t2.add)    // 遍历旧的 hash 表,复制函数指针到 t2 中
  if t2.count != t.count { // 复制出错
   throw("mismatched count during itab table copy")
  }

  // 发布新哈希表。使用原子写入:请参见 getitab 中的注释。
  // 使用 t2 覆盖 itabTable
  atomicstorep(unsafe.Pointer(&itabTable), unsafe.Pointer(t2))
  // 使用新的 hash 表
  // 因为 t 是局部变量,指向旧的地址,
  // 但是扩容之后是新的地址了,所以现在需要将新的地址赋给 t
  t = itabTable
  // 注:旧的哈希表可以在此处进行GC。
 }
 // 将 itab 加入到全局哈希表
 t.add(m)
}

其实 itabAdd 的关键路径比较清晰,只是因为它是一个哈希表,所以里面在判断到当前 itab 的数量超过 itabTable 容量的 75% 的时候,会对 itabTable 进行 2 倍扩容。

根据 interfacetype 和 _type 初始化 itab

上面那个图我们说过,itab 本质上是 interfacetype_type 方法的交集,这一节我们就来看看,itab 是怎么根据这两个类型来进行初始化的。

itabinit 方法实现:

// init 用 m.inter/m._type 对的所有代码指针填充 m.fun 数组。
// 如果该类型不实现接口,它将 m.fun[0] 设置为 0 ,并返回缺少的接口函数的名称。
// 可以在同一个m上多次调用,甚至同时调用。
func (m *itab) init() string {
 inter := m.inter    // 接口
 typ := m._type      // 实际的类型
 x := typ.uncommon()

 // inter 和 typ 都具有按名称排序的方法,并且接口名称是唯一的,因此可以在锁定步骤中迭代这两个;
 // 循环时间复杂度是 O(ni+nt),不是 O(ni*nt)
 ni := len(inter.mhdr) // 接口的方法数量
 nt := int(x.mcount)   // 实际类型的方法数量
 // 实际类型的方法数组,数组元素为 method
 xmhdr := (*[1 << 16]method)(add(unsafe.Pointer(x), uintptr(x.moff)))[:nt:nt] // 大小无关紧要,因为下面的指针访问不会超出范围
 j := 0
 // 用来保存 inter/_type 对方法列表的数组,数组元素为 unsafe.Pointer(是实际类型方法的指针)
 methods := (*[1 << 16]unsafe.Pointer)(unsafe.Pointer(&m.fun[0]))[:ni:ni] // 保存 itab 方法的数组
 // 第一个方法的指针
 var fun0 unsafe.Pointer
imethods:
 for k := 0; k < ni; k++ { // 接口方法遍历
  i := &inter.mhdr[k]                // i 是接口方法, imethod 类型
  itype := inter.typ.typeOff(i.ityp) // 接口的方法类型
  name := inter.typ.nameOff(i.name)  // 接口的方法名称
  iname := name.name()               // 接口的方法名
  ipkg := name.pkgPath()             // 接口的包路径
  if ipkg == "" {
   ipkg = inter.pkgpath.name()
  }

  // 根据接口方法查找实际类型的方法
  for ; j < nt; j++ { // 实际类型的方法遍历
   t := &xmhdr[j]               // t 是实际类型的方法,method 类型
   tname := typ.nameOff(t.name) // 实际类型的方法名
   // 比较接口的方法跟实际类型的方法是否一致
   if typ.typeOff(t.mtyp) == itype && tname.name() == iname {
    // 实际类型的包路径
    pkgPath := tname.pkgPath()
    if pkgPath == "" {
     pkgPath = typ.nameOff(x.pkgpath).name()
    }

    // 如果是导出的方法
    // 则保存到 itab 中
    if tname.isExported() || pkgPath == ipkg {
     if m != nil {
      ifn := typ.textOff(t.ifn) // 实际类型的方法指针(通过这个指针可以调用实际类型的方法)
      if k == 0 {
       // 第一个方法
       fun0 = ifn // we'll set m.fun[0] at the end
      } else {
       methods[k] = ifn
      }
     }
     // 比较下一个方法
     continue imethods
    }
   }
  }
  // 没有实现接口(实际类型没有实现 interface 中的任何一个方法)
  m.fun[0] = 0
  return iname // 返回缺失的方法名,返回值在类型断言失败的时候会需要提示用户
 }
 // 实现了接口
 m.fun[0] = uintptr(fun0)
 return ""
}

接口断言过程总览(类型转换的关键)

具体来说有四种情况,对应上面提到的 runtime.assert* 方法:

  • 实际类型转换到 iface
  • iface 转换到另一个 iface
  • 实际类型转换到 eface
  • eface 转换到 iface

这其中的关键是 interfacetype + _type 可以生成一个 itab

上面的内容可能有点混乱,让人摸不着头脑,但是我们通过上面的讲述,相信已经了解了 go 接口中底层的一些实现细节,现在,就让我们重新来捋一下,看看 go 接口到底是怎么实现的:

首先,希望我们可以达成的一个共识就是,go 的接口断言本质上是类型转换,switch...case 里面或 v, ok := i.(T) 允许转换失败,而 i.(T).xx() 这种不允许转换失败,转换失败的时候会 panic

接着,我们就可以通过下图来了解 go 里面的接口整体的实现原理了(还是以上面的代码作为例子):

1.将结构体赋值给接口类型:var f Flyable = Bird{}

在这个赋值过程中,创建了一个 iface 类型的变量,这个变量中的 itab 的方法表只包含了 Flyable 定义的方法。

2.iface转另一个 iface:

  • f.(Runnable)
  • _, ok := f.(Runnable)
  • switch f.(type) 里面的 caseRunnable

在这个断言过程中,会将 Flyable 转换为 Runnable,本质上是一个 iface 转换到另一个 iface。但是有个不同之处在于, 两个 iface 里面的方法列表是不一样的,只包含了当前 interfacetype 里面定义的方法。

3.将结构体赋值给空接口:var i interface{} = Bird{}

在这个过程中,创建了一个 eface 类型的变量,这个 eface 里面只包含了类型信息以及实际的 Bird 结构体实例。

4.eface转换到 iface

  • i.(Flyable)
  • _, ok := i.(Runnable)
  • switch i.(type) 里面的 caseFlyable

因为 _type 包含了 Bird 类型的所有信息,而 data 包含了 Bird 实例的值,所以这个转换是可行的。

panicdottypeI 与 panicdottypeE

从前面的几个小节,我们知道,go 的 iface 类型转换使用的是 runtime.assert* 几个方法,还有另外一种情况就是, 在编译期间编译器就已经知道了无法转换成功的情况,比如下面的代码:

package main

type Flyable interface {
 Fly()
}

type Cat struct {
}

func (c Cat) Fly() {
}

func (c Cat) test() {
}

// GOOS=linux GOARCH=amd64 go tool compile -N -S -l main.go > main.s
func main() {
 var b interface{}
 var _ = b.(int) // CALL runtime.panicdottypeE(SB)

 var c Flyable = &Cat{}
 c.(Cat).test() // CALL runtime.panicdottypeI(SB)
}

上面的两个转换都是错误的,第一个 b.(int) 尝试将 nil 转换为 int 类型,第二个尝试将 *Cat 类型转换为 Cat 类型, 这两个错误的类型转换都在编译期可以发现,因此它们生成的汇编代码调用的是 runtime.panicdottypeEruntime.panicdottypeI 方法:

// 在执行 e.(T) 转换时如果转换失败,则调用 panicdottypeE
// have:我们的动态类型。
// want:我们试图转换为的静态类型。
// iface:我们正在转换的静态类型。
// 转换的过程:尝试将 iface 的 have 转换为 want 失败了。
// 不是调用方法的时候的失败。
func panicdottypeE(have, want, iface *_type) {
 panic(&TypeAssertionError{iface, have, want, ""})
}

// 当执行 i.(T) 转换并且转换失败时,调用 panicdottypeI
// 跟 panicdottypeE 参数相同,但是 hava 是动态的 itab 类型
func panicdottypeI(have *itab, want, iface *_type) {
 var t *_type
 if have != nil {
  t = have._type
 }
 panicdottypeE(t, want, iface)
}

这两个方法都是引发一个 panic,因为我们的类型转换失败了:

iface 和 eface 里面的 data 是怎么来的

我们先看看下面的代码:

package main

type Bird struct {
}

func (b Bird) Fly() {
}

type Flyable interface {
 Fly()
}

// GOOS=linux GOARCH=amd64 go tool compile -N -S -l main.go > main.s
func main() {
 bird := Bird{}
 var efc interface{} = bird // CALL runtime.convT(SB)
 var ifc Flyable = bird     // CALL runtime.convT(SB)
 println(efc, ifc)
}

我们生成伪汇编代码发现,里面将结构体变量赋值给接口类型变量的时候,实际上是调用了 convT 方法。

convT* 方法

iface 里面还包含了几个 conv* 前缀的函数,在我们将某一具体类型的值赋值给接口类型的时候,go 底层会将具体类型的值通过 conv* 函数转换为 iface 里面的 data 指针:

// convT 将 v 指向的 t 类型的值转换为可以用作接口值的第二个字的指针(接口的第二个字是指向 data 的指针)。
// data(Pointer) => 指向 interface 第 2 个字的 Pointer
func convT(t *_type, v unsafe.Pointer) unsafe.Pointer {
 // ... 其他代码
 // 分配 _type 类型所需要的内存
 x := mallocgc(t.size, t, true)
 // 将 v 指向的值复制到刚刚分配的内存上
 typedmemmove(t, x, v)
 return x
}

我们发现,在这个过程,实际上是将值复制了一份:

iface.go 里面还有将无符号值转换为 data 指针的函数,但是还不知道在什么地方会用到这些方法,如:

// 转换 uint16 类型值为 interface 里面 data 的指针。
// 如果是 0~255 的整数,返回指向 staticuint64s 数组里面对应下标的指针。
// 否则,分配新的内存地址。
func convT16(val uint16) (x unsafe.Pointer) {
 // 如果小于 256,则使用共享的内存地址
 if val < uint16(len(staticuint64s)) {
  x = unsafe.Pointer(&staticuint64s[val])
  if goarch.BigEndian {
   x = add(x, 6)
  }
 } else {
  // 否则,分配新的内存
  x = mallocgc(2, uint16Type, false)
  *(*uint16)(x) = val
 }
 return
}

个人猜测,仅仅代表个人猜测,在整数赋值给 iface 或者 eface 的时候会调用这类方法。不管调不调用,我们依然可以看看它的设计,因为有些值得学习的地方:

staticuint64s 是一个全局整型数组,里面存储的是 0~255 的整数。上面的代码可以表示为下图:

这个函数跟上面的 convT 的不同之处在于,它在判断整数如果小于 256 的时候,则使用的是 staticuint64s 数组里面对应下标的地址。 为什么这样做呢?本质上是为了节省内存,因为对于数字来说,其实除了值本身,没有包含其他的信息了,所以如果对于每一个整数都分配新的内存来保存, 无疑会造成浪费。按 convT16 里面的实现方式,对于 0~255 之间的整数,如果需要给它们分配内存,就可以使用同一个指针(指向 staticuint64s[] 数组中元素的地址)。

这实际上是享元模式。

Java 里面的小整数享元模式

go 里使用 staticuint64s 的方式,其实在 Java 里面也有类似的实现,Java 中对于小整数也是使用了享元模式, 这样在装箱的时候,就不用分配新的内存了,就可以使用共享的一块内存了,当然,某一个整数能节省的内存非常有限,如果需要分配内存的小整数非常大,那么节省下来的内存就非常客观了。 当然,也不只是能节省内存这唯一的优点,从另一方面说,它也节省了垃圾回收器回收内存的开销,因为不需要管理那么多内存。

我们来看看 Java 中的例子:

class Test {
    public static void main(String[] args) {
        Integer k1 = 127;
        Integer k2 = 127;
        System.out.println(k1 == k2); // true
        System.out.println(k1.equals(k2)); // true

        Integer k10 = 128;
        Integer k20 = 128;
        System.out.println(k10 == k20); // false
        System.out.println(k10.equals(k20)); // true
    }
}

Java 里面有点不一样,它是对 -128~127 范围内的整数做了享元模式的处理,而 go 里面是 0~255

上面的代码中,当我们使用 == 来比较 Integer 的时候,值相等的两个数,在 -128~127 的范围的时候,返回的是 true,超出这个范围的时候比较返回的是 false。 这是因为在 -128~127 的时候,值相等的两个数字指向了相同的内存地址,超出这个范围的时候,值相等的两个数指向了不同的地址。

Java 的详细实现可以看 java.lang.Integer.IntegerCache

总结

  • go 的的接口(interface)本质上是一种结构体,底册实现是 ifaceefaceiface 表示我们通过 type i interface{} 定义的接口,而 eface 表示 interface{}/any,也就是空接口。
  • iface 里面保存的 itab 中保存了具体类型的方法指针列表,data 保存了具体类型值的内存地址。
  • eface 里面保存的 _type 包含了具体类型的所有信息,data 保存了具体类型值的内存地址。
  • itab 是底层保存接口类型跟具体类型方法交集的结构体,如果具体类型实现了接口的所有方法,那么这个 itab 里面的保存有指向具体类型方法的指针。如果具体类型没有实现接口的全部方法,那么 itab 中的不会保存任何方法的指针(从 itab 的作用上看,我们可以看作是一个空的 itab)。
  • 不管 itab 的方法列表是否为空,interfacetype_type 比较之后生成的 itab 会缓存下来,在后续比较的时候可以直接使用缓存。
  • _type 是 go 底层用来表示某一个类型的结构体,包含了类型所需空间大小等信息。
  • 类型断言 i.(T) 本质上是 ifaceiface 的转换,或者是 efaceiface 的转换,如果没有第二个返回值,那么转换失败的时候会引发 panic
  • switch i.(type) { case ...} 本质上也是 ifaceefaceiface 的转换,但是转换失败的时候不会引发 panic
  • 全局的保存 itab 的缓存结构体,底层是使用了一个哈希表来保存 itab 的,在哈希表使用超过 75% 的时候,会触发扩容,新的哈希表容量为旧的 2 倍。
  • staticuint64s 使用了享元模式,Java 中也有类似的实现。

以上就是一文带你了解Golang中interface的设计与实现的详细内容,更多关于Golang interface的资料请关注我们其它相关文章!

(0)

相关推荐

  • Go语言题解LeetCode705设计哈希集合

    目录 题目描述 思路分析 AC 代码 题目描述 705. 设计哈希集合 不使用任何内建的哈希表库设计一个哈希集合(HashSet). 实现 MyHashSet 类: void add(key) 向哈希集合中插入值 key . bool contains(key) 返回哈希集合中是否存在这个值 key . void remove(key) 将给定值 key 从哈希集合中删除.如果哈希集合中没有这个值,什么也不做.   示例: 输入: ["MyHashSet", "add&quo

  • Go语言设计实现在任务栏里提醒你喝水的兔子

    目录 前言 详细设计 安装包 效果展示 前言 之前看到 RunCat 一只可以在电脑上奔跑猫,其主要的功能是监控电脑的CPU.内存的使用情况,使用越多跑的越快.所以准备做一只在任务栏里的兔子,主要使用 Go 语言实现一个简单的到点拜年的兔子. 详细设计 基本需求: 打开应用时可以在任务栏里显示 实现动态兔子生成 实现一只在任务栏里的兔子.基本就是一个应用的图标,并且图标是动态变化的.使用 Go 开发的话可以使用 systray 工具包,能够支持在任务栏里显示图标. go get github.c

  • 一文带你了解Golang中interface的设计与实现

    目录 前言 接口是什么 iface 和 eface 结构体 _type 是什么 itab 是什么 生成的 itab 是怎么被使用的 itab 关键方法的实现 根据 interfacetype 和 _type 初始化 itab 接口断言过程总览(类型转换的关键) panicdottypeI 与 panicdottypeE iface 和 eface 里面的 data 是怎么来的 convT* 方法 Java 里面的小整数享元模式 总结 在上一篇文章<go interface 基本用法>中,我们了

  • 一文带你了解Golang中的WaitGroups

    目录 什么是WaitGroups 如何使用WaitGroups 为什么使用WaitGroups而不是channel 需要注意的一件事 总结 什么是WaitGroups WaitGroups是同步你的goroutines的一种有效方式.想象一下,你和你的家人一起驾车旅行.你的父亲在一个条形商场或快餐店停下来,买些食物和上厕所.你最好想等大家回来后再开车去地平线.WaitGroups帮助你做到这一点. WaitGroups是通过调用标准库中的sync包来定义的. var wg sync.WaitGr

  • 一文带你了解Golang中的并发性

    目录 什么是并发性,为什么它很重要 并发性与平行性 Goroutines, the worker Mortys Channels, the green portal 总结 并发是一个很酷的话题,一旦你掌握了它,就会成为一笔巨大的财富.说实话,我一开始很害怕写这篇文章,因为我自己直到最近才对并发性不太适应.我已经掌握了基础知识,所以我想帮助其他初学者学习Go的并发性.这是众多并发性教程中的第一篇,请继续关注更多的教程. 什么是并发性,为什么它很重要 并发是指在同一时间运行多个事物的能力.你的电脑有

  • 一文带你了解Golang中select的实现原理

    目录 概述 结构 现象 非阻塞的收发 随机执行 编译 直接阻塞 独立情况 非阻塞操作 通用情况 运行时 初始化 循环 总结 概述 select是go提供的一种跟并发相关的语法,非常有用.本文将介绍 Go 语言中的 select 的实现原理,包括 select 的结构和常见问题.编译期间的多种优化以及运行时的执行过程. select 是一种与 switch 非常相似的控制结构,与 switch 不同的是,select 中虽然也有多个 case,但是这些 case 中的表达式都必须与 Channel

  • 一文带你掌握Golang Interface原理和使用技巧

    目录 1. interface 的基本概念 2. interface 的原理 3. interface 的使用技巧 3.1 使用空接口 3.2 使用类型断言 3.3 使用类型switch 3.4 使用接口组合 3.5 将方法定义在interface类型中 3.6 使用匿名接口嵌套 4. interface 的常见使用场景 4.1 依赖注入 4.2 测试驱动开发 4.3 框架设计 5. 总结 Golang 中的 interface 是一种非常重要的特性,可以让我们写出更加灵活的代码.interfa

  • 一文带你掌握Java8中Lambda表达式 函数式接口及方法构造器数组的引用

    目录 函数式接口概述 函数式接口示例 1.Runnable接口 2.自定义函数式接口 3.作为参数传递 Lambda 表达式 内置函数式接口 Lambda简述 Lambda语法 方法引用 构造器引用 数组引用 函数式接口概述 只包含一个抽象方法的接口,称为函数式接口. 可以通过 Lambda 表达式来创建该接口的对象. 可以在一个接口上使用 @FunctionalInterface 注解,这样做可以检查它是否是一个函数式接口.同时 javadoc 也会包含一条声明,说明这个接口是一个函数式接口.

  • 一文带你了解Java中的ForkJoin

    目录 什么是ForkJoin? ForkJoinTask 任务 ForkJoinPool 线程池 工作窃取算法 构造方法 提交方法 创建工人(线程) 例:ForkJoinTask实现归并排序 ForkJoin计算流程 前言: ForkJoin是在Java7中新加入的特性,大家可能对其比较陌生,但是Java8中Stream的并行流parallelStream就是依赖于ForkJoin.在ForkJoin体系中最为关键的就是ForkJoinTask和ForkJoinPool,ForkJoin就是利用

  • 一文带你了解Java中的SPI机制

    目录 1: SPI机制简介 2: SPI原理 3: 使用场景 4: 源码论证 5: 实战 6: 优缺点 6.1 优点 6.2 缺点 1: SPI机制简介 SPI 全称是 Service Provider Interface,是一种 JDK 内置的动态加载实现扩展点的机制,通过 SPI 技术我们可以动态获取接口的实现类,不用自己来创建.这个不是什么特别的技术,只是 一种设计理念. 2: SPI原理 Java SPI 实际上是基于接口的编程+策略模式+配置文件组合实现的动态加载机制. 系统设计的各个

  • Golang中interface{}转为数组的操作

    interface{} 转为普通类型 我们都知道在golang中interface{}可以代表任何类型,对于像int64.bool.string等这些简单类型,interface{}类型转为这些简单类型时,直接使用 p, ok := t.(bool) p, ok := t.(int64) 如果ok==true的话,就已经类型转换成功. 假设有这样一个场景,我们有一个函数有返回值,但是返回值的类型不定,所以我们的返回值类型只能以接口来代替了. 返回接口类型之后,我们就要对其类型进行判断然后进行类型

  • 一文带你理解 Vue 中的生命周期

    目录 1.beforeCreate & created 2.beforeMount & mounted 3.beforeUpdate & updated 4.beforeDestroy & destroyed 5.activated & deactivated 前言: 每个 Vue 实例在被创建之前都要经过一系列的初始化过程.例如需要设置数据监听.编译模板.挂载实例到 DOM.在数据变化时更新 DOM 等.同时在这个过程中也会运行一些叫做生命周期钩子的函数,给予用户

随机推荐