首页 > 编程笔记

Go语言接口的内部实现(超级详细)

我们知道接口是 Go 语言类型系统的灵魂,那么接口是如何来实现它的底层结构的呢?本节一起来探讨。

接口内部的数据结构

从前面的内容我们了解到,接口变量必须初始化才有意义,没有初始化的接口变量的默认值是 nil,没有任何意义。

具体类型实例传递给接口称为接口的实例化。在接口的实例化的过程中,编译器通过特定的数据结构描述这个过程。首先介绍非空接口的内部数据结构,非空接口的底层数据结构是 iface。

非空接口初始化的过程就是初始化一个 iface 类型的结构,例如:
type iface struct {
    tab *itab                //itab 存放类型及方法指针信息
    data unsafe.Pointer      //数据信息
}
从上面的代码可以看到 iface 的结构非常简单,有两个指针类型字段:
data 指向具体的实例数据,如果传递给接口的是值类型,则 data 指向的是实例的副本,如果传递给接口的是指针类型,则 data 指向指针的副本。总而言之,无论是接口的转换,还是函数调用,Go 语言都遵循值传递。

接下来看一下 itab 数据结构,itab 是接口内部实现的核心和基础,例如:
type itab struct {
    inter *interfacetype             //接口自身的静态类型
    _type *_type                     //_type就是接口存放的具体实例的类型(动态类型)
    //hash存放具体类型的Hash值
    hash uint32           //copy of _type.hash. Used for type switches.
    _ [4]byte
    fun [1]uintptr        //variable sized. fun[0] == 0 means _type does not implement inter.
}
itab 有如下 4 个字段:
itab 这个数据结构是非空接口实现动态调用的基础,itab 的信息被编译器和链接器保存了下来,存放在可执行文件的只读存储段(.rodata)中。itab 存放在静态分配的存储空间中,不受 GC 的限制,其内存不会被回收。

Go 语言是一种强类型的语言,编译器在编译时会做严格的类型校验。所以,Go 语言必然为每种类型维护一个类型的元信息,这个元信息在运行和反射时都会用到,Go 语言的类型元信息的通用结构是 _type,其他类型都是以 _type 为内嵌字段封装而成的结构体,例如:
type _type struct {
    size uintptr           //大小
    ptrdata uintptr        //指向元信息的指针
    hash uint32            //类型Hash
    tflag tflag            //类型的特征标记
    align uint8            //_type作为整体变量存放时的对齐字节数
    fieldalign uint8       //当前结构字段的对齐字节数
    kind uint8             //基础类型枚举值和反射中的Kind一致,kind决定了如何解析该类型
    alg *typeAlg           //指向一个函数指针表,该表有两个函数,一个是计算类型Hash函数,
                          //另一个是比较两个类型是否相同的equal函数
    gcdata *byte           //GC相关信息
    str nameOff            //str用来表示类型名称字符串在编译后二进制文件中某个section的偏移量
                          //由链接器负责填充
    ptrToThis typeOff      //ptrToThis 用来表示类型元信息的指针在编译后二进制文件中某个
                          //section的偏移量
                          //由链接器负责填充
}
_type 包含所有类型的共同元信息,编译器和运行时可以根据该元信息解析具体类型、类型名存放位置、类型 Hash 值等基本信息。

这里需要说明一下,_type 中的 nameOff 和 typeOff 最终是由链接器负责确定和填充的,它们都是一个偏移量(offset),类型的名称和类型元信息实际上存放在连接后可执行文件的某个段(section)中,这两个值是相对于段内的偏移量,运行时提供两个转换查找函数,例如:
//获取_type的name
func resolveNameOff (ptrInModule unsafe. Pointer, off nameOff) name {}
//获取_type的副本
func resolveTypeOff (ptrInModule unsafe.Pointer, off typeOff) *_type {}
Go 语言类型元信息最初由编译器负责构建,并以表的形式存放在编译后的对象文件中,再由链接器在链接时进行段合并、符号重定向(填充某些值)。这些类型信息在接口的动态调用和反射中被运行时引用。

接口的类型元信息的数据结构如下:
//描述接口的类型
type interfacetype struct {
    typ _type              //类型通用部分
    pkgpath name           //接口所属包的名字信息,name内存放的不仅有名称,还有描述信息
    mhdr []imethod         //接口的方法
}
//接口方法元信息
type imethod struct {
    name nameOff           //方法名在编译后的section中的偏移量
    ityp typeOff           //方法类型在编译后的section中的偏移量
}

接口的调用过程

前面讲解了接口内部的基本数据结构,接下来跟踪接口实例化和动态调用过程,使用 Go 源码和反汇编代码相结合的方式进行研究,例如:
package main
type Caler interface {
    Add(a,b int) int
    Sub(a,b int) int
}
type Adder struct{ id int }
func (adder Adder) Add(a, b int) int { return a+b }
func (adder Adder) Sub(a,b int)int{ return a-b }
func main(){
    var m Caler = Adder{id: 1234}
    m.Add(10,32)
}
生成汇编代码:

go build -gcflags="-S –N -l" iface.go >iface.s 2>&1

接下来分析 main 函数的汇编代码:
"". main STEXT size=151 args=0x0 locals=0x40
…
0x000f 00015 (src/iface.go:16) SUBQ $64,SP
0x0013 00019 (src/iface.go:16) MOVQ BP, 56 (SP)
0x0018 00024 (src/iface.go:16) LEAQ 56(SP),BP
为 main 函数堆栈开辟空间并保存原来的BP指针,这是函数调用前编译器的固定动作。var m Caler =Adder{id: 1234} 语句汇编代码分析:
0x001d 00029 (src/iface.go:17) M0VQ $0, ""..autotmp_1+32 (SP)
0x0026 00038 (src/iface.go:17) MOVQ $1234,""..autotmp_1+32 (SP)
在堆栈上初始化局部对象 Adder,先初始化为 0,后初始化为 1234。
0x002f 00047 (src/iface.go:17) LEAQ go. itab. "" .Adder, "".Caler(SB),AX
0x0036 00054 (src/iface.go:17) MOVQ AX,(SP)
这两条语句非常关键,首先 LEAQ 指令是一个获取地址的指令,go.itab."".Adder,"" .Caler (SB) 是一个全局符号引用,通过该符号能够获取接口初始化时 itab 数据结构的地址。这个标号在链接器链接的过程中会替换为具体的地址。

我们知道(SP)中存放的是指向 itab(Caler,Adder)的元信息的地址,这里(SP)是函数调用第一个参数的位置,例如:
0x003a 00058 (src/iface .go:17) LEAQ "".. autotmp_1+32(SP), AX
0x003f 00063 (src/iface.go:17) MOVQ AX,8(SP)
0x0044 008 (src/iface.go:17) PCDATA $0, $0
复制刚才的 Adder 类型对象的地址到 8(SP),8(SP) 是函数调用的第二个参数位置,例如:
0x0044 00068 (src/iface.go:17) CALL runtime.convT2I64 (SB)
runtime.convT2I64 函数是运行时接口动态调用的核心函数。runtime 中有一类这样的函数,看一下 runtime convT2I64 的源码:
func convT2I64 (tab *itab, elem unafe.Pointer) (i iface) {
   t := tab._ type
   if raceenabled {
       raceReadObjectPC( t, elem, getcallerpc (unsafe.Pointer(&tab)), funcPC (convT2I64))
   }
   if msanenabled {
       msanread(elem, t.size)
   }
   var x unsafe. Pointer
   if *(*uint64) (elem) == 0{
       x= unsafe. Pointer (&zeroVal [0])
   }else{
       x= mallocgc(8, t, false)
       *(*uint64) (x) = *(*uint64) (elem)
   }
   i.tab = tab
   i.data = x
   return
}
从上述源码可以清楚看出,runtime convT2I64 的两个参数分别是 *itab 和 unsafe.Pointer 类型,这两个参数正是上文传递进去的两个参数值:go.itab."".Adder,"".Caler (SB) 和指向 Adder 对象复制的指针。runtime.convT2I64 的返回值是一个 iface 数据结构,其意义就是根据 itab 元信息和对象值复制的指针构建和初始化 iface 数据结构,iface 数据结构是实现接口动态调用的关键。

至此,已经完成了接口初始化的工作,即完成了 iface 数据结构的构建过程。下一步就是接口方法调用了,例如:
0x0049 00073 (src/ iface.go:17) MOVQ 24(SP), AX
0x004e 00078 (src/iface . go:17) MOVQ 16(SP), CX
0x0053 00083 (src/ iface. go:17) MOVQ CX, "". m+40(SP)
0x0058 00088 (src/ iface.go:17) MOVQ AX, "".m+48 (SP)
16(SP) 和 24(SP) 存放的是函数 runtime.convT2I64 的返回值,分别是指向 itab 和 data 的指针,将指向 itab 的指针复制到 40(SP),将指向对象 data 的指针复制到 48(SP) 位置。

m.Add(10, 32) 对应的汇编代码如下:
0x005d 00093 (src/ iface.go:18) MOVQ "" .m+40(SP), AX
0x0062 00098 (src/ iface.go:18) MOVQ 32 (AX), AX
0x0066 00102 (src/ iface.go:18) MOVQ "" .m+48(SP), CX
0x006b 00107 (src/iface.go:18) MOVQ $10, 8(SP)
0x0074 00116 (src/iface.go:18) MOVQ $32, 16(SP)
0x007d 00125 (src/iface.go:18) MOVQ CX, (SP)
0x0081 00129 (src/iface.go:18) PCDATA $0, $0
0x0081 00129 (src/iface .go:18) CALL AX
第 1 条指令是将 itab 的指针(位于 40(SP) )复制到 AX 寄存器。第 2 条指令是 AX 将 itab 的偏移 32 字节的值复制到 AX。

再来看一下 itab 的数据结构:
type itab struct {
    inter *interfacetype
    _type *_type
    link *titab
    hash uint32           //copy of _type .hash. Used for type switches .
    bad bool              //type does not implement interface
    inhash bool           //has this itab been added to hash?
    unused [2]byte
    fun [1]uintptr        //variable sized
}

此函数调用时,对象的值的副本作为第1个参数,调用格式如下:
func (reciver, param1, param2)
至此,整个接口的动态调用完成。从中可以清楚地看到,接口的动态调用分为两个阶段:
接下来看一下 go.itab. "". Adder, "". Caler (SB)这个符号在哪里。使用 readelf 工具来静态地分析编译后的 ELF 格式的可执行程序。
#编译
#go build -gcflags="-N -1" iface.go
#readelf -s -W iface | egrep 'itab'
60: 000000000047b220 0 OBJECT LOCAL DEFAULT 5 runtime . itablink
61: 0000000000476230 0 OBJECT LOCAL DEFAULT 5 runtime.eitablink
88: 00000000004aa100 48 OBJECT GLOBAL DEFAULT 8 go.itab.main.Adder, main.Caler
214: 00000000004aa080 40 OBJECT GLOBAL DEFAULT 8 go. itab. runtime.errorString, error
418: 00000000004095e0 1129 FUNC GLOBAL DEFAULT 1 runtime.getitab
419: 0000000000409a501665 FUNC GLOBAL DEFAULT 1 runtime. additab
420: 000000000040a0e0 257 FUNC GLOBAL DEFAULT 1 runtime . itabsinit
可以看到符号表中 go. itab. main.Adder, main.Caler 对应本程序中itab的元信息,它被存放在第 8 个段中。来看一下第 8 个段是什么段。
#readelf -S -W iface | egrep '\[ 8\] | Nr'
[Nr] Name Type Address Off Size ES Flg LK Inf Al
[ 8] . noptrdata PROGBITS 00000000004aa000 0aa000 000a78 00 WA 0 0 32
可以看到这个接口动态转换的数据元信息存放在 . noptrdata 段中,它是由链接器负责初始化的。可以进一步使用 dd 工具读取并分析其内容。

空接口的数据结构

前面已经了解到空接口interface{}是没有任何方法集的接口,所以,空接口内部不需要维护和动态内存分配相关的数据结构 itab。空接口只关心存放的具体类型是什么、具体类型的值是什么,所以,空接口的底层数据结构也是很简单的,例如:
//空接口
type eface struct{
    _type *_type
    data unsafe.Pointer
}
从 eface 的数据结构可以看出,空接口不是真的为空,其保留了具体实例的类型和值副本,即便存放的具体类型是空的,空接口也不是空的。

由于空接口自身没有方法集,所以,空接口变量实例化后的真正用途不是接口方法的动态调用。空接口在 Go 语言中真正的意义是支持多态。以下几种方式使用了空接口(将空接口类型还原):

推荐阅读