【Go】内存中的接口类型

时间:2022-09-05 17:29:41

【Go】内存中的接口类型

前言

抽象来讲,接口,是一种约定,是一种约束,是一种协议。

Go语言中,接口是一种语法类型,用来定义一种编程规范。

在Go语言中,接口主要有两类:

没有方法定义的空接口

有方法定义的非空接口

之前,有两篇图文详细介绍了空接口对象及其类型:

  • 【Go】内存中的空接口
  • 【Go】再谈空接口

本文将深入探究包含方法的非空接口,以下简称接口。

环境

  1. OS:Ubuntu20.04.2LTS;x86_64
  2. Go:goversiongo1.16.2linux/amd64

声明

操作系统、处理器架构、Go版本不同,均有可能造成相同的源码编译后运行时的寄存器值、内存地址、数据结构等存在差异。

本文仅包含 64 位系统架构下的 64 位可执行程序的研究分析。

本文仅保证学习过程中的分析数据在当前环境下的准确有效性。

代码清单

  1. //interface_in_memory.go
  2. packagemain
  3. import"fmt"
  4. import"reflect"
  5. import"strconv"
  6. typefoointerface{
  7. fmt.Stringer
  8. Foo()
  9. ree()
  10. }
  11. typefooImplint
  12. //go:noinline
  13. func(ifooImpl)Foo(){
  14. println("hellofoo")
  15. }
  16. //go:noinline
  17. func(ifooImpl)ree(){
  18. println("helloree")
  19. }
  20. //go:noinline
  21. func(ifooImpl)String()string{
  22. returnstrconv.Itoa(int(i))
  23. }
  24. funcmain(){
  25. impl:=fooImpl(123)
  26. impl.Foo()
  27. impl.ree()
  28. fmt.Println(impl.String())
  29. typeOf(impl)
  30. exec(impl)
  31. }
  32. //go:noinline
  33. funcexec(foofoo){
  34. foo.Foo()
  35. foo.ree()
  36. fmt.Println(foo.String())
  37. typeOf(foo)
  38. fmt.Printf("exec参数类型地址:%p\n",reflect.TypeOf(exec).In(0))
  39. }
  40. //go:noinline
  41. functypeOf(iinterface{}){
  42. v:=reflect.ValueOf(i)
  43. t:=v.Type()
  44. fmt.Printf("类型:%s\n",t.String())
  45. fmt.Printf("地址:%p\n",t)
  46. fmt.Printf("值:%d\n",v.Int())
  47. fmt.Println()
  48. }

以上代码,定义了一个包含3个方法的接口类型foo,还定义了一个fooImpl类型。在语法上,我们称fooImpl类型实现了foo接口。

运行结果

【Go】内存中的接口类型

程序结构

【Go】内存中的接口类型

数据结构介绍

接口数据类型的结构定义在reflect/type.go源文件中,如下所示:

  1. //表示一个接口方法
  2. typeimethodstruct{
  3. namenameOff//方法名称相对程序.rodata节的偏移量
  4. typtypeOff//方法类型相对程序.rodata节的偏移量
  5. }
  6. //表示一个接口数据类型
  7. typeinterfaceTypestruct{
  8. rtype//基础信息
  9. pkgPathname//包路径信息
  10. methods[]imethod//接口方法
  11. }

其实这只是一个表象,完整的接口数据类型结构如下伪代码所示:

  1. //表示一个接口类型
  2. typeinterfaceTypestruct{
  3. rtype//基础信息
  4. pkgPathname//包路径信息
  5. methods[]imethod//接口方法的slice,实际指向array字段
  6. uuncommonType//占位
  7. array[len(methods)]imethod//实际的接口方法数据
  8. }

完整的结构分布图如下:

【Go】内存中的接口类型

另外两个需要了解的结构体,之前文章已经多次介绍过,也在reflect/type.go源文件中,定义如下:

  1. typeuncommonTypestruct{
  2. pkgPathnameOff//包路径名称偏移量
  3. mcountuint16//方法的数量
  4. xcountuint16//公共导出方法的数量
  5. moffuint32//[mcount]method相对本对象起始地址的偏移量
  6. _uint32//unused
  7. }

reflect.uncommonType结构体用于描述一个数据类型的包名和方法信息。对于接口类型,意义不是很大。

  1. //非接口类型的方法
  2. typemethodstruct{
  3. namenameOff//方法名称偏移量
  4. mtyptypeOff//方法类型偏移量
  5. ifntextOff//通过接口调用时的地址偏移量;接口类型本文不介绍
  6. tfntextOff//直接类型调用时的地址偏移量
  7. }

reflect.method结构体用于描述一个非接口类型的方法,它是一个压缩格式的结构,每个字段的值都是一个相对偏移量。

  1. typenameOffint32//offsettoaname
  2. typetypeOffint32//offsettoan*rtype
  3. typetextOffint32//offsetfromtopoftextsection
  • nameOff 是相对程序 .rodata 节起始地址的偏移量。
  • typeOff 是相对程序 .rodata 节起始地址的偏移量。
  • textOff 是相对程序 .text 节起始地址的偏移量。

接口实现类型

从以上“运行结果”可以看到,fooImpl的类型信息位于0x4a9be0内存地址处。

关于fooImpl类型,【Go】再谈整数类型一文曾进行过非常详细的介绍,此处仅分析其方法相关内容。

查看fooImpl类型的内存数据如下:

【Go】内存中的接口类型

绘制成图表如下:

【Go】内存中的接口类型

fooImpl类型有3个方法,我们以Foo方法来说明接口相关的底层原理。

Foo方法的相关数据如下:

  1. varFoo=reflect.method{
  2. name:0x00000172,//方法名称相对程序`.rodata`节起始地址的偏移量
  3. mtyp:0x00009960,//方法类型相对程序`.rodata`节起始地址的偏移量
  4. ifn:0x000989a0,//接口调用的指令相对程序`.text`节起始地址的偏移量
  5. tfn:0x00098160,//正常调用的指令相对程序`.text`节起始地址的偏移量
  6. }

方法名称

method.name用于定位方法的名称,即一个reflect.name对象。

Foo方法的reflect.name对象位于 0x49a172(0x00000172 + 0x49a000)地址处,毫无疑问,解析结果是Foo。

  1. (gdb)p/x0x00000172+0x49a000
  2. $3=0x49a172
  3. (gdb)x/3bd0x49a172
  4. 0x49a172:103
  5. (gdb)x/3c0x49a172+3
  6. 0x49a175:70'F'111'o'111'o'
  7. (gdb)

方法类型

method.mtyp用于定位方法的数据类型,即一个reflect.funcType对象。

Foo方法的reflect.funcType对象,其位于0x4a3960(0x00009960 + 0x49a000)地址处。

Foo方法的数据类型的字符串表示形式是func()。

  1. (gdb)x/56bx0x4a3960
  2. 0x4a3960:0x080x000x000x000x000x000x000x00
  3. 0x4a3968:0x080x000x000x000x000x000x000x00
  4. 0x4a3970:0xf60xbc0x820xf60x020x080x080x33
  5. 0x4a3978:0x000x000x000x000x000x000x000x00
  6. 0x4a3980:0xa00x4a0x4c0x000x000x000x000x00
  7. 0x4a3988:0x340x110x000x000x000x000x000x00
  8. 0x4a3990:0x000x000x000x000x000x000x000x00
  9. (gdb)x/wx0x4a3988
  10. 0x4a3988:0x00001134
  11. (gdb)x/s0x00001134+0x49a000+3
  12. 0x49b137:"*func()"
  13. (gdb)

想要深入了解函数类型,请阅读【Go】内存中的函数。

接口方法

method.ifn字段的英文注释为function used in interface call,即调用接口方法时使用的函数。

在本例中,就是通过foo接口调用fooImpl类型的Foo函数时需要执行的指令集合。

具体来讲就是,代码清单中的exec函数内调用Foo方法需要执行的指令集合。

Foo函数的method.ifn = 0x000989a0,计算出其指令集合位于地址0x4999a0(0x000989a0 + 0x401000)处。

【Go】内存中的接口类型

通过内存数据可以清楚地看到,接口方法的符号是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)处。

【Go】内存中的接口类型

通过内存数据可以清楚地看到,类型方法的符号是main.fooImpl.Foo。

调用堆栈

通过上述分析,已经能够对method.ifn和method.tfn两个字段的含义建立起基本的认知。

实践是检验真理的唯一标准。能动手尽量别吵吵。

在main.(*fooImpl).Foo和main.fooImpl.Foo两个函数的入口处设置断点,通过行动巩固我们对接口类型的认识。

【Go】内存中的接口类型

通过动态调试,我们清晰地看到:

  • 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接口的类型信息存储位置。查看类型数据如下:

【Go】内存中的接口类型

将以上内存数据绘制成图表如下:

【Go】内存中的接口类型

  • 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源文件中:

  1. funcinterequal(p,qunsafe.Pointer)bool{
  2. x:=*(*iface)(p)
  3. y:=*(*iface)(q)
  4. returnx.tab==y.tab&&ifaceeq(x.tab,x.data,y.data)
  5. }
  6. funcifaceeq(tab*itab,x,yunsafe.Pointer)bool{
  7. iftab==nil{
  8. returntrue
  9. }
  10. t:=tab._type
  11. eq:=t.equal
  12. ifeq==nil{
  13. panic(errorString("comparinguncomparabletype"+t.string()))
  14. }
  15. ifisDirectIface(t){
  16. //Seecommentinefaceeq.
  17. returnx==y
  18. }
  19. returneq(x,y)
  20. }

该函数的执行逻辑是:

  1. 接口类型不同返回 false
  2. 接口类型为空返回 true
  3. 实现类型不可比较立即 panic
  4. 比较两个实现类型的对象并返回结果

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)。

  1. typeifacestruct{
  2. tab*itab
  3. dataunsafe.Pointer
  4. }
  5. typeitabstruct{
  6. inter*interfacetype//接口类型
  7. _type*_type//具体实现类型
  8. hashuint32//copyof_type.hash.Usedfortypeswitches.
  9. _[4]byte
  10. fun[1]uintptr//variablesized.fun[0]==0means_typedoesnotimplementinter.
  11. }

该结构体与reflect/value.go源文件中定义的nonEmptyInterface结构体是等价的:

  1. typenonEmptyInterfacestruct{
  2. itab*struct{
  3. ityp*rtype//接口类型
  4. typ*rtype//具体实现类型
  5. hashuint32//实现类型哈希种子
  6. _[4]byte//内存对齐
  7. fun[100000]unsafe.Pointer//方法数组,编译器控制数组长度
  8. }
  9. wordunsafe.Pointer//具体实现类型对象
  10. }

没错,接口对象就是iface对象,接口对象就是nonEmptyInterface对象。

源码清单中的exec函数接受一个foo接口类型的参数,在该函数入口处设置断点,即可查看其参数:

【Go】内存中的接口类型

内存数据显示,exec函数的参数foo的值如下伪代码所示:

  1. foo:=runtime.iface{
  2. tab:0x4dcbb8,
  3. data:0x543ad8,//指向整数123
  4. }

iface.data指针指向的内存数据是整数123,关于整数和runtime.staticuint64s,请阅读【Go】内存中的整数。

iface.tab指针指向一个全局符号go.itab.main.fooImpl,main.foo。该符号可以被视为一个全局常量,它是由Go编译器生成的,保存在可执行程序的.rodata节,其值如下伪代码所示:

  1. go.itab.main.fooImpl,main.foo=&runtime.itab{
  2. inter:0x4aa5c0,//foo接口类型的地址,上文已经详细分析
  3. _type:0x4a9be0,//fooImpl实现类型的地址,上文已经详细分析
  4. hash:0xb597252a,//fooImpl类型的哈希种子拷贝
  5. fun:[0x4999a0,0x499a20,0x499aa0]//方法数组
  6. }

在本例中,runtime.iface.tab.fun字段值包含三个指针,分别指向以下三个函数:

  • main.(*fooImpl).Foo (0x4999a0)
  • main.(*fooImpl).String (0x499a20)
  • main.(*fooImpl).ree (0x499aa0)

当exec函数调用foo接口的方法时,实际是从runtime.iface.tab.fun字段的数组中获得方法地址;

【Go】内存中的接口类型

所以,在本例中,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接口,并扩展了两个方法。

  1. typefoointerface{
  2. fmt.Stringer
  3. Foo()
  4. ree()
  5. }

而在程序运行时的内存数据中,在动态调试过程中,根本就没有fmt.Stringer接口什么事,连根毛都没看见。

实际上,Go编译器把foo接口的定义调整为以下代码,这就是接口继承和扩展的本质。

  1. typefoointerface{
  2. String()string
  3. Foo()
  4. ree()
  5. }

总结

本文完整地、详细地、深入地剖析了Go语言接口的类型结构、对象结构、实现类型、方法调用、继承扩展等等的各个方面的底层原理。

相信这是对Go接口类型的一次重新认识。

本文转载自微信公众号「Golang In Memory」

【Go】内存中的接口类型

原文链接:https://mp.weixin.qq.com/s/c31jSNN8Kji8pa9YwfcigA