Go 函数(其一),函数类型与函数值
1. 函数类型
函数类型表示具有相同参数和返回类型的所有函数的集合[1]。一个函数类型的表示形式由一个func
关键字和一个函数签名组成[2] 。其中,函数签名是函数的参数列表和返回值列表的统称,它包括参数以及返回值的类型、数量和顺序[3][4]。对于其他信息,比如参数和返回值的名称,则不包含在函数签名内。因为参数和返回值的名称通常只在函数体内部使用,对于函数的调用者来说,并不影响函数的使用方式。同理函数名也不在其内,函数名称用于在程序中唯一标识和调用函数,也不会影响一类函数的使用方式。因此 Go 语言的函数类型表现形式大致如下:
func(int, string, string) (int,int,bool)
该函数类型有两个特征:三个参数,且类型顺序为int
、string
、string
;三个返回值,且类型顺序为int
、int
、bool
。一个函数只要满足了这两个特征,就能被认为是与该函数类型相匹配。但是需要注意如果函数必须符合函数签名的规定,只要任何一项规定不满足,比如参数或者返回值的顺序不同,那么它们就不被视为同一类型。如下:
// 定义一个函数类型 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
一致,但唯独参数的顺序不一样。这就让FuncImpl
与MyFuncType
不匹配,FuncImpl
实际匹配的函数类型为func(string, int, string) (int,int,bool)
。
2. 函数值
Note : 以下讨论全部基于 64 位 win10
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]。
2.1 未捕获变量的函数值
在Go语言中,函数值可以像普通变量一样传递和使用,它包含了函数的地址和相关的闭包环境(如果有的话)。在没有捕获变量(没有闭包环境)的情况下,函数值的底层结构funcval
只存储函数地址[8]。而本节将通过具体代码示例,大致讨论没有捕获变量的函数值是怎样实现。(Note:下图为没有捕获变量的函数值的内存布局)
以下为示例程序,其中定义了一个函数类型FnType
,用于表示接受两个int
类型参数并返回int
的函数。接着,定义了一个简单的Add
函数,计算两个整数的和。然后,又定义了一个calc
函数,接受两个整数参数和一个FnType
类型的函数fn
,并调用fn
。
package main
type FnType func(int, int) int
func 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
... # 此处省略了二进制数据表示。实际内容为 0
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
则表示在这个只读数据中存储了对main.Add
函数的引用。
在 Go 语言里,只有那些被用作函数值的函数才可能会生成相应的函数指针或地址信息。这是因为��数值的使用涉及到了函数地址的传递和存储,而这需要编译器生成额外的代码来支持。当一个函数被用作函数值时,编译器会为该函数生成必要的元数据[8]。这种元数据可能包括函数的代码段地址、所需的栈空间大小、参数和返回值的信息等。
我们现在提到的main.Add.f
就是编译期间确定了函数Add
会被用作函数值而由编译器生成的函数指针(元数据),而这类函数指针其实就相当于是函数值,只要对main.Add.f
进行一次解引就可以获得其指向的函数地址。
若以 Go 程序来模拟没有捕获变量的函数值的创建过程,其内容大致如下:
main.Add.f := funcval{fn: main.Add}
fn := &main.Add.f
2.2 捕获了变量的函数值(闭包)
闭包,这一源自函数式编程的概念,在计算机科学中扮演着至关重要的角色,它允许函数携带其定义时的环境状态。从维基百科的定义来看,闭包被描述为一个函数与其相关引用环境的组合,这种组合在实现层面往往表现为一个结构体,其中不仅包含指向函数体的指针,还携带着一个环境指针,用以维持函数定义时的上下文[12]。而这一定义与 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)
这段指令先后使用 LEAQ 和 CALL 在堆上创建了一个匿名结构体struct{F uintptr; main.c int}
,用于后续存储函数指针和捕获变量c
。这个匿名结构体本质上其实是runtime.funcval
。
而上述指令中提及的 runtime.newobject 是一个运行时函数,用于在堆上分配内存,这里它根据传入的类型信息分配内存。type:noalg.struct{F uintptr; main.c int}
则是由编译器生成的,其描述了匿名结构体的类型。描述如下:
# 该匿名结构体实际上是:
# type funcval struct {
# fn uintptr
# c int
# }
type:noalg.struct { F uintptr; main.c int } SRODATA dupok size=128
# 此处省略的是该结构体的二进制数据表示
...
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)
首先通过 LEAQ 指令获取匿名函数main.closure.func1
(即函数closure
返回的匿名函数)的地址,并将其加载到寄存器 CX 中。然后使用 MOVQ 指令将这个地址存储到先前在堆上分配的结构体的第一个 Word(即funcval.fn
)上。接下来,又通过一系列指令将这个结构体的地址传递给寄存器 CX,并检查结构体的有效性。随后,将变量c
的值加载到寄存器 DX 中,并使用 MOVQ 指令将c
的值存储到结构体的第二个 Word(即funcval.c
)上。
这样,匿名函数main.closure.func1
的入口地址和捕获的变量c
都被写入到结构体中,确保了闭包函数在脱离其创建函数closure
后也能顺利执行。最终,这个结构体的地址被赋值给返回值寄存器,返回给调用者。
若以 Go 程序来模拟上述创建过程,其内容大致如下:
tmp := &funcval{}
tmp.fn = main.closure.func1 // main.closure.func1 就是匿名函数地址,而非函数指针
tmp.c = mian.c
fn = tmp
到这儿就是对捕获了变量的函数值(闭包函数的函数值)大致的讨论了,我们因此了解到 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
函数的汇编代码中才能找到答案,我们来看这段指令:
main.closure STEXT size=108 args=0x0 locals=0x30 funcid=0x0 align=0x0
TEXT main.closure(SB), ABIInternal, $48-0
...
# 创建用于存储闭包信息的匿名结构体(runtime.funcval)
LEAQ type:noalg.struct { F uintptr; main.c int }(SB), AX
CALL runtime.newobject(SB)
# AX 和 偏移地址 main..autotmp_2+32(SP) 会存储该结构体的地址
MOVQ AX, main..autotmp_2+32(SP)
# 将匿名函数地址存储到结构体第一个 Word 上(相对结构体地址偏移为 0)
LEAQ main.closure.func1(SB), CX
MOVQ CX, (AX)
# 将存储在偏移地址 main..autotmp_2+32(SP) 上的结构体地址加载到 CX 寄存器
MOVQ main..autotmp_2+32(SP), CX
TESTB AL, (CX)
# 将变量 main.c 加载到 DX 寄存器
MOVQ main.c+16(SP), DX
# 将变量 main.c存储到结构体第二个字上(相对结构体地址偏移为 8)
MOVQ DX, 8(CX)
# 以下为返回处理
...
从上述汇编中我们能够发现,被捕获的变量是直接存储到了相对匿名结构体地址偏移为 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]。(Note:在本小节最开始 main 函数的汇编代码中有备注)
Go 在处理闭包函数的函数值时为什么没有像顶级函数的函数值一样定义像是
main.Add.f
这类函数指针 ?
在讨论闭包函数的函数值时我们能够发现,在创建函数值时存储在funcval.fn
上的值是函数地址 main.closure.func1
,而不是函数指针main.closure.func1.f
。
实际上 Go 在创建闭包函数的函数值时压根就不需要生成像main.Add.f
那样的函数指针。闭包和顶级函数在处理函数值时存在不同主要是因为它们的环境和生命周明有所差异。顶级函数,如main.Add
,在编译时就已经确定了其函数体和相关的元数据,因此编译器可以为其生成一个静态的函数指针,如main.Add.f
,这个指针直接指向函数的起始地址,不需要额外的环境信息。
然而,闭包的情况有所不同。闭包函数需要在运行时动态创建,它会捕获创建时周围作用域的变量的具体状态。这意味着每个闭包实例都可能有不同的环境状态,即使它们是由相同的匿名函数定义产生的。而编译期间无法精准确定闭包创建时周围作用域变量的具体状态,所以也就没有生成像是main.Add.f
那样的静态函数指针。
如何获取函数值的底层结构。
既然函数值是一个指向runtime.funcval
结构体的指针,那么我们是不是可以通过某种方式将函数值转换为一个我们自定义的funcval
的指针呢。这里我们使用unsafe.Pointer
将函���值转换为一个类型*funcval
的指针funcVal
,且该指针所指向的结构体的字段fn
的值是Add
函数地址 0x95ae80[13]。
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
函数不可被当作函数值。
转载自:https://juejin.cn/post/7390934048258441227