Go 函数(其一),函数类型与函数值

109 阅读13分钟

1. 函数类型

函数类型表示具有相同参数和返回类型的所有函数的集合[1]。一个函数类型的表示形式由一个func关键字和一个函数签名组成[2] 。其中,函数签名是函数的参数列表和返回值列表的统称,它包括参数以及返回值的类型、数量和顺序[3][4]。对于其他信息,比如参数和返回值的名称,则不包含在函数签名内。因为参数和返回值的名称通常只在函数体内部使用,对于函数的调用者来说,并不影响函数的使用方式。同理函数名也不在其内,函数名称用于在程序中唯一标识和调用函数,也不会影响一类函数的使用方式。因此 Go 语言的函数类型表现形式大致如下:

func(int, string, string) (int,int,bool)

该函数类型有两个特征:三个参数,且类型顺序为intstringstring;三个返回值,且类型顺序为intintbool。一个函数只要满足了这两个特征,就能被认为是与该函数类型相匹配。但是需要注意如果函数必须符合函数签名的规定,只要任何一项规定不满足,比如参数或者返回值的顺序不同,那么它们就不被视为同一类型。如下:

// 定义一个函数类型 MyFuncType
type MyFuncType func(int, string, string) (int,int,bool)
​
func FuncImpl(string, int, string) (int,int,bool) { return 0,0,false }
​
var funcValue MyFuncType = FuncImpl
// 这里编译器会提示错误:
// cannot use FuncImpl (value of type func(int, string, int) (int,int,bool)) 
// as MyFuncType value in variable

在这里例子中,虽然函数FuncImpl也有三个参数和返回值,且类型都与函数类型MyFuncType一致,但唯独参数的顺序不一样。这就让FuncImplMyFuncType不匹配,FuncImpl实际匹配的函数类型为func(string, int, string) (int,int,bool)

2. 函数值

Note : 以下讨论全部基于 Go Version go1.20 windows/amd64

package main
​
import (
    "fmt"
    "unsafe"
)
​
func Add(a, b int) int { return a + b }
​
func main() {
    fmt.Printf("Add 函数地址:%p\n", Add)
​
    fn := Add
    fmt.Printf("fn 变量地址:0x%x\n", &fn)
    fmt.Printf("fn 变量地址:0x%x\n", uintptr(unsafe.Pointer(&fn)))
    fmt.Printf("fn 变量的值(函数值):0x%x\n", *(*uintptr)(unsafe.Pointer(&fn)))
​
    ptr := *(*uintptr)(unsafe.Pointer(&fn))
    fmt.Printf("fn 变量两次解引后的地址:0x%x\n", *(*uintptr)(unsafe.Pointer(ptr)))
}
​
// output:
// Add 函数地址:            0x95ae80
// fn 变量地址:             0xc00000a030        
// fn 变量地址:             0xc00000a030        
// fn 变量的值(函数值):        0x97fc28    
// fn 变量两次解引后的地址:   0x95ae80

在这段代码中,我们首先通过fmt.Printf打印出函数Add的地址,为 0x95ae80。接着,将该函数赋值给变量fn,然后通过&获取到fn变量的地址,为 0xc00000a030。之后我们又通过*(*uintptr)(unsafe.Pointer(&fn))获取变量fn存储的值,为 0x97fc28;紧接着又对该值进行解引,最终得到地址 0x95ae80,即Add函数地址[5][6]

从上述结果中可以发现,fn的值是一个指针,但该指针并不是Add函数的地址 0x95ae80,而是另一个地址 0x97fc28。当我们对这个地址 0x97fc28 进行一次解引后,得到地址才是函数地址 0x95ae80。由此可知,fn的值是一个函数指针(Note:这里为了解释现象暂称其为函数指针,实际上并不是),而这个指针所指向的地址才是真正的函数地址。在 Go 语言中,fn存储的这种值就是函数值。

函数值是一种特殊的值,它使得函数能够被作为一种值进行操作,可以将其赋值给变量、作为参数传递给其他函数或从函数中返回[7]。其本质上是一个指针,但它并不像我们刚才所说的那样是一个直接指向函数地址的指针(Go 1.0 版本除外),而是一个指向 runtime.funcval 结构体的指针[8][9]

type funcval struct {
    fn uintptr
    // variable-size, fn-specific data here(这个位置用于存储与具体函数相关的可变大小数据)
}

从上述定义来看,函数值的底层结构只会存一个地址(函数地址),但在实际运行时却可能会有其他变量被捕获,并存储在这个结构体中。因此,这个结构体实际上代表的是一个可变大小的数据块。也就是说,函数值实际上是指向可变大小数据内存块的指针,而其中第一个 Word 用于存储函数地址,其余字则存储被调用代码使用的附加数据(如闭包环境)[8]

img

2.1 未捕获变量的函数值

在Go语言中,函数值可以像普通变量一样传递和使用,它包含了函数的地址和相关的闭包环境(如果有的话)。在没有捕获变量(没有闭包环境)的情况下,函数值的底层结构funcval只存储函数地址[8]。而本节将通过具体代码示例,大致讨论没有捕获变量的函数值是怎样实现。(Note:下图为没有捕获变量的函数值的内存布局)

img

以下为示例程序,其中定义了一个函数类型FnType,用于表示接受两个int类型参数并返回int的函数。接着,定义了一个简单的Add函数,计算两个整数的和。然后,又定义了一个calc函数,接受两个整数参数和一个FnType类型的函数fn,并调用fn

package main
​
type FnType func(int, int) intfunc Add(a, b int) int { return a + b }
​
func calc(a, b int, fn FnType) int { return fn(a, b) }
​
func main() {
    _ = calc(1, 2, Add)
}

现在我们在终端使用go tool compile -S -N -l main.go命令,生成 Plan9 汇编代码(该命令中 -N 表示不进行优化,-l 表示不进行内联)[10]。以下是生成的 main 函数的汇编代码:

main.main STEXT size=59 args=0x0 locals=0x20 funcid=0x0 align=0x0
    TEXT    main.main(SB), ABIInternal, $32-0
    CMPQ    SP, 16(R14)
    PCDATA  $0, $-2
    JLS     52
    PCDATA  $0, $-1
    SUBQ    $32, SP
    MOVQ    BP, 24(SP)
    LEAQ    24(SP), BP
    FUNCDATA        $0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
    FUNCDATA        $1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
    MOVL    $1, AX
    MOVL    $2, BX
    LEAQ    main.Add·f(SB), CX # 将main.Add·f符号的地址计算出来,并存储到CX寄存器中
    PCDATA  $1, $0
    CALL    main.calc(SB)
    MOVQ    24(SP), BP
    ADDQ    $32, SP
    RET
    ...

在上述代码中,LEAQ 指令计算了main.Add·f符号的地址,并将其存储到 CX 寄存器中。然后,调用了函数main.calc。其中main.Add·f是一个符号,表示这是一个指向函数main.Add的指针[8][11]。 该符号和main.Add的大致描述如下:

# main.Add·f
main.Add·f SRODATA dupok size=8
    0x0000 00 00 00 00 00 00 00 00
    rel 0+8 t=1 main.Add+0   # 重定位信息
​
# main.Add
main.Add STEXT nosplit size=49 args=0x10 locals=0x10 funcid=0x0 align=0x0
    TEXT    main.Add(SB), NOSPLIT|ABIInternal, $16-16        
    SUBQ    $16, SP
    MOVQ    BP, 8(SP)
    LEAQ    8(SP), BP
    ...
    MOVQ    AX, main.a+24(SP)
    MOVQ    BX, main.b+32(SP)
    MOVQ    $0, main.~r0(SP)
    ADDQ    BX, AX              # a = a + b
    MOVQ    AX, main.~r0(SP)
    MOVQ    8(SP), BP
    ADDQ    $16, SP
    RET

main.Add·f符号(Linux 中为"".Add.f)是一个只读数据(SRODATA),其大小为8字节。其中dupok表示如果有多个相同的符号定义,重复的定义可以被合并。而rel 0+8 t=1 main.Add+0 是一段重定位信息,它告诉链接器,在当前段的偏移 0 处的 8 个字节是一个待定的地址,需要在链接时将其替换为 main.Add 函数的绝对地址。 在上述main.Add·f符号的定义中我们可以看到一段大小为 8,但内容却是0x0000 00 00 00 00 00 00 00 00的数据。实际上该段数据是用来存放 main.Add 函数的绝对地址的,但编译器无法确定这个地址,所以需要通过链接器重定位来完成[12]

在 Go 语言里,只有那些被用作函数值的函数才可能会生成像.f这样的地址信息。这是因为函数值的使用涉及到了函数地址的传递和存储,而这需要编译器生成额外的代码来支持。当一个函数被用作函数值时,编译器会为该函数生成必要的元数据[8]

我们现在提到的main.Add.f就是编译期间确定了函数Add会被用作函数值而由编译器生成的地址信息(元数据),对它取地址后就相当于得到了函数值,即在链接器完成Add地址的重定位后,只需通过main.Add.f符号就可以获得main.Add的绝对地址,我们对main.Add.f取地址就相当于获得了一个指向main.Add的函数指针,该指针可视为函数值。

2.2 捕获了变量的函数值(闭包)

闭包,这一源自函数式编程的概念,在计算机科学中扮演着至关重要的角色,它允许函数携带其定义时的环境状态。从维基百科的定义来看,闭包被描述为一个函数与其相关引用环境的组合,这种组合在实现层面往往表现为一个结构体,其中不仅包含指向函数体的指针,还携带着一个环境指针,用以维持函数定义时的上下文[13]。而这一定义与 Go 的函数值底层结构runtime.funcval几乎一致。

在 Go 的运行时系统中,函数值被具体化为runtime.funcval结构体,这一结构体不仅封装了函数的入口地址,还隐含了闭包环境,后者负责保存函数引用的变量——即那些在函数定义时可以访问,但既非函数参数也非局部变量的对象。事实上,Go 的闭包和函数值的底层结构其实是共用的runtime.funcval[9]

而为了进一步理解闭包和函数值在 Go 中的关系及函数值的运作机制,我们可以通过一个具体的代码示例来观察它们:

package main

type FnType func(int, int) int

func closure() FnType {
	c := 1
	return func(a, b int) int {
		return a + b + c
	}
}

func main() {
	fn := closure()
	fn(1, 2)
}

这段示例中closure函数定义了一个局部变量c,并返回了一个匿名函数。这个匿名函数会捕获c,即使在closure函数执行完毕后,它依旧能够访问并使用c。我们通过调用closure并将返回的匿名函数赋值给变量fn,这时fn不仅包含了该匿名函数的代码逻辑,还隐含了对c的引用,形成了一种闭包状态。当fn被调用时,它依然能够利用变量c进行计算,从而输出正确的结果。

接下来我们需要通过具体的汇编代码来进一步分析闭包的底层是如何实现的,以及它与函数值有怎样的联系。这次主要分析closure函数 ,如下:

main.closure STEXT size=108 args=0x0 locals=0x30 funcid=0x0 align=0x0
	TEXT    main.closure(SB), ABIInternal, $48-0
	...
	SUBQ    $48, SP
	MOVQ    BP, 40(SP)
	...
	MOVQ    $0, main.~r0+24(SP)		# 初始化返回地址
	MOVQ    $1, main.c+16(SP)		# c := 1
	LEAQ    type:noalg.struct { F uintptr; main.c int }(SB), AX
	PCDATA  $1, $0
	CALL    runtime.newobject(SB)
	MOVQ    AX, main..autotmp_2+32(SP)
	LEAQ    main.closure.func1(SB), CX
	MOVQ    CX, (AX)
	MOVQ    main..autotmp_2+32(SP), CX
	TESTB   AL, (CX)
	MOVQ    main.c+16(SP), DX
	MOVQ    DX, 8(CX)
	MOVQ    main..autotmp_2+32(SP), AX
	MOVQ    AX, main.~r0+24(SP)		# 将函数值结构体地址赋值给返回值寄存器
	MOVQ    40(SP), BP
	ADDQ    $48, SP
	RET
	...

上述汇编代码主要展示closure函数创建并返回一个捕获外部变量的闭包的过程。以下是我们需要开始关注的内容:

LEAQ    type:noalg.struct { F uintptr; main.c int }(SB), AX
CALL    runtime.newobject(SB)
MOVQ    AX, main..autotmp_2+32(SP)
  • 指令中的type:noalg.struct{F uintptr; main.c int}是描述了匿名结构体类型的基本信息的符号(详情如下面的代码块所示)。
  • runtime.newobject 则是一个运行时函数,用于在堆上分配内存,其函数定义为func newobject(*_type) unsafe.Pointer

这段指令先把符号type:noalg.struct{F uintptr; main.c int}的地址作为runtime.newobject函数的参数加载到 AX 寄存器中,然后通过 CALL 调用runtime.newobject,最后将返回的地址存入 main..autotmp_2+32(SP)。注意在 CALL 调用返回后,AX 寄存器中的数据不再是类型符号的地址,而是被创建在堆中的匿名结构体的地址。

# 该匿名结构体实际上是:
# type funcval struct {
#     fn 	uintptr
#     c 	int
# }
type:noalg.struct { F uintptr; main.c int } SRODATA dupok size=128
        0x0000 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
        0x0010 75 ef 29 df 02 08 08 19 00 00 00 00 00 00 00 00
        0x0020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
        0x0030 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
        0x0040 02 00 00 00 00 00 00 00 02 00 00 00 00 00 00 00
        0x0050 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
        0x0060 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
        0x0070 00 00 00 00 00 00 00 00 08 00 00 00 00 00 00 00
        rel 32+8 t=1 runtime.gcbits.+0
        rel 40+4 t=5 type:.namedata.*struct { F uintptr; c int }-+0
        rel 44+4 t=-32763 type:*struct { F uintptr; main.c int }+0
        rel 48+8 t=1 type:.importpath.main.+0
        rel 56+8 t=1 type:noalg.struct { F uintptr; main.c int }+80
        rel 80+8 t=1 type:.namedata..F-+0
        rel 88+8 t=1 type:uintptr+0
        rel 104+8 t=1 type:.namedata.c-+0
        rel 112+8 t=1 type:int+0

这个类型描述也是在编译期间确定并由编译器生成的,而这里我们不会展开讨论,我们需要继续关注closure函数接下来的汇编内容。下面的指令大致是将匿名函数main.closure.func1的地址和捕获的变量c存储在创建的匿名结构体相应的字段中:

LEAQ    main.closure.func1(SB), CX
MOVQ    CX, (AX)
MOVQ    main..autotmp_2+32(SP), CX
TESTB   AL, (CX)
MOVQ    main.c+16(SP), DX
MOVQ    DX, 8(CX)
  • 首先加载闭包函数main.closure.func1的地址到 CX 寄存器;
  • 使用 MOVQ 将这个地址存储到闭包的匿名结构体的第一个字段fn上(此时 AX 寄存器中是闭包匿名结构体的地址,该结构体被分配在堆上);
  • 重新将闭包匿名结构体的地址加载给 CX,为后续赋值main.c做准备(此时main..autotmp_2+32(SP) 中存储的是闭包匿名结构体的地址);
  • 加载main.c到 DX,并将其存储在闭包匿名结构体的第二个字段c上(c的地址相对于闭包匿名结构体起始地址偏移量为 8)。

image.png

这样,匿名函数main.closure.func1的入口地址和捕获的变量c都被写入到结构体中,确保了闭包函数在脱离其创建函数closure后也能顺利执行。最终,这个结构体的地址被赋值给返回值寄存器,返回给调用者。

到这儿就是对捕获了变量的函数值(闭包函数的函数值)大致的讨论了,我们因此了解到 Go 语言在创建该类函数值时会在汇编层面直接创建一个包含函数地址和附加数据的匿名结构体,而闭包函数的函数值就是一个指向这个匿名结构体的指针。

2.3 函数值的调用

关于函数值调用,我们就使用上一小节闭包的例子继续进行讨论。这里我们需要回到闭包例子的 main 函数中,关注 main 函数的汇编:

main.main STEXT size=66 args=0x0 locals=0x20 funcid=0x0 align=0x0
	TEXT    main.main(SB), ABIInternal, $32-0
	CMPQ    SP, 16(R14)
	PCDATA  $0, $-2
	JLS     58
	PCDATA  $0, $-1
	SUBQ    $32, SP
	MOVQ    BP, 24(SP)
	LEAQ    24(SP), BP
	...
	CALL    main.closure(SB)
	MOVQ    AX, main.fn+16(SP)
	MOVQ    (AX), CX			# 解引函数值
	MOVL    $2, BX
	MOVQ    AX, DX				# 函数值被存入 DX 寄存器中(需要留意)
	MOVL    $1, AX
	CALL    CX
	MOVQ    24(SP), BP
	ADDQ    $32, SP
	RET
    ...

函数值调用的关键步骤发生在MOVQ (AX), CX这一行,它解引用了 AX 寄存器中存储的匿名结构体地址(函数值),将闭包函数的实际地址加载到了 CX 寄存器中。这一步至关重要,因为它为后续的闭包函数调用准备好了入口地址。之后就是通过 CALL CX 指令,使用先前解引用得到的函数地址,调用闭包函数。

到这里其实应该有一个疑问。对于闭包而言,在调用闭包函数后,函数是如何去找到闭包捕获的变量的呢?

其实上一小节我们在分析closure函数的汇编时就已经提到过,被捕获的变量是直接存储到了相对匿名结构体地址偏移为 8 的地址上(就是字段 mian.c[9]。既然如此,那么调用闭包函数的时候直接在相对匿名结构体地址偏移为 8 的地址开始寻找捕获的变量不就可以了嘛。以下是main.closure.func1的汇编代码:

main.closure.func1 STEXT nosplit size=63 args=0x10 locals=0x18 funcid=0x0 align=0x0
	TEXT    main.closure.func1(SB), NOSPLIT|NEEDCTXT|ABIInternal, $24-16
	SUBQ    $24, SP
	MOVQ    BP, 16(SP)
	LEAQ    16(SP), BP
	...
	MOVQ    AX, main.a+32(SP)
	MOVQ    BX, main.b+40(SP)
	MOVQ    8(DX), CX			# <--- 看这儿
	MOVQ    CX, main.c+8(SP)
	MOVQ    $0, main.~r0(SP)
	LEAQ    (AX)(BX*1), DX
	LEAQ    (DX)(CX*1), AX
	MOVQ    AX, main.~r0(SP)
	MOVQ    16(SP), BP
	ADDQ    $24, SP
	RET

我们可以看到这句指令MOVQ 8(DX), CX,而在这之后就是将 CX 中的内容存进了 main.c+8(SP),这变相地说明了相对 DX 中存储的地址偏移为 8 的位置存储的是变量main.c ,而 DX 中存储的就应该是之前在closure函数中返回的匿名结构体地址。实际上 DX 寄存器中存储的确实是匿名结构体地址(函数值)[9]

Go 在处理闭包函数的函数值时为什么没有像顶级函数的函数值一样定义像是main.Add.f这类函数指针 ?

在讨论闭包函数的函数值时我们能够发现,在创建函数值时存储在funcval.fn上的值是函数地址 main.closure.func1,而不是函数指针main.closure.func1.f

实际上 Go 在创建闭包函数的函数值时压根就不需要生成像main.Add.f那样的函数指针。闭包和顶级函数在处理函数值时存在不同主要是因为它们的环境和生命周期有所差异。顶级函数,如main.Add,在编译时就已经确定了其函数体和相关的元数据,因此编译器可以为其生成一个main.Add.f,不需要额外的环境信息。

然而,闭包的情况有所不同。闭包函数需要在运行时动态创建,它会捕获创建时周围作用域的变量的具体状态。这意味着每个闭包实例都可能有不同的环境状态,即使它们是由相同的匿名函数定义产生的。而编译期间无法精准确定闭包创建时周围作用域变量的具体状态,所以也就没有生成main.closure.func1.f

如何获取函数值的底层结构。

既然函数值是一个指向runtime.funcval结构体的指针,那么我们是不是可以通过某种方式将函数值转换为一个我们自定义的funcval的指针呢。这里我们使用unsafe.Pointer将函数值转换为一个类型*funcval的指针funcVal,且该指针所指向的结构体的字段fn的值是Add函数地址 0x95ae80[14]

func Add(a, b int) int { return a + b }

type funcval struct {
    fn uintptr
}

// main:
fn := Add

var funcVal *funcval
ptr := *(*uintptr)(unsafe.Pointer(&fn))
funcVal = (*funcval)(unsafe.Pointer(ptr))
fmt.Printf("函数值:%#v\n", funcVal)
fmt.Printf("Add 函数地址:%p\n", Add)

// output:
// 函数值:&main.funcval{fn:0x95ae80}
// Add 函数地址:0x95ae80

对匿名结构体地址(函数值)进行一次解引之后,可以得到函数地址?

因为对于非空结构体而言,结构体在内存中的地址其实也是该结构体第一个字段在内存中的地址(第一个字段相对结构体地址的偏移为0),所以我们拿到的结构体地址实际也是第一个字段的地址。又因为匿名结构体(runtime.funcval)的第一个字段fn是一个指针,该指针存储的值是函数地址,所以我们对fn解引后就会得到fn存储的值(函数地址)。因此在汇编层面对函数值进行一次解引可以得到函数地址。

func Add(a, b int) int { return a + b }

type funcval struct {
    fn uintptr
}

// main:
fn := Add

ptr := *(*uintptr)(unsafe.Pointer(&fn))
funcVal := (*funcval)(unsafe.Pointer(ptr))
fmt.Printf("函数值:%p\n", funcVal)
fmt.Printf("函数值字段 fn 地址:%p\n", &(funcVal.fn))
fmt.Printf("变量 funcVal 地址:%p\n", &funcVal)

// output:
// 函数值:					0x97fc28
// 函数值字段 fn 地址:		0x97fc28
// 变量 funcVal 地址:		0xc00000a038

Note:内置函数和init函数不可被当作函数值。