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

深入理解Go语言中函数的概念

时间:2023-03-17 10:52:21 科技观察

在计算机编程中,函数实际上是一个抽象的概念,是一个编程接口;通过抽象,可以将一个复杂的系统分解成各种不透明的接口,将复杂的算法进行封装,方便相互调用,实现分层、可扩展、方便等。具体来说,函数一般是指一段独立的、可复用的程序逻辑,用来方便其他函数的调用;英文名称是function,有时也叫method、routine。编译器最终将函数编译成机器指令,存放在可执行文件中。在进程的内存空间中,一个函数无非是一段包含机器指令的连续内存区域;仅在结构上,它与数组没有区别。在Go语言中,函数(function)是一等公民(first-classcitizen),不仅仅是代码片段,还是数据类型;和其他数据类型一样,它有自己的类型信息。函数类型函数类型有多种定义,它们是等价的。在runtime/type.go源文件中定义如下:typefunctypestruct{typ_typeinCountuint16outCountuint16}在reflect/type.go和internal/reflectlite/type.go源文件中定义如下://funcTyperepresentsafunctiontype.////A*rtypeforeachinandoutparameterisstoredinarraythat//directlyfollowsthefuncType(可能还有它的uncommonType)。所以//一个函数类型withonemethod,oneinput,andoneoutputis:////struct{//funcType//uncommonType//[2]*rtype//[0]isin,[1]isout//}typefuncTypestruct{rtypeinCountuint16outCountuint16//topbitissetiflastinputparameteris...}从funcType结构体的注释中可以看出函数类型的信息其实很复杂。其实完整的函数类型定义如下伪代码所示:/parametertypelistoutTypes[outCount]*rtype//返回值类型列表methods[uncommon.mcount]method//方法列表}uncommonType和method定义在reflect/type.go源文件中,用于存储和解析类型方法信息。typeuncommonTypestruct{pkgPathnameOff//包路径名偏移量mcountuint16//方法数xcountuint16//公共导出方法数mofuint32//方法相对于该对象起始地址的偏移量_uint32//未使用}//非接口类型方法typemethodstruct{namenameOff//方法名偏移量mtyptypeOff//方法类型偏移量ifntextOff//通过接口调用时的地址偏移量;接口类型本文不介绍tfntextOff//直接调用时的地址偏移typeOff是从.rodata部分开始的偏移量。textOff是距.text部分开头的偏移量。有关reflect.name的介绍,请阅读Integersinmemory。函数类型结构分布示意图完整的函数类型信息结构分布如下图所示:每个函数都有自己的类型信息,但有的函数简单,有的函数复杂,并不是每个函数类型都包括上图中的所有字段在里面。一个简单的函数类型信息结构分布可能如下图所示:或注:上图中浅灰色块表示内存对齐填充,不存储任何数据。当然,函数也可能有参数没有返回值,函数也可能没有参数和返回值,它们的类型信息结构会有些不同。想象一下,但它只是一个简化的结构。通过本文的内存分析,我们将了解函数类型的每一个细节。环境操作系统:Ubuntu20.04.2LTS;x86_64Go:goversiongo1.16.2linux/amd64声明操作系统、处理器架构、Go版本不同,可能导致编译相同源码时存在寄存器值、内存地址、数据结构。不同之处。本文仅包括64位系统架构下64位可执行程序的研究和分析。本文仅保证当前环境下学习过程中分析数据的准确性和有效性。本文只讨论普通函数和声明的函数类型,不讨论接口、实现、闭包等知识点。代码列表packagemainimport("errors""fmt""reflect")//声明函数类型typecalcfunc(a,bint)(sumint)//私有方法->packagescope//go:noinlinefunc(fcalc)foo(a,bint)int{returnf(a,b)+1}//Ree公共导出方法->publicscope//go:noinlinefunc(fcalc)Ree(a,bint)int{returnf(a,b)-1}funcmain(){//普通函数Print(fmt.Printf)//函数类型实例varaddcalc=func(a,bint)(sumint){returna+b}fmt.Println(add.foo(1,2))fmt.Println(add.Ree(1,2))Print(add)//匿名函数Print(func(){fmt.Println("helloanonymousfunction")})//方法;closuref:=errors.New("helloerror").ErrorPrint(f)}//go:noinlinefuncPrint(iinterface{}){v:=reflect.ValueOf(i)fmt.Println("type",v.Type().String())fmt.Println("address",v)fmt.Println()}运行效果上面的代码清单主要是打印出四个函数的类型和内存地址。编译运行,输出结果如下:在本文的内存分析过程中,有很多操作是通过偏移计算内存地址。主要涉及.text和.rodata两个段,它们在本程序中的信息如下:普通函数以常用函数fmt.Printf为例,研究普通函数的类型信息。从上面的运行输出结果可以看出,fmt.Printf函数类型的字符串表示形式为:func(string,...interface{})(int,error)动态调试在Print的入口处设置断点函数,检查fmt.Printf函数的类型信息。将fmt.Printf函数的类型信息画成图表如下:rtype.size=8rtype.ptrdata=8rtype.hash=0xd9fb8597rtype.tflag=2=reflect.tflagExtraStarrtype.align=8rtype.fieldAlign=8rtype.kind=0x33rtype.equal=0=nilrtype.str=0x00005c90=>*func(string,...interface{})(int,error)rtype.ptrToThis=0funcType.inCount=2funcType.outCount=0x8002函数类型。inTypes=[0x4a4860,0x4a2f80]funcType.outTypes=[0x4a41e0,0x4a9860]指针常量函数对象的大小为8字节(rtype.size),包含8字节的指针数据(rtype.ptrdata),所以我们可以把函数对象被视为指针。也就是说,fmt.Printf其实是一个指针,只不过这个指针是一个不可变常量。这与C/C++一致,函数名是一个指针常量。类型名rtype.tflag=2=reflect.tflagExtraStarfmt.Printf函数有自己的数据类型,但是类型没有名字。数据类型数据类型(Kind)计算如下:constkindMask=(1<<5)-1func(t*rtype)Kind()Kind{returnKind(t.kind&kindMask)}0x33&31=19=reflect.Funcis变量参数fmt.Printf函数的参数个数(funcType.inCount)为2,返回值个数也为2,为什么funcType.outCount的值为0x8002?原因是funcType.outCount字段不仅需要记录函数返回值的个数,还需要标明函数最后一个参数是否为可变参数类型;如果是,则将funcType.outCount字段值的最高位设置为1。在reflect/type.go源文件中,判断可变参数的方法如下:func(t*rtype)IsVariadic()bool{ift.Kind()!=Func{panic("reflect:IsVariadicofnon-functype"+t.String())}tt:=(*funcType)(unsafe.Pointer(t))returntt.outCount&(1<<15)!=0}返回值个数的计算方法是:outCount:=funcType.outCount&(1<<15-1)好奇funcType.outCount字段中怎么没有存储可变标志。参数和返回值的类型在fmt.Printf函数定义中,参数和返回值的类型有:string...interface{}interror在内存的函数类型信息中,参数的类型指针和返回值被存储;通过这些指针查看它们的类型信息如下:从内存数据可以看出fmt.Printf函数的参数和返回值的数据类别(Kind)如下:reflect.Stringreflect.Slicereflect.Intreflect.Interface关于整数及其类型的详细介绍,请阅读Integersinmemory。有关字符串及其类型的详细介绍,请阅读StringsinMemory。在Go语言中,error比较特殊,它既是一个关键字,又是一个接口定义。关于接口类型,后面会专门出一篇文章深入分析,这里先不介绍了。关于切片,[]int在内存切片一文中有详细介绍。很明显,fmt.Printf函数的第二个参数不是[]int,我们通过内存数据来看具体的slice类型。从上图可以看出,编译器将源码中的可变参数类型...interface{}编译成了[]interface{},从而把可变参数变成了形参。这种处理可变参数的方式与Java语言非常相似。通过深入分析和理解fmt.Printf函数的类型,我们很容易理解反射包(reflect)中函数相关的接口;有兴趣的可以看看源码实现。我相信fmt.Printf函数的类型信息是比较出来的,比较简单。typeTypeinterface{...//省略无关接口IsVariadic()boolNumIn()intNumOut()intIn(iint)TypeOut(iint)Type...//省略无关接口}声明的函数类型在Go语言中,任何数据类型可以通过type关键字来定义,非常非常强大。在本文的代码清单中,我们使用type关键字来定义calc类型,这显然是一个函数类型。typecalcfunc(a,bint)(sumint)这个类型和fmt.Printf函数类型有什么区别吗?使用与上述相同的方法,让我们深入挖掘。动态调试从内存数据可以看出,add类型的calc变量指向一个匿名函数,被编译器命名为main.main.func1。calc的类型信息很复杂,一共128字节,整理成图表如下:rtype.size=8rtype.ptrdata=8rtype.hash=0x405feca1rtype.tflag=7=reflect.tflagUncommon|反射.tflagExtraStar|reflect.tflagNamedrtype.align=8rtype.fieldAlign=8rtype.kind=0x33rtype.equal=0=nilrtype.str=0x00002253=>*main.calcrtype.ptrToThis=0x0000ec60funcType.inCount=2funcType.outCount=1功能类型。inTypes=[0x4a41e0,0x4a41e0]funcType.outTypes=[0x4a41e0]uncommonType.pkgPath=0x0000034c=>mainuncommonType.mcount=2uncommonType.xcount=1uncommonType.moff=0x48method[0].name=0x00od0Re000].mtyp=0xffffffff方法[0].ifn=0x00098240方法[0].tfn=0x00098240方法[1].name=0x000001f6=>foo方法[1].mtyp=0xffffffff方法[1].ifn=0x000981e0[1].tfn=0x000981e0类型名rtype.tflag字段包含reflect.tflagNamed标志,表示该类型被命名。calc类型的名称为calc,获取方式在reflect/type.go源文件中定义:func(t*rtype)hasName()bool{returnt.tflag&tflagNamed!=0}func(t*rtype)Name()string{if!t.hasName(){return""}s:=t.String()i:=len(s)-1fori>=0&&s[i]!='.'{i--}返回[i+1:]}func(t*rtype)String()string{s:=t.nameOff(t.str).name()ift.tflag&tflagExtraStar!=0{returns[1:]}returns}类型指针rtype.ptrToThis=0x0000ec60这个值是程序的.rodata部分的偏移量。在这个程序中,.rodata段的起始地址是0x49a000。calc类型的指针类型为*calc,类型信息存放在地址0x49a000+0x0000ec60。本文不再对指针类型做进一步介绍。参数和返回值calc类型有2个参数和1个返回值,都是int类型(信息存放在地址0x4a41e0)。类型方法方法本质上是函数。在ATourofGo(https://tour.golang.org/methods/1)中,函数的定义是:Amethodisafunctionwithaspecialreceiverargument.calc是一个函数类型。一个函数类型可以有自己的方法,确实是一个巧妙的设计。calc类型的rtype.tflag字段包含reflect.tflagUncommon标志,表示其类型信息包含uncommonType数据。uncommonType对象的大小为16个字节。calc类型有3个参数和返回值,3个类型指针占用24个字节。因此,[mcount]方法相对于uncommonType对象的偏移量为16+24=40字节。通过计算得到如下结果:calc类型的Ree方法重命名为main.calc.Ree,内存地址为0x00098240+0x401000=0x499240。是导出函数,所以reflect.name.bytes[0]=1。calc类型的foo方法重命名为main.calc.foo,内存地址为0x000981e0+0x401000=0x4991e0。从内存分析结果可以看出,如果一个数据类型定义了多个方法,其中有的方法有公有的导出方法,名字以大写字母开头,有的有私有的方法,名字以小写字母开头,那么编译器会将公共导出方法信息排序在前面,私有方法信息在后面排序,然后保存其数据类型信息。而这个结论可以通过reflect/type.go源码文件中定义的两个方法来证实:func(t*uncommonType)methods()[]method{ift.mcount==0{returnnil}return(*[1<<16]method)(add(unsafe.Pointer(t),uintptr(t.moff),"t.mcount>0"))[:t.mcount:t.mcount]}func(t*uncommonType)exportedMethods()[]method{ift.xcount==0{returnil}return(*[1<<16]method)(add(unsafe.Pointer(t),uintptr(t.moff),"t.xcount>0"))[:t.xcount:t.xcount]}这个例子也可以看出,无论是Ree方法还是foo方法,它们对应的method.mtyp字段值都是0xffffffff,也就是-1。从runtime/type.go源文件中resolveTypeOff函数的注释,我们可以了解到-1表示没有对应的类型信息。也就是说,calc类型的Ree和foo方法虽然也是函数,但是没有对应的函数类型信息。因此,Go编译器不会为每个函数生成对应的类型信息,而是只在需要的时候生成,或者运行时(runtime)根据需要生成。在匿名函数代码清单中,第三次调用main.Print函数输出匿名函数的类型信息。这个匿名函数没有形成闭包,所以比较简单。将它的内存数据整理成图表如下:这个函数没有参数,没有返回值,也没有方法,所以它的类型信息非常非常简单。我相信不需要进一步的介绍。总结通过一步一步的内存分析,我对Go语言的功能有了更深入的了解,学到了很多知识,解决了很多疑惑。相信在实际开发中,一定能够游刃有余,避免一些坑。