当前位置: 首页 > 科技观察

[Go]内存中的接口类型

时间:2023-03-11 21:49:14 科技观察

前言接口抽象的说就是一种约定,一种约束,一种协议。在Go语言中,接口是一种用于定义编程规范的语法类型。在Go语言中,主要有两种类型的接口:没有方法定义的空接口和有方法定义的非空接口。在此之前,有两篇图文详细介绍了空接口对象及其类型:【Go】内存中的空接口【Go】再说说空接口本文将深入探讨包含方法的非空接口,以下简称接口。环境操作系统:Ubuntu20.04.2LTS;x86_64Go:goversiongo1.16.2linux/amd64声明操作系统、处理器架构、Go版本不同,可能导致编译相同源码时存在寄存器值、内存地址、数据结构。不同之处。本文仅包括64位系统架构下64位可执行程序的研究和分析。本文仅保证当前环境下学习过程中分析数据的准确性和有效性。代码清单//interface_in_memory.gopackagemainimport"fmt"import"reflect"import"strconv"typefoointerface{fmt.StringerFoo()ree()}typefooImplint//go:noinlinefunc(ifooImpl)Foo(){println("hellofoo")}//go:noinlinefunc(ifooImpl)ree(){println("helloree")}//go:noinlinefunc(ifooImpl)String()string{returnstrconv.Itoa(int(i))}funcmain(){impl:=fooImpl(123)impl.Foo()impl.ree()fmt.Println(impl.String())typeOf(impl)exec(impl)}//去:noinlinefuncexec(foofoo){foo.Foo()foo.ree()fmt.Println(foo.String())typeOf(foo)fmt.Printf("exec参数类型地址:%p\n",reflect.TypeOf(exec).In(0))}//go:noinlinefunctypeOf(iinterface{}){v:=reflect.ValueOf(i)t:=v.Type()fmt.Printf("类型:%s\n",t.String())fmt.Printf("地址:%p\n",t)fmt.Printf("Value:%d\n",v.Int())fmt.Println()}上面的代码定义了一个包含3个方法的接口类型foo,还定义了一个fooImpl类型。从句法上讲,我们说类型fooImpl实现了foo接口。运行结果程序结构数据结构介绍接口数据类型结构定义在reflect/type.go源文件中,如下://表示一个接口方法typeimethodstruct{namenameOff//方法名相对于program.rodata的偏移量sectiontyptypeOff//方法类型相对于program.rodata段的偏移量}//表示一个接口数据类型typeinterfaceTypestruct{rtype//基本信息pkgPathname//封装路径信息methods[]imethod//接口方法}其实,这只是一种表示,完整的接口数据类型结构如下伪代码所示://表示一个接口类型typeinterfaceTypestruct{rtype//基本信息pkgPathname//包路径信息methods[]imethod//接口方法的slice,其中实际指向数组字段uuncommonType//占用数组[len(methods)]imethod//实际接口方法数据}完整的结构分布图如下:T另外两个需要理解的结构在之前的文章中已经多次介绍过,在reflect/type.go源文件中也有,定义如下:methodsxcountuint16//公共导出方法数mofuint32//[mcount]方法相对于该对象起始地址的偏移量_uint32//未使用的}reflect.uncommonType结构,用于描述数据类型的包名和方法信息。对于接口类型,它没有多大意义。//非接口类型方法typemethodstruct{namenameOff//方法名偏移mtyptypeOff//方法类型偏移ifntextOff//通过接口调用时的地址偏移;接口类型本文不介绍tfntextOff//直接调用时地址偏移}reflect.method结构用于描述一个非接口类型的方法。它是一个压缩格式的结构体,每个字段的值是一个相对偏移量。typenameOffint32//offsettoanametypetypeOffint32//offsettoan*rtypetypetextOffint32//offsetfromtopoftextsectionnameOff是从程序的.rodata部分开始的偏移量。typeOff是距程序.rodata部分开头的偏移量。textOff是距程序.text部分开头的偏移量。接口实现类型从上面的“运行结果”可以看出,fooImpl的类型信息位于内存地址0x4a9be0。关于fooImpl类型,[Go]RevisitingIntegerTypes已经给出了非常详细的介绍,这里我们只分析其方法相关的内容。查看fooImpl类型的内存数据如下:画一张图如下:fooImpl类型有3个方法,我们以Foo方法来说明接口相关的底层原理。Foo方法的相关数据如下:varFoo=reflect.method{name:0x00000172,//方法名相对于程序`.rodata`段起始地址的偏移量mtyp:0x00009960,//方法类型是相对于程序的`.rodata`段起始地址的偏移量ifn:0x000989a0,//接口调用的指令相对于程序的`.text`段起始地址的偏移量programtfn:0x00098160,//接口调用的指令是相对于程序中`.text`段开头地址的Offset}methodnamemethod.name用于定位方法名,是一个reflect.name对象。Foo方法的reflect.name对象位于地址0x49a172(0x00000172+0x49a000),分析结果毫无疑问是Foo。(gdb)p/x0x00000172+0x49a000$3=0x49a172(gdb)x/3bd0x49a1720x49a172:103(gdb)x/3c0x49a172+30x49a175:70'F'111'o'111'o'(gdb)方法类型method.mtyp为定位方法的数据类型,即reflect.funcType对象。Foo方法的reflect.funcType对象,位于地址0x4a3960(0x00009960+0x49a000)。Foo方法的数据类型的字符串表示是func()。(gdb)x/56bx0x4a39600x4a3960:0x080x000x000x000x000x000x000x000x4a3968:0x080x000x000x000x000x000x000x000x4a3970:0xf60xbc0x820xf60x020x080x080x330x4a3978:0x000x000x000x000x000x000x000x000x4a3980:0xa00x4a0x4c0x000x000x000x000x000x4a3988:0x340x110x000x000x000x000x000x000x4a3990:0x000x000x000x000x000x000x000x00(gdb)x/wx0x4a39880x4a3988:0x00001134(gdb)x/s0x00001134+0x49a000+30x49b137:"*func()"(gdb)想要要深入了解函数类型,请阅读[Go]内存中的函数。接口方法method.ifn字段的英文注解是functionusedininterfacecall,即调用接口方法时使用的函数。在这个例子中,就是通过foo接口调用fooImpl类型的Foo函数时需要执行的指令集。具体来说就是在代码清单中的exec函数中调用Foo方法需要执行的指令集。Foo函数的method.ifn=0x000989a0计算出其指令集位于地址0x4999a0(0x000989a0+0x401000)。从内存数据可以清楚的看出接口方法的符号是main.(*fooImpl).Foo。这个函数主要做了两件事:检查panic是否在地址0x4999d7调用了另一个函数main.fooImpl.Foo。typemethodmethod.tfn字段的英文注解是functionusedfornormalmethodcall,即用于普通方法调用的函数。在这个例子中,它是通过一个类型为fooImpl的对象调用Foo函数时需要执行的一组指令。具体来说,就是在代码清单中调用main函数中的Foo方法需要执行的指令集。Foo函数的method.tfn=0x00098160,计算出其指令集位于地址0x499160(0x00098160+0x401000)。从内存数据中可以清楚地看到,类型方法的符号是main.fooImpl.Foo。Callstack通过上面的分析,我们已经能够对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函数中。然而,乍一看,这似乎是某种设计缺陷,无法直接调用main.fooImpl.Foo函数,而是必须经过一个“中间人”才行。接口类型从上面的“运行结果”可以看出,exec函数的参数type的地址为0x4aa5c0,这是foo接口的类型信息的存放位置。查看类型数据如下:将以上内存数据绘制成图形如下:rtype.size=16rtype.ptrdata=16rtype.hash=0x187f135ertype.tflag=0xf=reflect.tflagUncommon|反射.tflagExtraStar|reflect.tflagNamedrtype.align=8rtype.fieldAlign=8rtype.kind=0x14=20=reflect.Interfacertype.equal=0x4c4d38->runtime.interequalrtype.str=0x000003e3->*main.foortype.ptrToThis=0x00006a20->*foointerfaceType.pkgPath=0x49a34c->maininterfaceType.methods.Data=0x4aa620interfaceType.methods.Len=3interfaceType.methods.Cap=3uncommonType.pkgPath=0x0000034cuncommonType.mcount=0uncommonType.xcount=0uncommonType。moff=0x28interface[Type.methods0].name=0x00000172->FoointerfaceType.methods[0].typ=0x00009960->func()interfaceType.methods[1].name=0x00000d7a->StringinterfaceType.methods[1.typ=0x0000a140->func()stringinterfaceType.methods[2].name=0x000002ce->reeinterfaceType.methods[2].typ=0x00009960->func()objectsize接口类型的对象大小(rtype.size)为16字节,指针数据(rtype.ptrdata)占16字节;也就是说,接口类型的对象由2个指针组成,空接口(interface{})的对象大小与比较函数内存数据显示接口类型的对象大小相同使用runtime.interequal进行相等比较,定义在runtime/alg中。在go源文件中:funcinterequal(p,qunsafe.Pointer)bool{x:=*(*iface)(p)y:=*(*iface)(q)returnx.tab==y.tab&&ifaceeq(x.tab,x.data,y.data)}funcifaceeq(tab*itab,x,yunsafe.Pointer)bool{iftab==nil{returntrue}t:=tab._typeeq:=t.equalifeq==nil{panic(errorString)("comparinguncomparabletype"+t.string()))}ifisDirectIface(t){//见commentinefaceeq.returnx==y}returneq(x,y)}这个函数的执行逻辑是:如果接口类型不同,返回错误的;如果接口类型为空,则返回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/runtime2.go源代码文件中定义了runtime.iface结构体,其中包含两个大小为16字节的指针域(rtype.size)。typeifacestruct{tab*itabdataunsafe.Pointer}typeitabstruct{inter*interfacetype//接口type_type*_type//具体实现类型hashuiint32//copyof_type.hash.Usedfortypeswitches._[4]bytefun[1]uintptr//variablesized.fun[0]==0means_typedoesnotimplementinter.}这个结构相当于reflect/value.go源文件中定义的nonEmptyInterface结构:typenonEmptyInterfacestruct{itab*struct{ityp*rtype//接口类型typ*rtype//具体实现类型hashuiint32//实现类型hashseed_[4]byte//内存对齐fun[100000]unsafe.Pointer//方法数组,编译器控制数组长度}wordunsafe.Pointer//具体实现类型对象}对,接口对象就是iface对象,接口object是nonEmptyInterface对象。源列表中的exec函数接受foo接口类型的参数。在函数入口处打断点查看其参数:内存数据显示exec函数的参数foo的值如下伪代码所示:foo:=runtime.iface{tab:0x4dcbb8,data:0x543ad8,//pointtointeger123}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类型的哈希种子Copyfun:[0x4999a0,0x499a20,0x499aa0]//方法数组}本例中为runtime.iface。tab.fun字段值包含三个指向以下三个函数的指针:main.(*fooImpl).Foo(0x4999a0)main.(*fooImpl).String(0x499a20)main.(*fooImpl).ree(0x499aa0)当执行函数调用foo接口的方法,该方法实际上是从runtime.iface.tab.fun字段地址的数组中获取的;所以,在这个例子中,exec`函数只能处理上面三个方法,而不能处理下面三个方法:,将其作为参数传递给exec函数,Go编译器会生成一个新的runtime.itab对象,并将其命名为go.itab.${pkg}.${type},main.foo格式,也是同样的格式调用和执行的方式。在Go语言中,接口方法的调用逻辑是一致的。接口扩展(继承)在sourcelist中,foo接口继承了fmt.Stringer接口,扩展了两个方法。typefoointerface{fmt.StringerFoo()ree()}在程序运行时的内存数据中,在动态调试过程中,根本就没有fmt.Stringer接口这个东西,甚至连它的根都看不到。事实上,Go编译器将foo接口的定义调整为如下代码,这就是接口继承和扩展的本质。typefoointerface{String()stringFoo()ree()}总结本文完整、详细、深入地分析了Go语言接口的类型结构、对象结构、实现类型、方法调用、继承与扩展等底层原理。相信这是对Go接口类型的全新认识。本文转载自微信公众号「记忆中的Golang」