1 接口内部实现
毫无疑问,接口是Go语言类型系统的灵魂,是Go语言实现多态和反射的基础。前面我们结束了接口的基本概念和用法,定义接口只需要简单声明一个方法集合即可,定义新类型时不需要显式地声明要实现的接口,接口的使用也很简单。但这一切语言特性的背后是语言设计者们的智慧:把复杂留给自己,把简单留给用户。
接口的底层是如何实现的?如果实现动态调用?接口的动态调用到底需要多大的额外开销?这是我们需要弄清楚的问题。
<备注> 基于 go1.15版本。
1.1 数据结构
接口变量必须初始化才有意义,没有初始化的接口变量的默认值是nil。具体类型实例传递给接口称为接口的初始化。接口的初始化过程中,编译器通过特定的数据结构描述这个过程。非空接口的内部数据结构如下:
// src/runtime/runtime2.go
type iface struct {
tab *itab //itab存放类型及方法指针信息
data unsafe.Pointer //数据信息
}
非空接口的初始化过程就是初始化一个 iface 类型的结构体。
- itab:是一个结构体,用来存放接口自身类型和并绑定的实例类型及实例相关的函数指针,具体内容下面会详细介绍。
- 数据指针data:指向接口绑定的实例的副本,接口的初始化也是一种值拷贝。data指向具体的实例数据,如果传递给接口的是值类型,则data指向的是实例的副本,如果传递给接口的是指针类型,则data指向指针副本。总而言之,无论是接口的转换,还是函数的调用,Go遵循一样的规则——值传递。
itab 结构体
// src/runtime/runtime2.go
type itab struct {
inter *interfacetype //接口自身的静态类型
_type *_type //_type就是接口存放的具体实例的类型(动态类型)
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结构体有5个字段:
- inner:指向接口类型元信息的指针。
- _type:是指向接口存放的具体类型元信息的指针,iface里的data指针指向的是该类型的值。一个是类型信息,另一个是类型的值。
- hash:具体类型的哈希值。_type 里面也有hash,这里冗余存放主要是为了接口类型断言或类型查询时快速访问。
- fun:是一个函数指针,可以理解为C++对象模型里面的虚拟函数指针,这里虽然只有一个元素,实际上指针数组的大小是可变的,编译器负责填充,运行时使用底层指针进行访问,不会受struct类型越界检查的约束,这些指针指向的是具体类型的方法。
itab 这个数据结构是非空接口实现动态调用的基础,itab 的信息被编译器和链接器保存了下来,存放在可执行文件的只读存储端(.rodata)中。itab 存放在静态分配的存储空间中,不受GC(垃圾回收器)的限制,其内存不会被回收。
_type 结构体
Go语言是一种强类型语言,编译器在编译时会做严格的类型校验。所以Go语言必然为每种类型维护一个类型的元信息,这个元信息在运行和反射时都会用到,Go语言的类型元信息的通用结构是 _type,其他类型都是以 _type 为内嵌字段封装而成的结构体。
// src/runtime/type.go
type _type struct {
size uintptr //大小
ptrdata uintptr // size of memory prefix holding all pointers
hash uint32 //类型哈希值
tflag tflag //类型的特征标记
align uint8 //_type作为整体变量存放时的字节对齐数
fieldAlign uint8 //当前结构体字段的对齐字节数
kind uint8 //基础类型枚举值和反射中的Kind一致,kind决定了如何解析该类型
// function for comparing objects of this type
// (ptr to object A, ptr to object B) -> ==?
equal func(unsafe.Pointer, unsafe.Pointer) bool //比较两个类型是否相同的equal函数
// gcdata stores the GC type data for the garbage collector.
// If the KindGCProg bit is set in kind, gcdata is a GC program.
// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
gcdata *byte //GC相关信息
str nameOff //str用来表示类型名称字符串在编译后二进制文件中某个Section的偏移量,由链接器负责填充
ptrToThis typeOff //ptrToThis用来表示类型元信息的指针在编译后二进制文件中某个Section的偏移量,由链接器负责填充
}
_type 包含所有类型的共同元信息,编译器和运行时可以根据该元信息解析具体类型、类型名存放位置、类型的Hash值等基本信息。
这里需要说明一下:_type 里面的 nameOff 和 typeOff 最终是由链接器负责确定和填充的,它们都是一个偏移量(offset),类型的名称和类型元信息实际上存放在连接后可执行文件的某个段(section)里,这两个值是相对于段内的偏移量,运行时提供了两个转换查找函数。
// src/runtime/type.go
//获取 _type 的name
func resolveNameOff(ptrInModule unsafe.Pointer, off nameOff) name {}
//获取 _type 的副本
func resolveTypeOff(ptrInModule unsafe.Pointer, off typeOff) *_type {}
<注意> Go语言类型元信息最初由编译器负责构建,并以表的形式存放在编译后的对象文件中,再由链接器在链接时进行段合并、符号重定向(填充某些值)。这些类型信息在接口的动态调用和反射中被运行时调用。
接口的类型元信息数据结构。代码如下:
// src/runtime/type.go
//描述接口类型的数据结构
type interfacetype struct {
typ _type //类型通用部分
pkgpath name //接口所属的包的名字信息,name内存放的不仅有名称,还有描述信息
expMethods []imethod //接口的方法
}
//接口方法元信息数据结构
type imethod struct {
name nameOff //方法名在编译后的section里面的偏移量
ityp typeOff //方法类型在编译后的section里面的偏移量
}
非空接口的数据结构脉络图
2 接口调用过程分析
前面讨论了接口内部的基本数据结构,本节就来跟踪接口实例化和动态调用的过程,使用Go源码和反汇编代码相结合的方式进行研究。示例代码如下:demo.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(){
a := Adder{id: 1234} //1234 = 0x4d2
var c Caler
c = a
c.Add(10, 20)
c.Sub(30, 40)
}
执行下面的命令:
$ go build -gcflags '-l -N' demo.go #编译生成可执行文件demo
<说明> -gcflags 垃圾回收选项,-l参数代表禁止内联,-N参数代表禁止优化。go在编译目标程序的时候会嵌入运行时(runtime)的二进制,
禁止内联和优化可以让运行时(runtime)中的函数变得更容易调试。
$ go tool objdump -s "main.main" demo #反汇编可执行文件
<说明> 查看用法:go doc objdump。
TEXT main.main(SB) demo.go
1 demo.go:20 0x45db00 64488b0c25f8ffffff MOVQ FS:0xfffffff8, CX
2 demo.go:20 0x45db09 483b6110 CMPQ 0x10(CX), SP
3 demo.go:20 0x45db0d 0f86a4000000 JBE 0x45dbb7
4 demo.go:20 0x45db13 4883ec48 SUBQ $0x48, SP
5 demo.go:20 0x45db17 48896c2440 MOVQ BP, 0x40(SP)
6 demo.go:20 0x45db1c 488d6c2440 LEAQ 0x40(SP), BP
7 demo.go:21 0x45db21 48c744242000000000 MOVQ $0x0, 0x20(SP)
8 demo.go:21 0x45db2a 48c7442420d2040000 MOVQ $0x4d2, 0x20(SP)
9 demo.go:22 0x45db33 0f57c0 XORPS X0, X0
10 demo.go:22 0x45db36 0f11442430 MOVUPS X0, 0x30(SP)
11 demo.go:23 0x45db3b 488b442420 MOVQ 0x20(SP), AX
12 demo.go:23 0x45db40 48890424 MOVQ AX, 0(SP)
13 demo.go:23 0x45db44 e837b1faff CALL runtime.convT64(SB)
14 demo.go:23 0x45db49 488b442408 MOVQ 0x8(SP), AX
15 demo.go:23 0x45db4e 4889442428 MOVQ AX, 0x28(SP)
16 demo.go:23 0x45db53 488d0dc6c30200 LEAQ go.itab.main.Adder,main.Caler(SB), CX
17 demo.go:23 0x45db5a 48894c2430 MOVQ CX, 0x30(SP)
18 demo.go:23 0x45db5f 4889442438 MOVQ AX, 0x38(SP)
19 demo.go:25 0x45db64 8401 TESTB AL, 0(CX)
20 demo.go:25 0x45db66 488b0dcbc30200 MOVQ go.itab.main.Adder,main.Caler+24(SB), CX
21 demo.go:25 0x45db6d 48890424 MOVQ AX, 0(SP)
22 demo.go:25 0x45db71 48c74424080a000000 MOVQ $0xa, 0x8(SP)
23 demo.go:25 0x45db7a 48c744241014000000 MOVQ $0x14, 0x10(SP)
24 demo.go:25 0x45db83 ffd1 CALL CX
25 demo.go:26 0x45db85 488b442430 MOVQ 0x30(SP), AX
26 demo.go:26 0x45db8a 8400 TESTB AL, 0(AX)
27 demo.go:26 0x45db8c 488b4020 MOVQ 0x20(AX), AX
28 demo.go:26 0x45db90 488b4c2438 MOVQ 0x38(SP), CX
29 demo.go:26 0x45db95 48890c24 MOVQ CX, 0(SP)
30 demo.go:26 0x45db99 48c74424081e000000 MOVQ $0x1e, 0x8(SP)
31 demo.go:26 0x45dba2 48c744241028000000 MOVQ $0x28, 0x10(SP)
32 demo.go:26 0x45dbab ffd0 CALL AX
33 demo.go:27 0x45dbad 488b6c2440 MOVQ 0x40(SP), BP
34 demo.go:27 0x45dbb2 4883c448 ADDQ $0x48, SP
35 demo.go:27 0x45dbb6 c3 RET
36 demo.go:20 0x45dbb7 e844aeffff CALL runtime.morestack_noctxt(SB)
37 demo.go:20 0x45dbbc 0f1f4000 NOPL 0(AX)
38 demo.go:20 0x45dbc0 e93bffffff JMP main.main(SB)
分析main函数的汇编代码如下:
3 demo.go:20 0x45db0d 0f86a4000000 JBE 0x45dbb7
4 demo.go:20 0x45db13 4883ec48 SUBQ $0x48, SP
5 demo.go:20 0x45db17 48896c2440 MOVQ BP, 0x40(SP)
6 demo.go:20 0x45db1c 488d6c2440 LEAQ 0x40(SP), BP
为main函数开辟堆栈空间并保存原来的BP指针,这是函数调用前编译器的固定动作。
7 demo.go:21 0x45db21 48c744242000000000 MOVQ $0x0, 0x20(SP)
8 demo.go:21 0x45db2a 48c7442420d2040000 MOVQ $0x4d2, 0x20(SP)
(gdb) p a
$1 = {id = 1234}
汇编代码的第7行和第8行,在堆栈上初始化局部对象,先初始化为0,再初始化为1234(0x4d2)。
9 demo.go:22 0x45db33 0f57c0 XORPS X0, X0
10 demo.go:22 0x45db36 0f11442430 MOVUPS X0, 0x30(SP)
(gdb) p c
$2 = {tab = 0x0, data = 0x0}
汇编代码第9行和第10行,声明接口变量c并初始化其默认值。
接口的初始化
11 demo.go:23 0x45db3b 488b442420 MOVQ 0x20(SP), AX
12 demo.go:23 0x45db40 48890424 MOVQ AX, 0(SP)
13 demo.go:23 0x45db44 e837b1faff CALL runtime.convT64(SB)
14 demo.go:23 0x45db49 488b442408 MOVQ 0x8(SP), AX
15 demo.go:23 0x45db4e 4889442428 MOVQ AX, 0x28(SP)
16 demo.go:23 0x45db53 488d0dc6c30200 LEAQ go.itab.main.Adder,main.Caler(SB), CX
17 demo.go:23 0x45db5a 48894c2430 MOVQ CX, 0x30(SP)
18 demo.go:23 0x45db5f 4889442438 MOVQ AX, 0x38(SP)
汇编代码11~18行,对应的是Go代码的 c = a 语句,即接口初始化过程。
使用gdb调试如下:
(gdb) n
25 c.Add(10, 20)
(gdb) p c
$1 = {tab = 0x489f20 <Adder,main.Caler>, data = 0xc0000140a8}
(gdb) p a
$2 = {id = 1234}
(gdb) p &a #Adder类型变量a的地址
$27 = (main.Adder *) 0xc00002e758
(gdb) p &c
$7 = (main.Caler *) 0xc00002e768
(gdb) p c.tab #iface结构的tab指向的是itab结构
$3 = (runtime.itab *) 0x489f20 <Adder,main.Caler>
(gdb) p c.tab.inter
$4 = (runtime.interfacetype *) 0x468d00
(gdb) p c.tab._type
$5 = (runtime._type *) 0x46b820
(gdb) p c.data #data指向的是Adder类型变量a的副本
$12 = (void *) 0xc0000140a8
(gdb) x /1dw 0xc0000140a8
0xc0000140a8: 1234 #该处存储的Adder结构体成员变量id的初始化值1234
接口初始化工作的结构流程图如下:
通过上面的流程图,可以很清晰的看出,在对非空接口变量进行赋值初始化时的内存分布。通过调用runtime.convT64(),该函数的返回值是一个iface数据结构。其tab指针指向一个itab结构,在将Adder类型变量a赋值给Caler接口变量c时,通过这个结构体将Adder类型和Caler类型关联起来。iface结构的data指针指向的Adder类型变量a的副本。
itab结构的inter指针指向的是一个描述接口类型的interfacetype结构,_type指针指向的是描述结构体类型的 _type结构。
方法的调用
19 demo.go:25 0x45db64 8401 TESTB AL, 0(CX)
20 demo.go:25 0x45db66 488b0dcbc30200 MOVQ go.itab.main.Adder,main.Caler+24(SB), CX
21 demo.go:25 0x45db6d 48890424 MOVQ AX, 0(SP)
22 demo.go:25 0x45db71 48c74424080a000000 MOVQ $0xa, 0x8(SP)
23 demo.go:25 0x45db7a 48c744241014000000 MOVQ $0x14, 0x10(SP)
24 demo.go:25 0x45db83 ffd1 CALL CX
25 demo.go:26 0x45db85 488b442430 MOVQ 0x30(SP), AX
26 demo.go:26 0x45db8a 8400 TESTB AL, 0(AX)
27 demo.go:26 0x45db8c 488b4020 MOVQ 0x20(AX), AX
28 demo.go:26 0x45db90 488b4c2438 MOVQ 0x38(SP), CX
29 demo.go:26 0x45db95 48890c24 MOVQ CX, 0(SP)
30 demo.go:26 0x45db99 48c74424081e000000 MOVQ $0x1e, 0x8(SP)
31 demo.go:26 0x45dba2 48c744241028000000 MOVQ $0x28, 0x10(SP)
32 demo.go:26 0x45dbab ffd0 CALL AX
(gdb) p &c.tab.inter
$34 = (runtime.interfacetype **) 0x489f20 <Adder,main.Caler>
(gdb) p &c.tab.fun
$35 = ([1]uintptr *) 0x489f38 <Adder,main.Caler+24>
(gdb) p /x c.tab.fun
$36 = {0x45dbe0}
可以看到itab结构的fun字段的偏移量=0x489f38-0x489f20=0x18,即10进制的24。其存放的是Add()函数的首地址。
至此,整个接口的动态调用完成。从中可以清除地看到,接口的动态调用分为两个阶段:
- 第1阶段就是构建 iface动态数据结构,这一阶段是在接口初始化或者说实例化的时候完成的,映射到Go语言就是 c = a。
- 第2阶段就是通过函数指针间接调用接口绑定的实例方法的过程,映射到Go语言就是 c.Add(10, 20) 和 c.Sub(30, 40)。
3 接口调用代价
上面分析了接口动态调用过程,这个过程有两部分多余时耗,一个是接口实例化的过程,也就是 iface 动态结构建立的过程,一旦实例化后,这个接口和具体类型的 itab 数据结构是可以复用的;另一个是接口的方法调用,它是一个函数指针的间接调用过程。
4 空接口数据结构
我们已经知道了空接口是没有任何方法集的接口,所以空接口内部不需要维护与动态内存分配相关的数据结构 itab。空接口只关心存放的具体类型是什么,具体类型的值是什么。所以空接口的底层数据结构也比较简单些。
// src/runtime/runtime2.go
type eface struct {
_type *_type
data unsafe.Pointer
}
从eface 的数据结构可以看出,空接口不是真的为空,其保留了具体实例的类型和值拷贝。即便存放的具体类型是空的,空接口也不是空的。
由于空接口自身没有方法集,所以空接口变量实例化后的真正用途不是接口方法的动态调用。空接口中真正的意义是支持多态。有如下几种方式使用了空接口:
(1)通过接口类型断言。
(2)通过接口类型查询。
(3)通过反射。
参考
《Go语言核心编程》