golang 接口实现原理

166 阅读9分钟

本文是读了左大《go语言设计与实现》接口章节笔记以及实际做实验的记录,实验环境为

go version go1.14.2 windows/amd64

golang中接口类型分为两种,一种是不带任何方法的空接口,一种是带一组方法的接口,它们对应的数据结构如下

type iface struct {   	// 带一组方法的接口
    tab 	*itab
    data 	unsafe.Pointer
}

type eface struct {		// 不带方法的接口
    _type 	*_type
    data	unsafe.pointer
}

type itab struct {		// itab类型
    inter *interfacetype// 接口类型元数据
	_type *_type		// 值具体类型元数据
	hash  uint32 		// _type.hash的拷贝,用来做类型断言
	_     [4]byte
	fun   [1]uintptr 	// 虚函数表,存储一组函数指针,虽然申明为固定数组,但使用时会通过原始指针获取其中的数据
}

type _type struct {
	size       uintptr
	ptrdata    uintptr // size of memory prefix holding all pointers
	hash       uint32
	tflag      tflag
	align      uint8
	fieldAlign uint8
	kind       uint8
	// function for comparing objects of this type
	// (ptr to object A, ptr to object B) -> ==?
	equal func(unsafe.Pointer, unsafe.Pointer) bool
	// 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
	str       nameOff
	ptrToThis typeOff
}

类型转换

非空接口iface

结构体实现接口

func main() {
	var i I = Cat{Name: "world", Age: 20}
	i.i()
}

type I interface {
	i() int
}

type Cat struct {
	Name string
	Age  uint64
}

func (c Cat) i() int {
	var a int = 1
	return a
}

运行go tool compile -N -S -l main.go后得到汇编如下

// main函数分配栈空间
0x001a 00026 (main.go:3)        SUBQ    $80, SP
0x001e 00030 (main.go:3)        MOVQ    BP, 72(SP)
0x0023 00035 (main.go:3)        LEAQ    72(SP), BP

// 初始化Cat结构体
0x0028 00040 (main.go:4)        MOVQ    $0, ""..autotmp_1+48(SP)
0x0031 00049 (main.go:4)        XORPS   X0, X0
0x0034 00052 (main.go:4)        MOVUPS  X0, ""..autotmp_1+56(SP)
0x0039 00057 (main.go:4)        LEAQ    go.string."world"(SB), AX
0x0040 00064 (main.go:4)        MOVQ    AX, ""..autotmp_1+48(SP)
0x0045 00069 (main.go:4)        MOVQ    $5, ""..autotmp_1+56(SP)
0x004e 00078 (main.go:4)        MOVQ    $20, ""..autotmp_1+64(SP)

// 将初始化好的Cat结构体装换成接口I
0x0057 00087 (main.go:4)        LEAQ    go.itab."".Cat,"".I(SB), AX
0x005e 00094 (main.go:4)        MOVQ    AX, (SP)
0x0062 00098 (main.go:4)        LEAQ    ""..autotmp_1+48(SP), AX
0x0067 00103 (main.go:4)        MOVQ    AX, 8(SP)
0x006c 00108 (main.go:4)        CALL    runtime.convT2I(SB)
0x0071 00113 (main.go:4)        MOVQ    16(SP), AX
0x0076 00118 (main.go:4)        MOVQ    24(SP), CX
0x007b 00123 (main.go:4)        MOVQ    AX, "".i+32(SP)
0x0080 00128 (main.go:4)        MOVQ    CX, "".i+40(SP)

// 调用函数
0x0085 00133 (main.go:5)        MOVQ    "".i+32(SP), AX
0x008a 00138 (main.go:5)        TESTB   AL, (AX)
0x008c 00140 (main.go:5)        MOVQ    24(AX), AX
0x0090 00144 (main.go:5)        MOVQ    "".i+40(SP), CX
0x0095 00149 (main.go:5)        MOVQ    CX, (SP)
0x0099 00153 (main.go:5)        CALL    AX
0x009b 00155 (main.go:6)        MOVQ    72(SP), BP
0x00a0 00160 (main.go:6)        ADDQ    $80, SP
0x00a4 00164 (main.go:6)        RET

其中convT2I函数代码如下,可以看到传入了tab和真实数据的地址elem,分别对应了iface中的tabdata,返回了一个iface类型,该函数的作用就是按照itab记录的真实类型在堆中分配内存空间x,然后将elem指向的真实数据复制到x中,然后将传入的tabiface就返回

func convT2I(tab *itab, elem unsafe.Pointer) (i iface) {
	t := tab._type
	x := mallocgc(t.size, t, true)
	typedmemmove(t, x, elem)
	i.tab = tab
	i.data = x
	return
}

在汇编代码第24行之后,main函数栈空间如下图所示
sp+48 ~ sp+72 这段空间存储的是代码中 Cat{Name: "world", Age: 20}
sp+32 ~ sp+40 这段空间存储的就是i变量,i 变量是一个iface结构体
sp+16 ~ sp+32 这段空间是调用convT2I函数的返回值,也是一个iface,其中sp+8这个位置记录的是&Cat与另外两个不同,它存储的是原始Cat结构体的地址 ,与其他两个不同,其他两个是指向堆中的Cat结构体,这个是在调用convT2I函数时创建的
sp ~ sp+16这段空间存储的是传递给convT2I函数的参数,分别对应*itabunsafe.Pointer
未命名绘图.png
在汇编代码32行调用函数之前,main栈空间如下图所示,其中sp位置变成了堆中Cat结构体的地址,它是调用i函数时的参数,用以在调用i函数
未命名绘图-第 2 页.png
这里有一点困惑的时,我们采用的是结构体实现的接口,也是使用结构体装换成接口,但是调用函数时,传递的参数明显不是结构体,而是一个结构体的地址,即iface中的data;如果直接用Cat结构体调用i方法(如var cat = Cat{}; cat.i()),而不是通过接口的方式调用,则可以发现传递的参数是Cat结构体,即上图中sp ~ sp+24之间数据依次为 &("world"),5,20
对于上述问题,是为了确定栈帧大小,因为实现接口的结构体各种各样,如果使用值接收器方法,那需要在栈上放置整个接收者的数据,在分配栈空间时也需要综合考虑所有可能的结构体的大小,不太好分配栈帧大小,因此直接使用指针。推荐幼麟工作室的观点链接:www.bilibili.com/video/BV1hv…

结构体指针实现接口

func main() {
	var i I = &Cat{Name: "world", Age: 20}
	i.i()
}

type I interface {
	i() int
}

type Cat struct {
	Name string
	Age  uint64
}

func (c *Cat) i() int {
	var a int = 1
	return a
}

运行go tool compile -S -N -l main.go后,生成汇编语言如下,删减了一些无用代码

// main函数分配栈空间
0x001a 00026 (main.go:3)        SUBQ    $56, SP
0x001e 00030 (main.go:3)        MOVQ    BP, 48(SP)
0x0023 00035 (main.go:3)        LEAQ    48(SP), BP

// 初始化Cat结构体,并取其地址
0x0028 00040 (main.go:4)        LEAQ    type."".Cat(SB), AX
0x002f 00047 (main.go:4)        MOVQ    AX, (SP)
0x0033 00051 (main.go:4)        CALL    runtime.newobject(SB)
0x0038 00056 (main.go:4)        MOVQ    8(SP), DI
0x003d 00061 (main.go:4)        MOVQ    DI, ""..autotmp_2+16(SP)
0x0042 00066 (main.go:4)        MOVQ    $5, 8(DI)
0x0055 00085 (main.go:4)        LEAQ    go.string."world"(SB), AX
0x005c 00092 (main.go:4)        MOVQ    AX, (DI)
0x0061 00097 (main.go:4)        MOVQ    ""..autotmp_2+16(SP), AX
0x0066 00102 (main.go:4)        TESTB   AL, (AX)
0x0068 00104 (main.go:4)        MOVQ    $20, 16(AX)

// 初始化i变量,i类型为接口I
0x0070 00112 (main.go:4)        MOVQ    ""..autotmp_2+16(SP), AX
0x0075 00117 (main.go:4)        MOVQ    AX, ""..autotmp_1+24(SP)
0x007a 00122 (main.go:4)        LEAQ    go.itab.*"".Cat,"".I(SB), CX
0x0081 00129 (main.go:4)        MOVQ    CX, "".i+32(SP)
0x0086 00134 (main.go:4)        MOVQ    AX, "".i+40(SP)

// 调用i.i()方法
0x008b 00139 (main.go:5)        MOVQ    "".i+32(SP), AX
0x0090 00144 (main.go:5)        TESTB   AL, (AX)
0x0092 00146 (main.go:5)        MOVQ    24(AX), AX
0x0096 00150 (main.go:5)        MOVQ    "".i+40(SP), CX
0x009b 00155 (main.go:5)        MOVQ    CX, (SP)
0x009f 00159 (main.go:5)        CALL    AX

// 返回
0x00a1 00161 (main.go:6)        MOVQ    48(SP), BP
0x00a6 00166 (main.go:6)        ADDQ    $56, SP
0x00aa 00170 (main.go:6)        RET

runtime.newobject函数是为指定类型分配内存空间,并返回分配空间的地址,其源代码如下所示

func newobject(typ *_type) unsafe.Pointer {
	return mallocgc(typ.size, typ, true)
}

初始化i变量完成后,即执行完第24行汇编代码之后,main函数栈空间数据如下所示
sp ~ sp+8 这个空间保存的是Cat类型元数据的地址,它作为runtime.newobject的参数
sp+8 ~ sp+16 是堆中的Cat结构体的地址,它是runtime.newobject的返回值
sp+16 ~ sp+32 两段空间都是堆中Cat结构体的地址,没啥用,属于临时存放
sp+32 ~ sp+48 中存放的是变量i,其中包括itab和data数据
未命名绘图-第 3 页.png

在调用函数时,即第31行汇编代码,main函数栈空间如下图所示
与上图相比,只在sp ~ sp+8 之间有变化,它作为传递给i函数的参数,指向堆中Cat结构体
未命名绘图-第 4 页.png

空接口eface

实验代码如下

func main() {
	var i interface{} = Cat{Name: "Hello", Age: 200}
	print(i)
}

type Cat struct {
	Name string
	Age  int
}

编译后得到下列代码,之前的初始化代码与前面差不多,只截取关键代码如下

0x006e 00110 (main.go:4)        LEAQ    type."".Cat(SB), AX
0x0075 00117 (main.go:4)        MOVQ    AX, "".i+16(SP)
0x007a 00122 (main.go:4)        LEAQ    ""..autotmp_2+32(SP), AX
0x007f 00127 (main.go:4)        MOVQ    AX, "".i+24(SP)

代码很简单将类型信息的地址放到sp+16的位置,实际值地址保存在sp+24的位置,不做过多赘述了

类型断言

实验代码为

package main

func main() {
	var cat = &Cat{Name: "Hello", Age: 100}
	var i I = cat
	switch i.(type) {
	case *Cat:
		print("*Cat")
	case Cat:
		print("Cat")
	}
	return
}

type I interface {
	i()
}

type Cat struct {
	Name string
	Age  uint64
}

func (c Cat) i() {
	c.Name = "World"
	c.Age = 200
}

汇编语言如下,总的来说,就是按照每个分支对比,看进入哪个分支,对比流程就是对比接口中保存的类型hash值case分支中的类型hash值是否相等,然后还要对比go.itab的地址与接口中的*itab是否相等,如果相等则进入对应的分支(注:这段代码使用的时go1.18.2 windows/amd64)

// 分配栈空间
0x000a 00010 (main.go:3)        SUBQ    $112, SP
0x000e 00014 (main.go:3)        MOVQ    BP, 104(SP)
0x0013 00019 (main.go:3)        LEAQ    104(SP), BP

// 初始化Cat结构体
0x0018 00024 (main.go:4)        MOVQ    $0, ""..autotmp_4+80(SP)
0x0021 00033 (main.go:4)        MOVUPS  X15, ""..autotmp_4+88(SP)
0x0027 00039 (main.go:4)        LEAQ    ""..autotmp_4+80(SP), AX
0x002c 00044 (main.go:4)        MOVQ    AX, ""..autotmp_3+32(SP)
0x0031 00049 (main.go:4)        TESTB   AL, (AX)
0x0033 00051 (main.go:4)        MOVQ    $5, ""..autotmp_4+88(SP)
0x003c 00060 (main.go:4)        LEAQ    go.string."Hello"(SB), CX
0x0043 00067 (main.go:4)        MOVQ    CX, ""..autotmp_4+80(SP)
0x0048 00072 (main.go:4)        TESTB   AL, (AX)
0x004a 00074 (main.go:4)        MOVQ    $100, ""..autotmp_4+96(SP)
0x0053 00083 (main.go:4)        MOVQ    AX, "".cat+24(SP)

// Cat结构体指针转为接口I
0x0058 00088 (main.go:5)        MOVQ    AX, ""..autotmp_2+40(SP)
0x005d 00093 (main.go:5)        LEAQ    go.itab.*"".Cat,"".I(SB), CX
0x0064 00100 (main.go:5)        MOVQ    CX, "".i+48(SP)
0x0069 00105 (main.go:5)        MOVQ    AX, "".i+56(SP)

// 下面是switch的相关代码
0x006e 00110 (main.go:6)        MOVQ    "".i+48(SP), AX
0x0073 00115 (main.go:6)        MOVQ    "".i+56(SP), CX
0x0078 00120 (main.go:6)        MOVQ    AX, ""..autotmp_5+64(SP)
0x007d 00125 (main.go:6)        MOVQ    CX, ""..autotmp_5+72(SP)
0x0082 00130 (main.go:6)        TESTQ   AX, AX
// 1. 首先对比i变量是否为nil,如果i==nil,则跳转到结束位置
0x0085 00133 (main.go:6)        JNE     140	// 如果i不是nil
0x0087 00135 (main.go:6)        JMP     326	// 这里判断i是否为nil,如果是,则跳转到00326执行,直接结束
0x008c 00140 (main.go:6)        MOVL    16(AX), AX	// 这里是取出itab中的hash值到ax
0x008f 00143 (main.go:6)        MOVL    AX, ""..autotmp_7+20(SP)
// 2. 下面是Case Cat分支,看接口i具体值是否为Cat类型
0x0093 00147 (main.go:6)        CMPL    AX, $284731410	// 对比hash值与Cat类型的hash值,这个hash值是在编译的时候计算的,存储在只读存储区中
0x0098 00152 (main.go:6)        JEQ     156				// 如果相等,往下执行
0x009a 00154 (main.go:6)        JMP     233				// 如果不相等,跳转到00233指令执行
0x009c 00156 (main.go:9)        MOVQ    ""..autotmp_5+64(SP), AX	// 取出变量i中*itab的值放到AX
0x00a1 00161 (main.go:9)        LEAQ    go.itab."".Cat,"".I(SB), CX	// 取出go.itab."".Cat,"".I的地址到CX,这个也是在编译的时候生成的,存储在只读存储区中
0x00a8 00168 (main.go:9)        CMPQ    AX, CX	// 将i中*itab与go.itab."".Cat做比较
0x00ab 00171 (main.go:9)        JEQ     175		// 如果相等,往下执行
0x00ad 00173 (main.go:9)        JMP     182		// 不相等则跳转到00182指令
0x00af 00175 (main.go:9)        MOVL    $1, AX	// 这里往AX中放了个1,可以认为是标志位,说明00171代码对比结果为相等
0x00b4 00180 (main.go:9)        JMP     186
0x00b6 00182 (main.go:9)        XORL    AX, AX	// 这里将AX清空了,是00171代码对比结果为不相等的清空
0x00b8 00184 (main.go:9)        JMP     186
0x00ba 00186 (main.go:9)        MOVB    AL, ""..autotmp_6+19(SP)
0x00be 00190 (main.go:9)        NOP
0x00c0 00192 (main.go:9)        TESTB   AL, AL	// 这里旧用到了前面的标志位,用变量flag表示
0x00c2 00194 (main.go:9)        JNE     198		// 如果flag != 0,跳转到00198
0x00c4 00196 (main.go:9)        JMP     231		// 如果flag == 0, 跳转到00231
0x00c6 00198 (main.go:9)        JMP     200		// 继续往下执行
// 这里是如果i中保存的是Cat类型,调用print函数,输出"Cat"字符串
0x00c8 00200 (main.go:10)       CALL    runtime.printlock(SB)	
0x00cd 00205 (main.go:10)       LEAQ    go.string."Cat"(SB), AX
0x00d4 00212 (main.go:10)       MOVL    $3, BX
0x00d9 00217 (main.go:10)       CALL    runtime.printstring(SB)
0x00de 00222 (main.go:10)       NOP
0x00e0 00224 (main.go:10)       CALL    runtime.printunlock(SB)
0x00e5 00229 (main.go:6)        JMP     328		// 跳转到返回地址,结束执行
0x00e7 00231 (main.go:6)        JMP     324		// 这里是flag == 0的,跳转到00324,其实也是跳转到返回地址
// 3. 下面是Case *Cat分支,看接口i具体值是否为*Cat类型,流程与上面类似
// 首先判断接口中保存的类型hash值是否与*Cat类型hash值相等
// 然后判断接口中*itab指针指向的地址与编译时生成的go.itab.*"".Cat,"".I相等
0x00e9 00233 (main.go:6)        CMPL    AX, $593696792
0x00ee 00238 (main.go:6)        JEQ     242
0x00f0 00240 (main.go:6)        JMP     320
0x00f2 00242 (main.go:7)        MOVQ    ""..autotmp_5+64(SP), AX
0x00f7 00247 (main.go:7)        LEAQ    go.itab.*"".Cat,"".I(SB), CX
0x00fe 00254 (main.go:7)        NOP
0x0100 00256 (main.go:7)        CMPQ    AX, CX
0x0103 00259 (main.go:7)        JEQ     263
0x0105 00261 (main.go:7)        JMP     270
0x0107 00263 (main.go:7)        MOVL    $1, AX
0x010c 00268 (main.go:7)        JMP     274
0x010e 00270 (main.go:7)        XORL    AX, AX
0x0110 00272 (main.go:7)        JMP     274
0x0112 00274 (main.go:7)        MOVB    AL, ""..autotmp_6+19(SP)
0x0116 00278 (main.go:7)        TESTB   AL, AL
0x0118 00280 (main.go:7)        JNE     284
0x011a 00282 (main.go:7)        JMP     317
0x011c 00284 (main.go:7)        JMP     286
0x011e 00286 (main.go:8)        NOP
0x0120 00288 (main.go:8)        CALL    runtime.printlock(SB)
0x0125 00293 (main.go:8)        LEAQ    go.string."*Cat"(SB), AX
0x012c 00300 (main.go:8)        MOVL    $4, BX
0x0131 00305 (main.go:8)        CALL    runtime.printstring(SB)
0x0136 00310 (main.go:8)        CALL    runtime.printunlock(SB)
0x013b 00315 (main.go:6)        JMP     328
// 后面都是返回地址
0x013d 00317 (main.go:6)        JMP     322
0x013f 00319 (main.go:6)        NOP
0x0140 00320 (main.go:6)        JMP     322
0x0142 00322 (main.go:6)        JMP     324
0x0144 00324 (main.go:6)        JMP     328
0x0146 00326 (main.go:6)        JMP     328
0x0148 00328 (main.go:12)       MOVQ    104(SP), BP
0x014d 00333 (main.go:12)       ADDQ    $112, SP
0x0151 00337 (main.go:12)       RET

动态派发

理解了接口的数据结构之后,动态派发也很简单了。直接根据其中的itab的func函数列表中选择合适的函数即可,接收者即为iface中的data数据
itab结构是在编译器生成的,对于每个接口的每个实现者都会有一个固定的itab,以下面的代码为例,CatDog都实现了接口I

func main() {
	var i I = Cat{Name: "hello", Age: 12}
	i.a()
	i = Dog{}
	i.b()
}

type I interface {
	a()
	b()
}

type Cat struct {
	Name string
	Age  int
}

func (c Cat) a() {

}

func (c Cat) b() {

}

type Dog struct {
	Name string
	Age  int
}

func (c Dog) a() {

}

func (c Dog) b() {

}

在生成的汇编代码中搜索go.itab,就能找到相关的itab结构。注意,一定要在代码中有将Cat和Dog转为接口I的指令,否则生成的汇编代码不会生成这些itab结构

go.itab."".Dog,"".I SRODATA dupok size=40	// Dog的itab结构
        0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
        0x0010 bf 36 61 92 00 00 00 00 00 00 00 00 00 00 00 00  .6a.............
        0x0020 00 00 00 00 00 00 00 00                          ........
        rel 0+8 t=1 type."".I+0
        rel 8+8 t=1 type."".Dog+0
        rel 24+8 t=1 "".(*Dog).a+0			// (*Dog).a的函数地址
        rel 32+8 t=1 "".(*Dog).b+0			// (*Dog).b的函数地址
go.itab."".Cat,"".I SRODATA dupok size=40	// Cat的itab结构
        0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
        0x0010 12 a8 f8 10 00 00 00 00 00 00 00 00 00 00 00 00  ................
        0x0020 00 00 00 00 00 00 00 00                          ........
        rel 0+8 t=1 type."".I+0
        rel 8+8 t=1 type."".Cat+0
        rel 24+8 t=1 "".(*Cat).a+0
        rel 32+8 t=1 "".(*Cat).b+0

所以在动态派发的时候,只需要根据iface.itab即可找到对应的函数即可

结构体和指针

左大在书中陈述了为什么结构体指针转为接口,再调用值接收器的方法是不被允许的原因,个人不是很理解,找了一些资料,以下是相关链接及其截图

存储在接口中的具体值是不可寻址的

第一个观点首先出自go wiki中关于方法集的文档,链接:github.com/golang/go/w…
image.png
至于为什么要求这个具体值是不可寻址的,StackOverflow上有位大佬举了个例子:stackoverflow.com/a/48874650/…

语言规范问题

首先得说方法集这个概念:go.dev/ref/spec#Me…

  1. T类型的方法集包含所有使用T作为接收器定义的方法
  2. *T类型的方法集包含所有使用T*T作为接收器定义的方法
  3. 接口类型的方法集合,是定义在接口中所有类型集合的交集

其中第三点是针对go1.18泛型出现后的补充,这里不做过多描述
image.png
而为什么是*T的方法集包含T的方法集合,在Go FAQ中是这么说的:go.dev/doc/faq#dif…
接口中保存了*T,通过*T可以解引用获得T,如果反过来,那没有一个安全的方式获取T的指针*T,如果那样做了,将会允许方法修改保存在接口中值的内容
image.png

其他观点

最后一个观点是来自于左大同一个章节的评论中其他大佬的回复
image.png
个人认为该观点比较合理,在结构体变量转换成接口后,接口中保存的是结构体变量的拷贝值,而不是原来的值,而我们调用指针接收器的函数肯定是期望使用原值的指针,但是靠接口是无法获得原值的指针的,所以直接让其编译不通过
我认为这个是可以实现的,只是golang官方没有这样实现罢了,估计原因就是评论中所说的explicit is better than implicit,如果通过编译器隐式创建一个新的指针指向原值,那对GC也是会有一定影响的,对开发人员会产生一定的困惑