概念
在计算机程序设计中,函数其实是一种抽象概念,是一种编程接口;通过抽象,能够实现将复杂的系统分解成各种包装了复杂算法的不透明接口,方便彼此相互调用,实现分层、扩展性、便利性等等。
具体来讲,函数一般是指一段独立的、可重复利用的程序逻辑片段,用来方便其他函数调用;英文名称是function,有时候也称为method、routine。
编译器最终将函数编译为机器指令,保存在可执行文件中。
在进程的内存空间中,一个函数只不过是一段包含机器指令的连续内存区域;仅仅从结构上来讲,和数组没什么区别。
在Go语言中,函数(function)是一等公民(first-class citizen),不仅仅是代码片段,也是一种数据类型;和其他数据类型一样有自己的类型信息。
函数类型
函数类型的定义有多处,它们是等价的。
在runtime/type.go源文件中定义如下:
- typefunctypestruct{
- typ_type
- inCountuint16
- outCountuint16
- }
在reflect/type.go和internal/reflectlite/type.go源文件中定义如下:
- //funcTyperepresentsafunctiontype.
- //
- //A*rtypeforeachinandoutparameterisstoredinanarraythat
- //directlyfollowsthefuncType(andpossiblyitsuncommonType).So
- //afunctiontypewithonemethod,oneinput,andoneoutputis:
- //
- //struct{
- //funcType
- //uncommonType
- //[2]*rtype//[0]isin,[1]isout
- //}
- typefuncTypestruct{
- rtype
- inCountuint16
- outCountuint16//topbitissetiflastinputparameteris...
- }
从funcType结构体的注释中可以看到,函数类型的信息其实非常复杂。
其实完整的函数类型定义如下伪代码所示:
- typefuncTypestruct{
- rtype//基础类型信息
- inCountuint16//参数数量
- outCountuint16//返回值数量
- uncommonuncommonType//方法信息
- inTypes[inCount]*rtype//参数类型列表
- outTypes[outCount]*rtype//返回值类型列表
- methods[uncommon.mcount]method//方法列表
- }
uncommonType和method定义在reflect/type.go源文件中,用于存储和解析类型的方法信息。
- typeuncommonTypestruct{
- pkgPathnameOff//包路径名称偏移量
- mcountuint16//方法的数量
- xcountuint16//公共导出方法的数量
- moffuint32//methods相对本对象起始地址的偏移量
- _uint32//unused
- }
- //非接口类型的方法
- typemethodstruct{
- namenameOff//方法名称偏移量
- mtyptypeOff//方法类型偏移量
- ifntextOff//通过接口调用时的地址偏移量;接口类型本文不介绍
- tfntextOff//直接类型调用时的地址偏移量
- }
- typenameOffint32//offsettoaname
- typetypeOffint32//offsettoan*rtype
- typetextOffint32//offsetfromtopoftextsection
- nameOff 是相对 .rodata 节起始地址的偏移量。
- typeOff 是相对 .rodata 节起始地址的偏移量。
- textOff 是相对 .text 节起始地址的偏移量。
- 关于 reflect.name的介绍,请阅读 内存中的整数 。
函数类型结构分布示意图
完整的函数类型信息结构分布如下图所示:
每一种函数都有自己的类型信息,只不过有的函数简单,有的函数复杂,并不是每一种函数类型包含上图中的所有字段。
简单的函数类型信息结构分布可能如下图所示:
或者
备注:以上示意图中的浅灰色块表示内存对齐的填充,不存储任何数据。
当然,函数也可能有参数无返回值,函数还可能无参数有返回值,它们的类型信息结构还会有一点点不同,想象一下,不过只是一种简化的结构罢了。
通过本文的内存分析,我们将会了解函数类型的每一个细节。
环境
- OS:Ubuntu20.04.2LTS;x86_64
- Go:goversiongo1.16.2linux/amd64
声明
操作系统、处理器架构、Go版本不同,均有可能造成相同的源码编译后运行时的寄存器值、内存地址、数据结构等存在差异。
本文仅包含 64 位系统架构下的 64 位可执行程序的研究分析。
本文仅保证学习过程中的分析数据在当前环境下的准确有效性。
本文仅讨论普通函数和声明的函数类型,不讨论接口、实现、闭包等知识点。
代码清单
- packagemain
- import(
- "errors"
- "fmt"
- "reflect"
- )
- //声明函数类型
- typecalcfunc(a,bint)(sumint)
- //私有的方法->packagescope
- //go:noinline
- func(fcalc)foo(a,bint)int{
- returnf(a,b)+1
- }
- //Ree公共导出的方法->publicscope
- //go:noinline
- func(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")
- })
- //方法;闭包
- f:=errors.New("helloerror").Error
- Print(f)
- }
- //go:noinline
- funcPrint(iinterface{}){
- v:=reflect.ValueOf(i)
- fmt.Println("类型",v.Type().String())
- fmt.Println("地址",v)
- fmt.Println()
- }
运行效果
以上代码清单,主要打印输出了四个函数的类型和内存地址。
编译并运行,输出如下:
在本文的内存分析过程中,存在许多通过偏移量计算内存地址的操作。
主要涉及到 .text 和 .rodata 两个 section,在本程序中它们的信息如下:
普通函数
以fmt.Printf这个常用的函数为例,研究普通函数的类型信息。
从上面的运行输出结果可以看到,fmt.Printf函数类型的字符串表示形式为:
- func(string,...interface{})(int,error)
动态调试
在Print函数入口处设置断点,查看fmt.Printf函数的类型信息。
将fmt.Printf函数的类型信息绘制成图表如下:
- rtype.size = 8
- rtype.ptrdata = 8
- rtype.hash = 0xd9fb8597
- rtype.tflag = 2 = reflect.tflagExtraStar
- rtype.align = 8
- rtype.fieldAlign = 8
- rtype.kind = 0x33
- rtype.equal = 0 = nil
- rtype.str = 0x00005c90 => *func(string, ...interface {}) (int, error)
- rtype.ptrToThis = 0
- funcType.inCount = 2
- funcType.outCount = 0x8002
- funcType.inTypes = [ 0x4a4860, 0x4a2f80 ]
- funcType.outTypes = [ 0x4a41e0, 0x4a9860 ]
指针常量
函数对象的大小是8字节(rtype.size),而且包含8字节的指针数据(rtype.ptrdata),所以我们可以将函数对象视为指针。
也就是说fmt.Printf其实是一个指针,只不过这个指针是一个不可变的常量。这与C/C++是一致的,函数名称就是一个指针常量。
类型名称
rtype.tflag = 2 = reflect.tflagExtraStar
fmt.Printf函数有自己的数据类型,但是该类型并没有名称。
数据类别
数据类别(Kind)的计算方法如下:
- constkindMask=(1<<5)-1
- func(t*rtype)Kind()Kind{returnKind(t.kind&kindMask)}
0x33 & 31 = 19 = reflect.Func
可变参数
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{}
- int
- error
在内存的函数类型信息中,保存的是参数和返回值的类型指针;通过这些指针查看它们的类型信息如下:
通过内存数据可以看到,fmt.Printf函数的参数和返回值的数据类别(Kind)如下:
- reflect.String
- reflect.Slice
- reflect.Int
- reflect.Interface
关于整数及其类型的详细介绍,请阅读 内存中的整数 。
关于字符串及其类型的详细介绍,请阅读 内存中的字符串 。
在Go语言中,error比较特殊,它既是一个关键字,又是一个接口定义。关于接口类型,之后将发布专题文章进行深入解析,暂不介绍。
关于slice,内存中的slice 一文曾对 []int 进行了详细介绍 。
很明显,fmt.Printf函数的第二个参数不是[]int,通过内存数据来看一看具体是什么类型的slice。
通过上图可以看到,编译器将源码中的可变参数类型...interface{}编译为[]interface {},从而把可变参数变成一个参数。
这种处理可变参数的方式,和Java语言非常相似。
通过对fmt.Printf函数的类型深入分析和了解,我们就很容易理解反射包(reflect)中函数相关的接口了;有兴趣的话可以去看一看源码实现,相信对比fmt.Printf函数的类型信息,是比较简单的。
- typeTypeinterface{
- ......//省略无关接口
- IsVariadic()bool
- NumIn()int
- NumOut()int
- In(iint)Type
- Out(iint)Type
- ......//省略无关接口
- }
声明的函数类型
在Go语言中,通过type关键字可以定义任何数据类型,非常非常地强悍。
在本文的代码清单中,我们就使用type关键字定义了calc类型,这明显是一个函数类型。
type calc func (a, b int) (sum int)
这种类型与fmt.Printf函数类型有什么区别吗?使用上述相同的方法,我们来深入研究下。
动态调试
从内存数据可以看出,calc类型的add变量指向一个匿名函数,该匿名函数被编译器命名为main.main.func1。
calc的类型信息非常复杂,共128个字节,整理成图表如下:
- rtype.size = 8
- rtype.ptrdata = 8
- rtype.hash = 0x405feca1
- rtype.tflag = 7 = reflect.tflagUncommon | reflect.tflagExtraStar | reflect.tflagNamed
- rtype.align = 8
- rtype.fieldAlign = 8
- rtype.kind = 0x33
- rtype.equal = 0 = nil
- rtype.str = 0x00002253 => *main.calc
- rtype.ptrToThis = 0x0000ec60
- funcType.inCount = 2
- funcType.outCount = 1
- funcType.inTypes = [ 0x4a41e0, 0x4a41e0 ]
- funcType.outTypes = [ 0x4a41e0 ]
- uncommonType.pkgPath = 0x0000034c => main
- uncommonType.mcount = 2
- uncommonType.xcount = 1
- uncommonType.moff = 0x48
- method[0].name = 0x000001a8 => Ree
- method[0].mtyp = 0xffffffff
- method[0].ifn = 0x00098240
- method[0].tfn = 0x00098240
- method[1].name = 0x000001f6 => foo
- method[1].mtyp = 0xffffffff
- method[1].ifn = 0x000981e0
- method[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)-1
- fori>=0&&s[i]!='.'{
- i--
- }
- returns[i+1:]
- }
- func(t*rtype)String()string{
- s:=t.nameOff(t.str).name()
- ift.tflag&tflagExtraStar!=0{
- returns[1:]
- }
- returns
- }
类型指针
- rtype.ptrToThis=0x0000ec60
该值是相对程序 .rodata section 的偏移量。在本程序中,.rodata section 的起始地址是 0x49a000。
calc类型的指针类型为*calc,类型信息保存在地址 0x49a000+0x0000ec60处。关于指针类型本文不再进一步介绍。
参数和返回值
calc类型有2个参数和1个返回值,而且都是int类型(信息保存在0x4a41e0地址处)。
类型方法
方法本质上就是函数。
在 A Tour of Go (https://tour.golang.org/methods/1) 中,对函数的定义为:
- Amethodisafunctionwithaspecialreceiverargument.
calc是函数类型,函数类型居然能拥有自己的方法,确实是巧妙的设计。
calc类型的rtype.tflag字段包含reflect.tflagUncommon标记,表示其类型信息中包含uncommonType数据。
uncommonType对象的大小是 16 字节,calc类型共有3个参数和返回值,3个类型指针占 24 个字节,所以 [mcount]method 相对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{
- returnnil
- }
- 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语言的函数进行了深入的了解,学习了很多知识,解开了许多疑惑,相信在实际开发中必定能游刃有余,避免一些小坑。
原文链接:https://mp.weixin.qq.com/s/j0YYBnGsbEcD-WrKRDRSAw