Go语言之深入理解函数

开发 后端
在计算机程序设计中,函数其实是一种抽象概念,是一种编程接口;通过抽象,能够实现将复杂的系统分解成各种包装了复杂算法的不透明接口,方便彼此相互调用,实现分层、扩展性、便利性等等。

[[429304]]

概念

在计算机程序设计中,函数其实是一种抽象概念,是一种编程接口;通过抽象,能够实现将复杂的系统分解成各种包装了复杂算法的不透明接口,方便彼此相互调用,实现分层、扩展性、便利性等等。

具体来讲,函数一般是指一段独立的、可重复利用的程序逻辑片段,用来方便其他函数调用;英文名称是function,有时候也称为method、routine。

编译器最终将函数编译为机器指令,保存在可执行文件中。

在进程的内存空间中,一个函数只不过是一段包含机器指令的连续内存区域;仅仅从结构上来讲,和数组没什么区别。

在Go语言中,函数(function)是一等公民(first-class citizen),不仅仅是代码片段,也是一种数据类型;和其他数据类型一样有自己的类型信息。

函数类型

函数类型的定义有多处,它们是等价的。

在runtime/type.go源文件中定义如下:

  1. type functype struct { 
  2.     typ      _type 
  3.     inCount  uint16 
  4.     outCount uint16 

在reflect/type.go和internal/reflectlite/type.go源文件中定义如下:

  1. // funcType represents a function type. 
  2. // 
  3. // A *rtype for each in and out parameter is stored in an array that 
  4. // directly follows the funcType (and possibly its uncommonType). So 
  5. // a function type with one method, one input, and one output is
  6. // 
  7. //  struct { 
  8. //    funcType 
  9. //    uncommonType 
  10. //    [2]*rtype    // [0] is in, [1] is out 
  11. //  } 
  12. type funcType struct { 
  13.   rtype 
  14.   inCount  uint16 
  15.   outCount uint16 // top bit is set if last input parameter is ... 

从funcType结构体的注释中可以看到,函数类型的信息其实非常复杂。

其实完整的函数类型定义如下伪代码所示:

  1. type funcType struct { 
  2.     rtype           // 基础类型信息  
  3.     inCount  uint16 // 参数数量 
  4.     outCount uint16 // 返回值数量 
  5.     uncommon uncommonType     // 方法信息 
  6.     inTypes  [inCount]*rtype  // 参数类型列表 
  7.     outTypes [outCount]*rtype // 返回值类型列表 
  8.     methods  [uncommon.mcount]method // 方法列表 

uncommonType和method定义在reflect/type.go源文件中,用于存储和解析类型的方法信息。

  1. type uncommonType struct { 
  2.     pkgPath nameOff  // 包路径名称偏移量 
  3.     mcount  uint16   // 方法的数量 
  4.     xcount  uint16   // 公共导出方法的数量 
  5.     moff    uint32   // methods相对本对象起始地址的偏移量 
  6.     _       uint32   // unused 
  1. // 非接口类型的方法 
  2. type method struct { 
  3.     name nameOff // 方法名称偏移量 
  4.     mtyp typeOff // 方法类型偏移量 
  5.     ifn  textOff // 通过接口调用时的地址偏移量;接口类型本文不介绍 
  6.     tfn  textOff // 直接类型调用时的地址偏移量 
  1. type nameOff int32 // offset to a name 
  2. type typeOff int32 // offset to an *rtype 
  3. type textOff int32 // offset from top of text section 
  • nameOff 是相对 .rodata 节起始地址的偏移量。
  • typeOff 是相对 .rodata 节起始地址的偏移量。
  • textOff 是相对 .text 节起始地址的偏移量。
  • 关于 reflect.name的介绍,请阅读 内存中的整数 。

函数类型结构分布示意图

完整的函数类型信息结构分布如下图所示:

每一种函数都有自己的类型信息,只不过有的函数简单,有的函数复杂,并不是每一种函数类型包含上图中的所有字段。

简单的函数类型信息结构分布可能如下图所示:

或者

备注:以上示意图中的浅灰色块表示内存对齐的填充,不存储任何数据。

当然,函数也可能有参数无返回值,函数还可能无参数有返回值,它们的类型信息结构还会有一点点不同,想象一下,不过只是一种简化的结构罢了。

通过本文的内存分析,我们将会了解函数类型的每一个细节。

环境

  1. OS : Ubuntu 20.04.2 LTS; x86_64 
  2.  
  3. Go : go version go1.16.2 linux/amd64 

声明

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

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

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

本文仅讨论普通函数和声明的函数类型,不讨论接口、实现、闭包等知识点。

代码清单

  1. package main 
  2.  
  3. import ( 
  4.   "errors" 
  5.   "fmt" 
  6.   "reflect" 
  7.  
  8. // 声明函数类型 
  9. type calc func(a, b int) (sum int
  10.  
  11. // 私有的方法 -> package scope 
  12. //go:noinline 
  13. func (f calc) foo(a, b intint { 
  14.   return f(a, b) + 1 
  15.  
  16. // Ree 公共导出的方法 -> public scope 
  17. //go:noinline 
  18. func (f calc) Ree(a, b intint { 
  19.   return f(a, b) - 1 
  20.  
  21. func main() { 
  22.   // 普通函数 
  23.   Print(fmt.Printf) 
  24.   // 函数类型实例 
  25.   var add calc = func(a, b int) (sum int) { 
  26.     return a + b 
  27.   } 
  28.   fmt.Println(add.foo(1, 2)) 
  29.   fmt.Println(add.Ree(1, 2)) 
  30.   Print(add
  31.   // 匿名函数 
  32.   Print(func() { 
  33.     fmt.Println("hello anonymous function"
  34.   }) 
  35.   // 方法;闭包 
  36.   f := errors.New("hello error").Error 
  37.   Print(f) 
  38.  
  39.  
  40. //go:noinline 
  41. func Print(i interface{}) { 
  42.   v := reflect.ValueOf(i) 
  43.   fmt.Println("类型", v.Type().String()) 
  44.   fmt.Println("地址", v) 
  45.   fmt.Println() 

运行效果

以上代码清单,主要打印输出了四个函数的类型和内存地址。

编译并运行,输出如下:

在本文的内存分析过程中,存在许多通过偏移量计算内存地址的操作。

主要涉及到 .text 和 .rodata 两个 section,在本程序中它们的信息如下:

普通函数

以fmt.Printf这个常用的函数为例,研究普通函数的类型信息。

从上面的运行输出结果可以看到,fmt.Printf函数类型的字符串表示形式为:

  1. 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)的计算方法如下:

  1. const kindMask = (1 << 5) - 1 
  2.  
  3. func (t *rtype) Kind() Kind { return Kind(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源文件中,判断可变参数的方法如下:

  1. func (t *rtype) IsVariadic() bool { 
  2.     if t.Kind() != Func { 
  3.         panic("reflect: IsVariadic of non-func type " + t.String()) 
  4.     } 
  5.     tt := (*funcType)(unsafe.Pointer(t)) 
  6.     return tt.outCount&(1<<15) != 0 

返回值数量的计算方式是:

  1. 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函数的类型信息,是比较简单的。

  1. type Type interface { 
  2.     ...... // 省略无关接口 
  3.     IsVariadic() bool 
  4.     NumIn() int 
  5.     NumOut() int 
  6.     In(i int) Type 
  7.     Out(i int) Type 
  8.     ...... // 省略无关接口 

声明的函数类型

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

  1. func (t *rtype) hasName() bool { 
  2.     return t.tflag&tflagNamed != 0 
  3.  
  4. func (t *rtype) Name() string { 
  5.     if !t.hasName() { 
  6.         return "" 
  7.     } 
  8.     s := t.String() 
  9.     i := len(s) - 1 
  10.     for i >= 0 && s[i] != '.' { 
  11.         i-- 
  12.     } 
  13.     return s[i+1:] 
  14.  
  15. func (t *rtype) String() string { 
  16.     s := t.nameOff(t.str).name() 
  17.     if t.tflag&tflagExtraStar != 0 { 
  18.         return s[1:] 
  19.     } 
  20.     return s 

类型指针

  1. 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) 中,对函数的定义为:

  1. A method is a function with a special receiver argument. 

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源码文件中定义的两个方法得到印证:

  1. func (t *uncommonType) methods() []method { 
  2.   if t.mcount == 0 { 
  3.     return nil 
  4.   } 
  5.   return (*[1 << 16]method)(add(unsafe.Pointer(t), uintptr(t.moff), "t.mcount > 0"))[:t.mcount:t.mcount] 
  6.  
  7. func (t *uncommonType) exportedMethods() []method { 
  8.   if t.xcount == 0 { 
  9.     return nil 
  10.   } 
  11.   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语言的函数进行了深入的了解,学习了很多知识,解开了许多疑惑,相信在实际开发中必定能游刃有余,避免一些小坑。

 

责任编辑:武晓燕 来源: Golang In Memory
相关推荐

2022-11-07 18:12:54

Go语言函数

2023-10-27 11:27:14

Go函数

2024-04-07 00:04:00

Go语言Map

2021-04-20 23:25:16

执行函数变量

2019-11-05 10:03:08

callback回调函数javascript

2019-08-19 12:50:00

Go垃圾回收前端

2009-11-18 12:38:04

PHP字符串函数

2010-06-28 10:12:01

PHP匿名函数

2020-12-16 09:47:01

JavaScript箭头函数开发

2021-12-28 17:39:05

Go精度Json

2016-12-08 15:36:59

HashMap数据结构hash函数

2020-07-21 08:26:08

SpringSecurity过滤器

2010-06-01 15:25:27

JavaCLASSPATH

2017-01-10 08:48:21

2017-08-15 13:05:58

Serverless架构开发运维

2020-09-23 10:00:26

Redis数据库命令

2019-06-25 10:32:19

UDP编程通信

2024-02-21 21:14:20

编程语言开发Golang

2023-11-01 08:41:24

Go标准库http

2020-12-31 09:06:44

Go语言Reflect
点赞
收藏

51CTO技术栈公众号