【Go】内存中的接口类型
发布于 2021-11-04 14:23 ,所属分类:软件编程学习资料
前言
抽象来讲,接口,是一种约定,是一种约束,是一种协议。
在Go
语言中,接口是一种语法类型,用来定义一种编程规范。
在Go
语言中,接口主要有两类:
没有方法定义的空接口
有方法定义的非空接口
之前,有两篇图文详细介绍了空接口对象及其类型:
【Go】内存中的空接口
【Go】再谈空接口
本文将深入探究包含方法的非空接口,以下简称接口。
环境
OS : Ubuntu 20.04.2 LTS; x86_64
Go : go version go1.16.2 linux/amd64
声明
操作系统、处理器架构、Go
版本不同,均有可能造成相同的源码编译后运行时的寄存器值、内存地址、数据结构等存在差异。
本文仅包含64
位系统架构下的64
位可执行程序的研究分析。
本文仅保证学习过程中的分析数据在当前环境下的准确有效性。
代码清单
// interface_in_memory.go
package main
import "fmt"
import "reflect"
import "strconv"
type foo interface {
fmt.Stringer
Foo()
ree()
}
type fooImpl int
//go:noinline
func (i fooImpl) Foo() {
println("hello foo")
}
//go:noinline
func (i fooImpl) ree() {
println("hello ree")
}
//go:noinline
func (i fooImpl) String() string {
return strconv.Itoa(int(i))
}
func main() {
impl := fooImpl(123)
impl.Foo()
impl.ree()
fmt.Println(impl.String())
typeOf(impl)
exec(impl)
}
//go:noinline
func exec(foo foo) {
foo.Foo()
foo.ree()
fmt.Println(foo.String())
typeOf(foo)
fmt.Printf("exec 参数类型地址:%p\n", reflect.TypeOf(exec).In(0))
}
//go:noinline
func typeOf(i interface{}) {
v := reflect.ValueOf(i)
t := v.Type()
fmt.Printf("类型:%s\n", t.String())
fmt.Printf("地址:%p\n", t)
fmt.Printf("值 :%d\n", v.Int())
fmt.Println()
}
以上代码,定义了一个包含3
个方法的接口类型foo
,还定义了一个fooImpl
类型。在语法上,我们称fooImpl
类型实现了foo
接口。
运行结果
程序结构
数据结构介绍
接口数据类型的结构定义在reflect/type.go
源文件中,如下所示:
// 表示一个接口方法
type imethod struct {
name nameOff // 方法名称相对程序 .rodata 节的偏移量
typ typeOff // 方法类型相对程序 .rodata 节的偏移量
}
// 表示一个接口数据类型
type interfaceType struct {
rtype // 基础信息
pkgPath name // 包路径信息
methods []imethod // 接口方法
}
其实这只是一个表象,完整的接口数据类型结构如下伪代码所示:
// 表示一个接口类型
type interfaceType struct {
rtype // 基础信息
pkgPath name // 包路径信息
methods []imethod // 接口方法的 slice,实际指向 array 字段
u uncommonType // 占位
array [len(methods)]imethod // 实际的接口方法数据
}
完整的结构分布图如下:
另外两个需要了解的结构体,之前文章已经多次介绍过,也在reflect/type.go
源文件中,定义如下:
type uncommonType struct {
pkgPath nameOff // 包路径名称偏移量
mcount uint16 // 方法的数量
xcount uint16 // 公共导出方法的数量
moff uint32 // [mcount]method 相对本对象起始地址的偏移量
_ uint32 // unused
}
reflect.uncommonType
结构体用于描述一个数据类型的包名和方法信息。对于接口类型,意义不是很大。
// 非接口类型的方法
type method struct {
name nameOff // 方法名称偏移量
mtyp typeOff // 方法类型偏移量
ifn textOff // 通过接口调用时的地址偏移量;接口类型本文不介绍
tfn textOff // 直接类型调用时的地址偏移量
}
reflect.method
结构体用于描述一个非接口类型的方法,它是一个压缩格式的结构,每个字段的值都是一个相对偏移量。
type nameOff int32 // offset to a name
type typeOff int32 // offset to an *rtype
type textOff int32 // offset from top of text section
nameOff
是相对程序.rodata
节起始地址的偏移量。typeOff
是相对程序.rodata
节起始地址的偏移量。textOff
是相对程序.text
节起始地址的偏移量。
接口实现类型
从以上“运行结果”可以看到,fooImpl
的类型信息位于0x4a9be0
内存地址处。
关于fooImpl
类型,【Go】再谈整数类型一文曾进行过非常详细的介绍,此处仅分析其方法相关内容。
查看fooImpl
类型的内存数据如下:
绘制成图表如下:
fooImpl
类型有3
个方法,我们以Foo
方法来说明接口相关的底层原理。
Foo
方法的相关数据如下:
var Foo = reflect.method {
name: 0x00000172, // 方法名称相对程序 `.rodata` 节起始地址的偏移量
mtyp: 0x00009960, // 方法类型相对程序 `.rodata` 节起始地址的偏移量
ifn: 0x000989a0, // 接口调用的指令相对程序 `.text` 节起始地址的偏移量
tfn: 0x00098160, // 正常调用的指令相对程序 `.text` 节起始地址的偏移量
}
方法名称
method.name
用于定位方法的名称,即一个reflect.name
对象。
Foo
方法的reflect.name
对象位于0x49a172
(0x00000172 + 0x49a000)地址处,毫无疑问,解析结果是Foo
。
(gdb) p /x 0x00000172 + 0x49a000
$3 = 0x49a172
(gdb) x /3bd 0x49a172
0x49a172: 1 0 3
(gdb) x /3c 0x49a172 + 3
0x49a175: 70 'F' 111 'o' 111 'o'
(gdb)
方法类型
method.mtyp
用于定位方法的数据类型,即一个reflect.funcType
对象。
Foo
方法的reflect.funcType
对象,其位于0x4a3960
(0x00009960 + 0x49a000)地址处。
Foo
方法的数据类型的字符串表示形式是func()
。
(gdb) x /56bx 0x4a3960
0x4a3960: 0x08 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x4a3968: 0x08 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x4a3970: 0xf6 0xbc 0x82 0xf6 0x02 0x08 0x08 0x33
0x4a3978: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x4a3980: 0xa0 0x4a 0x4c 0x00 0x00 0x00 0x00 0x00
0x4a3988: 0x34 0x11 0x00 0x00 0x00 0x00 0x00 0x00
0x4a3990: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
(gdb) x /wx 0x4a3988
0x4a3988: 0x00001134
(gdb) x /s 0x00001134 + 0x49a000 + 3
0x49b137: "*func()"
(gdb)
想要深入了解函数类型,请阅读【Go】内存中的函数。
接口方法
method.ifn
字段的英文注释为function used in interface call
,即调用接口方法时使用的函数。
在本例中,就是通过foo
接口调用fooImpl
类型的Foo
函数时需要执行的指令集合。
具体来讲就是,代码清单中的exec
函数内调用Foo
方法需要执行的指令集合。
Foo
函数的method.ifn = 0x000989a0
,计算出其指令集合位于地址0x4999a0
(0x000989a0 + 0x401000)处。
通过内存数据可以清楚地看到,接口方法的符号是main.(*fooImpl).Foo
。该函数主要做了两件事:
检查
panic
在
0x4999d7
地址处调用另一个函数main.fooImpl.Foo
。
类型方法
method.tfn
字段的英文注释为function used for normal method call
,即正常方法调用时使用的函数。
在本例中,就是通过fooImpl
类型的对象调用Foo
函数时需要执行的指令集合。
具体来讲就是,代码清单中的main
函数内调用Foo
方法需要执行的指令集合。
Foo
函数的method.tfn = 0x00098160
,计算出其指令集合位于地址0x499160
(0x00098160 + 0x401000)处。
通过内存数据可以清楚地看到,类型方法的符号是main.fooImpl.Foo
。
调用堆栈
通过上述分析,已经能够对method.ifn
和method.tfn
两个字段的含义建立起基本的认知。
实践是检验真理的唯一标准。能动手尽量别吵吵。
在main.(*fooImpl).Foo
和main.fooImpl.Foo
两个函数的入口处设置断点,通过行动巩固我们对接口类型的认识。
通过动态调试,我们清晰地看到:
main
函数调用了main.fooImpl.Foo
函数exec
函数调用了main.(*fooImpl).Foo
函数main.(*fooImpl).Foo
函数调用了main.fooImpl.Foo
函数main.(*fooImpl).Foo
函数的调试信息显示autogenerated
,表示其是由编译器生成的
对比本文“代码清单”,你是否对Go
语言的方法调用有了全新的认识。
几乎每种编程语言都会存在编译器自动生成代码的情况,用来实现某些通用逻辑的处理。本例中自动生成的main.(*fooImpl).Foo
函数中增加了panic
检查逻辑,不过, 乍看起来这像是某种设计缺陷导致不能直接调用main.fooImpl.Foo
函数,而是必须经过一个"中间人"才行。
接口类型
从以上“运行结果”可以看到,exec
函数的参数类型的地址是0x4aa5c0
,也就是foo
接口的类型信息存储位置。查看类型数据如下:
将以上内存数据绘制成图表如下:
rtype.size = 16
rtype.ptrdata = 16
rtype.hash = 0x187f135e
rtype.tflag = 0xf =
reflect.tflagUncommon | reflect.tflagExtraStar | reflect.tflagNamed
rtype.align = 8
rtype.fieldAlign = 8
rtype.kind = 0x14 = 20 =
reflect.Interface
rtype.equal = 0x4c4d38 ->
runtime.interequal
rtype.str = 0x000003e3 ->
*main.foo
rtype.ptrToThis = 0x00006a20 ->
*foo
interfaceType.pkgPath = 0x49a34c ->
main
interfaceType.methods.Data = 0x4aa620
interfaceType.methods.Len = 3
interfaceType.methods.Cap = 3
uncommonType.pkgPath = 0x0000034c
uncommonType.mcount = 0
uncommonType.xcount = 0
uncommonType.moff = 0x28
interfaceType.methods[0].name = 0x00000172 ->
Foo
interfaceType.methods[0].typ = 0x00009960 ->
func()
interfaceType.methods[1].name = 0x00000d7a ->
String
interfaceType.methods[1].typ = 0x0000a140 ->
func() string
interfaceType.methods[2].name = 0x000002ce ->
ree
interfaceType.methods[2].typ = 0x00009960 ->
func()
对象大小
接口类型的对象大小(rtype.size
)是16
字节,指针数据(rtype.ptrdata
)占16
字节;也就是说,接口类型的对象由2
个指针组成,与空接口(interface{}
)对象大小一样。
比较函数
内存数据显示,接口类型的对象使用runtime.interequal
进行相等性比较,该函数定义在runtime/alg.go
源文件中:
func interequal(p, q unsafe.Pointer) bool {
x := *(*iface)(p)
y := *(*iface)(q)
return x.tab == y.tab && ifaceeq(x.tab, x.data, y.data)
}
func ifaceeq(tab *itab, x, y unsafe.Pointer) bool {
if tab == nil {
return true
}
t := tab._type
eq := t.equal
if eq == nil {
panic(errorString("comparing uncomparable type " + t.string()))
}
if isDirectIface(t) {
// See comment in efaceeq.
return x == y
}
return eq(x, y)
}
该函数的执行逻辑是:
接口类型不同返回 false
接口类型为空返回 true
实现类型不可比较立即 panic
比较两个实现类型的对象并返回结果
uncommonType
在接口类型数据中,包路径信息可以通过interfaceType.pkgPath
字段获取,方法信息通过interfaceType.methods
字段获取, 因此uncommonType
数据几乎没什么意义,只不过保持一致性罢了。
在本例中,可执行程序.rodata
节的起始地址是0x49a000
,interfaceType.pkgPath
=uncommonType.pkgPath
+0x49a000
。
接口方法
接口方法(reflect.imethod
)只有名称
和类型
信息,没有可执行指令,所以相对普通方法(reflect.method
)缺少两个字段。
foo
接口的方法的名称
和类型
,与fooImpl
类型的方法的名称
和类型
完全一致,此处不再赘述。如有需要请阅读上文中方法相关的内容。
接口对象
runtime.interequal
函数源码清晰地显示,其比较的是两个runtime.iface
对象。
runtime.iface
结构体定义在runtime/runtime2.go
源码文件中,包含两个指针字段,大小是16
个字节(rtype.size
)。
type iface struct {
tab *itab
data unsafe.Pointer
}
type itab struct {
inter *interfacetype // 接口类型
_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.
}
该结构体与reflect/value.go
源文件中定义的nonEmptyInterface
结构体是等价的:
type nonEmptyInterface struct {
itab *struct {
ityp *rtype // 接口类型
typ *rtype // 具体实现类型
hash uint32 // 实现类型哈希种子
_ [4]byte // 内存对齐
fun [100000]unsafe.Pointer // 方法数组,编译器控制数组长度
}
word unsafe.Pointer // 具体实现类型对象
}
没错,接口对象就是iface
对象,接口对象就是nonEmptyInterface
对象。
源码清单中的exec
函数接受一个foo
接口类型的参数,在该函数入口处设置断点,即可查看其参数:
内存数据显示,exec
函数的参数foo
的值如下伪代码所示:
foo := runtime.iface {
tab: 0x4dcbb8,
data: 0x543ad8, // 指向整数 123
}
iface.data
指针指向的内存数据是整数123
,关于整数
和runtime.staticuint64s
,请阅读【Go】内存中的整数。
iface.tab
指针指向一个全局符号go.itab.main.fooImpl,main.foo
。该符号可以被视为一个全局常量,它是由Go
编译器生成的,保存在可执行程序的.rodata
节,其值如下伪代码所示:
go.itab.main.fooImpl,main.foo = & runtime.itab {
inter: 0x4aa5c0, // foo 接口类型的地址,上文已经详细分析
_type: 0x4a9be0, // fooImpl 实现类型的地址,上文已经详细分析
hash: 0xb597252a, // fooImpl 类型的哈希种子拷贝
fun: [0x4999a0, 0x499a20, 0x499aa0] // 方法数组
}
在本例中,runtime.iface.tab.fun
字段值包含三个指针,分别指向以下三个函数:
main.(*fooImpl).Foo (0x4999a0)
main.(*fooImpl).String (0x499a20)
main.(*fooImpl).ree (0x499aa0)
当exec
函数调用foo
接口的方法时,实际是从runtime.iface.tab.fun
字段的数组中获得方法地址;
所以,在本例中,exec`函数只能寻址以上三个方法,而无法寻址以下三个方法:
main.fooImpl.Foo
main.fooImpl.String
main.fooImpl.ree
如果定义新的类型实现了foo
接口,作为参数传递给exec
函数,Go
编译器就会生成新的runtime.itab
对象,并命名为go.itab.${pkg}.${type},main.foo
格式,也是以相同的方式进行调用和执行。
在Go
语言中,接口方法的调用逻辑是一致的。
接口扩展(继承)
在源码清单中,foo
接口继承了fmt.Stringer
接口,并扩展了两个方法。
type foo interface {
fmt.Stringer
Foo()
ree()
}
而在程序运行时的内存数据中,在动态调试过程中,根本就没有fmt.Stringer
接口什么事,连根毛都没看见。
实际上,Go
编译器把foo
接口的定义调整为以下代码,这就是接口继承和扩展的本质。
type foo interface {
String() string
Foo()
ree()
}
总结
本文完整地、详细地、深入地剖析了Go
语言接口的类型结构、对象结构、实现类型、方法调用、继承扩展等等的各个方面的底层原理。
相信这是对Go接口类型的一次重新认识。
相关资源