函数是 Go 语言中的一等公民,理解和掌握函数的调用过程是我们深入学习 Go 无法跳过的,本节将从函数的调用惯例和参数的传递方法两个方面分别介绍函数的执行过程。
调用惯例
无论是系统级编程语言 C 和 Go,还是脚本语言 Ruby 和 Python,这些编程语言在调用函数时往往都使用相同的语法:
1 | somefunction(arg0, arg1) |
虽然它们调用函数的语法很相似,但是它们的调用惯例却可能大不相同。调用惯例是调用方和被调用方对于参数和返回值传递的约定,我们将在这里为各位读者分别介绍 C 和 Go 语言的调用惯例。
Go 语言
分析了 C 语言函数的调用惯例之后,我们再来剖析一下 Go 语言中函数的调用惯例。我们以下面这个非常简单的代码片段为例简要分析一下:
1 | package main |
上述的 myFunction
函数接受两个整数并返回两个整数,main
函数在调用 myFunction
时将 66 和 77 两个参数传递到当前函数中,使用 go tool compile -S -N -l main.go
命令编译上述代码可以得到如下所示的汇编指令:
注:如果编译时不使用 -N -l 参数,编译器会对汇编代码进行优化,编译结果会有较大差别。
1 | "".main STEXT size=68 args=0x0 locals=0x28 |
根据 main
函数生成的汇编指令,我们可以分析出 main
函数调用 myFunction
之前的栈情况:
main
函数通过 SUBQ $40, SP
指令一共在栈上分配了 40 字节的内存空间:
空间 | 大小 | 作用 |
---|---|---|
SP+32 ~ BP | 8 字节 | main 函数的栈基址指针 |
SP+16 ~ SP+32 | 16 字节 | 函数 myFunction 的两个返回值 |
SP ~ SP+16 | 16 字节 | 函数 myFunction 的两个参数 |
myFunction
入参的压栈顺序和 C 语言一样,都是从右到左,即第一个参数 66 在栈顶的 SP ~ SP+8,第二个参数存储在 SP+8 ~ SP+16 的空间中。
当我们准备好函数的入参之后,会调用汇编指令 CALL "".myFunction(SB)
,这个指令首先会将 main
的返回地址存入栈中,然后改变当前的栈指针 SP 并开始执行 myFunction
的汇编指令:
1 | "".myFunction STEXT nosplit size=49 args=0x20 locals=0x0 |
从上述的汇编代码中我们可以看出,当前函数在执行时首先会将 main
函数中预留的两个返回值地址置成 int
类型的默认值 0,然后根据栈的相对位置获取参数并进行加减操作并将值存回栈中,在 myFunction
函数返回之间,栈中的数据如图所示:
在 myFunction
返回之后,main
函数会通过以下的指令来恢复栈基址指针并销毁已经失去作用的 40 字节的栈空间:
1 | 0x0033 00051 (main.go:9) MOVQ 32(SP), BP |
通过分析 Go 语言编译后的汇编指令,我们发现 Go 语言使用栈传递参数和接收返回值,所以它只需要在栈上多分配一些内存就可以返回多个值。
思考
C 语言和 Go 语言在设计函数的调用惯例时选择也不同的实现。C 语言同时使用寄存器和栈传递参数,使用 eax 寄存器传递返回值;而 Go 语言使用栈传递参数和返回值。我们可以对比一下这两种设计的优点和缺点:
- C 语言的方式能够极大地减少函数调用的额外开销,但是也增加了实现的复杂度;
- CPU 访问栈的开销比访问寄存器高几十倍3;
- 需要单独处理函数参数过多的情况;
- Go 语言的方式能够降低实现的复杂度并支持多返回值,但是牺牲了函数调用的性能;
- 不需要考虑超过寄存器数量的参数应该如何传递;
- 不需要考虑不同架构上的寄存器差异;
- 函数入参和出参的内存空间需要在栈上进行分配;
Go 语言使用栈作为参数和返回值传递的方法是综合考虑后的设计,选择这种设计意味着编译器会更加简单、更容易维护。
参数传递
除了函数的调用惯例之外,Go 语言在传递参数时是传值还是传引用也是一个有趣的问题,这个问题影响的是当我们在函数中对入参进行修改时会不会影响调用方看到的数据。我们先来介绍一下传值和传引用两者的区别:
- 传值:函数调用时会对参数进行拷贝,被调用方和调用方两者持有不相关的两份数据;
- 传引用:函数调用时会传递参数的指针,被调用方和调用方两者持有相同的数据,任意一方做出的修改都会影响另一方。
不同语言会选择不同的方式传递参数,Go 语言选择了传值的方式,无论是传递基本类型、结构体还是指针,都会对传递的参数进行拷贝。本节剩下的内容将会验证这个结论的正确性。
整型和数组
我们先来分析 Go 语言是如何传递基本类型和数组的。如下所示的函数 myFunction
接收了两个参数,整型变量 i
和数组 arr
,这个函数会将传入的两个参数的地址打印出来,在最外层的主函数也会在 myFunction
函数调用前后分别打印两个参数的地址:
1 | func myFunction(i int, arr [2]int) { |
当我们通过命令运行这段代码我们会发现,main
函数和被调用者 myFunction
中参数的地址是完全不同的。
不过从 main
函数的角度来看,在调用 myFunction
前后,整数 i
和数组 arr
两个参数的地址都没有变化。那么如果我们在 myFunction
函数内部对参数进行修改是否会影响 main
函数中的变量呢?我们更新 myFunction
函数并重新执行这段代码:
1 | func myFunction(i int, arr [2]int) { |
你可以看到在 myFunction
中对参数的修改也仅仅影响了当前函数,并没有影响调用方 main
函数,所以我们能给出如下的结论 - Go 语言中对于整型和数组类型的参数都是值传递的,也就是在调用函数时会对内容进行拷贝,需要注意的是如果当前数组的大小非常的大,这种传值方式就会对性能造成比较大的影响。
结构体和指针
接下来我们继续分析 Go 语言另外两种常见类型 —— 结构体和指针。在这段代码中定义一个只包含一个成员变量的简单结构体 MyStruct
以及接受两个参数的 myFunction
方法:
1 | type MyStruct struct { |
从运行的结果我们可以得出如下结论:
- 传递结构体时:会对结构体中的全部内容进行拷贝;
- 传递结构体指针时:会对结构体指针进行拷贝;
对结构体指针的修改是改变了指针指向的结构体,b.i
可以被理解成 (*b).i
,也就是我们先获取指针 b
背后的结构体,再修改结构体的成员变量。我们简单修改上述代码,分析一下 Go 语言结构体在内存中的布局:
1 | type MyStruct struct { |
在这段代码中,我们通过指针的方式修改结构体中的成员变量,结构体在内存中是一片连续的空间,指向结构体的指针也是指向这个结构体的首地址。将 MyStruct
指针修改成 int
类型的,那么访问新指针就会返回整型变量 i
,将指针移动 8 个字节之后就能获取下一个成员变量 j
。
如果我们将上述代码简化成如下所示的代码片段并使用 go tool compile
进行编译会得到如下的结果:
1 | type MyStruct struct { |
在这段汇编语言中我们发现当参数是指针时,也会使用 MOVQ "".ms+8(SP), AX
指令对引用进行复制,然后将复制后的指针作为返回值传递回调用方。
所以将指针作为参数传入某一个函数时,在函数内部会对指针进行复制,也就是会同时出现两个指针指向原有的内存空间,所以 Go 语言中『传指针』也是传值。
传值
当我们对 Go 语言中大多数常见的数据结构进行验证之后,其实就能够推测出 Go 语言在传递参数时其实使用的就是传值的方式,接收方收到参数时会对这些参数进行复制;了解到这一点之后,在传递数组或者内存占用非常大的结构体时,我们在一些函数中应该尽量使用指针作为参数类型来避免发生大量数据的拷贝而影响性能。
小结
这一节我们详细分析了 Go 语言的调用惯例,包括传递参数和返回值的过程和原理。Go 通过栈传递函数的参数和返回值,在调用函数之前会在栈上为返回值分配合适的内存空间,随后将入参从右到左按顺序压栈并拷贝参数,返回值会被存储到调用方预留好的栈空间上,我们可以简单总结出以下几条规则:
- 通过堆栈传递参数,入栈的顺序是从右到左;
- 函数返回值通过堆栈传递并由调用者预先分配内存空间;
- 调用函数时都是传值,接收方会对入参进行复制再计算;