✍ 个人博客:https://blog.csdn.net/Newin2020?type=blog
📝 专栏地址:https://blog.csdn.net/newin2020/category_12898955.html
📣 专栏定位:为 0 基础刚入门 Golang 的小伙伴提供详细的讲解,也欢迎大佬们一起交流~
📚 专栏简介:在这个专栏,我将带着大家从 0 开始入门 Golang 的学习。在这个 Golang 的新人系列专栏下,将会总结 Golang 入门基础的一些知识点,并由浅入深的学习这些知识点,方便大家快速入门学习~
❤️ 如果有收获的话,欢迎点赞 👍 收藏 📁 关注,您的支持就是我创作的最大动力 💪
1. 函数的定义
go 函数支持普通函数、匿名函数、闭包。
go 中函数是 “一等公民”:
- 函数本身可以当做变量
- 匿名函数、闭包
- 函数可以满足接口
func add(a, b int) (int, error) {
return a+b, nil
}
//调用函数
sum, _ := add(1, 2)
注意:
函数传递传递的时候是值传递,go 语言中全部都是值传递。
1.1 返回值定义
在 go 语言中,返回值的定义是可以返回多个的,并且可以选择是否定义返回值的名字。
func add(a, b int) (sum int, err error) {
sum = a + b
return //如果定义了名字,那此处默认返回定义好的变量sum和err返回
}
注意:
返回值中,如果第一个定义了名字,后面的类型也必须定义名字。
1.2 可变参数
可变参数是指函数可以接受不定数量的参数。通过在参数类型前加上 … 来实现。例如,如果一个函数的参数类型为 …int ,那么就可以向这个函数传递任意数量的 int 类型的参数。在函数内部,可变参数会被当作一个切片来处理。
func add(desc string, item ...int) (sum int, err error) {
for _, value := range items {
sum += value
}
return sum, err
}
//调用函数
sum, _ := add("add", 1, 2, 3, 4)
1.3 函数一等公民特性
函数一等公民特性指的是函数可以像其他数据类型一样被处理:
- 函数可以像普通变量一样被赋值给变量。
- 函数可以作为参数传递给其他函数。
- 函数可以作为其他函数的返回值。
func cal(op string) func() {
switch op {
case "+":
return func() {
fmt.Println("这是加法")
}
case "-":
return func() {
fmt.Println("这是减法")
}
default
}
}
//函数可以赋值给变量
funcVar := cal("+")
sum, _ := funcVar(1, 2, 3, 4)
1.4 匿名函数
在 Go 语言中,匿名函数是指没有名称的函数,它可以在需要的地方直接定义和使用。匿名函数通常用于以下场景:
- 作为回调函数,传递给其他函数。
- 在一个函数内部定义,用于封装一些局部逻辑,仅在该函数内使用。
func callback(y int, f func(int, int)) {
f(y, 2)
}
//创建匿名函数
localFunc := func(a, b int) {
fmt.Printf("total is: %d\r\n", a+b)
}
callback(1, localFunc)
2. 函数帧布局与跳转
2.1 函数执行
按照编程语言定义的函数,会被编译器编译为一堆机器指令,写入可执行文件中。在程序执行的时候,可执行文件就会被加载到内存,这些机器指令将会位于虚拟地址空间中的代码段。
如果在一个函数中调用另一个函数,编译器就会对应生成一条 call 指令,程序执行到这条指令时,就会跳转到被调用函数入口处开始执行。而每个函数的最后,都会有一条 ret 指令,负责在函数结束后跳回到调用处继续执行。
2.2 函数栈帧
在函数执行的时候,需要有足够的内存空间来存放局部变量、参数、返回值等数据,这段空间就对应到虚拟地址空间的栈了。栈只有一个口可供进出,先入栈的在底,后入栈的在顶,最后入栈的最早被取出。运行时栈的上面是高地址,向下增长,分配给函数的栈空间被称为函数栈帧,而栈底通常称为栈基,栈顶又叫做栈指针。
Go 语言中的函数栈帧布局如下,先是调用者的栈基地址,其次是局部变量,然后就是调用函数的返回值,最后才是参数。注意 callee 表示被调用者,所以下图中 callee’s 指的是被调用的函数。
而 call 指令只做两件事情:
- 将下一条指令的地址入栈,即返回地址,被调用函数执行结束后会跳回到这里。
- 跳转到被调用的函数入口处执行。
所有函数的栈帧布局都遵循统一的约定,所以被调用者是通过栈指针 + 相应的偏移来定位到每个参数和返回值的。
2.3 指令执行
程序执行时,CPU 会用特定的寄存器来存储运行时栈基于栈指针,同时也有指令指针寄存器用于存储下一条要执行的指令地址。
例如我接下来要执行入栈数据 3 的指令,则 CPU 在读取后,会将指令指针移向下一条指令,然后栈指针向下移动,入栈数字 3。
同理,将数字 4 入栈后,指令指针和栈指针都会向下移动。
2.4 栈空间分配
不过在 Go 语言中函数栈帧不是这样逐步扩张的,而是一次性分配,也就是在分配栈帧时,直接将指针移动到到所需最大栈空间的位置,然后通过栈指针加上偏移值这种相对寻址的方式来使用函数栈帧。
例如,下面 sp 加 16 字节处存储 3,加 8 字节处存储 4,诸如此类。
之所以 Go 语言会选择一次性分配,主要是为了避免栈访问越界。例如我下面有 3 个 goroutine,初始分配的栈空间只有这么大,如果 g2 的栈已经快使用完了,而接下来要执行的函数要用的空间比 g2 分配的空间的剩余部分还要大。若函数栈是逐步扩张的,则执行期间就可能发生栈访问越界的问题。
由于函数栈帧的大小可以在编译时期确定,对于栈消耗较大的函数,Go 语言的编译器会在函数头部插入检测代码,如果发现需要进行 “栈增长”,就会另外分配一段足够大的栈空空间,并把原来栈上的数据拷贝过来,而原来的栈空间就会被释放掉。
2.5 指针的跳转
函数可以通过 call 指令来实现跳转,而每个函数调用开始时会分配栈帧,调用结束前则会释放自己的栈帧,ret 指令又会把栈恢复到 call 之前的样子,通过这些指令的配合能够实现函数的层层嵌套。
如果一个函数 A 调用函数 B,B 调用 C,C 又调用 D,就会形成下面这样的栈。
3. 函数传参
3.1 案例一:错误的交换
现在有个交换变量的函数,在调用后并没有成功交换两个变量的值,打印出来还是交换之前的值。
func swap(a, b int) {
a, b = b, a
}
func main() {
a, b := 1, 2
swap(a, b)
fmt.Println(a, b) // 1 2
}
我们可以通过分析上面函数的栈帧来发现问题所在,首先来看一下 main 函数的栈帧分配情况,其中重点来关注代码中涉及到的变量 a 和 b。
按照分配顺序,会先分配 main 中局部变量 a 和 b 的空间,又因为 main 中调用的的 swap 函数没有返回值,所以在局部变量的后面紧跟着就是被调用函数 swap 传入的参数。这里需要传入两个整形参数,而传参就是值拷贝,因此要拷贝这两个整形变量的值。
另外,需要注意的是参数入栈的顺序为由右至左,因此我们需要先入栈第二个参数,然后再入栈第一个参数,而返回值同理。这样被调用函数通过 SP 加偏移寻址的时候就比较方便。
接下来调用者栈帧后面接着就是 call 指令存入的返回地址,再下面分配的就是 swap 函数栈帧了。
现在,我们假设函数执行到了 swap 函数中的 a, b = b, a 这行代码,那么通过观察函数栈帧就能找到交换失败的原因了。这行代码并没有修改调用者 main 函数中局部变量的值,而是修改了传入参数的值。而打印的时候打印的是局部变量的值,而不是传入参数的值,因此会出现交换失败的情况。
3.2 案例二:正确的交换
我们再来看一个案例,仍然是交换两个整型变量的值,但是这次的参数类型改为了整形指针,而这一次就交换成功了。我们还是通过函数调用栈,来看看这一次和上一次有何不同。
func swap(a, b *int) {
*a, *b = *b, *a
}
func main() {
a, b := 1, 2
swap(&a, &b)
fmt.Println(a, b) // 2 1
}
main 函数栈帧还是先分配局部变量,然后再分配参数空间。这次的参数都是指针,而指针又是值拷贝,所以这里拷贝的就是 a 和 b 的地址。并且入栈顺序依然是由右至左,即先入栈 B 的地址,再入栈 A 的地址。然后紧跟着就是返回地址以及 swap 函数栈帧。
而当我们执行到 swap 函数中 *a, *b = *b, *a 这行代码时,由于参数的栈帧里存储的是变量 a 和 b 的地址,因此当更改值的时候会定位到两个变量的原始地址除进行更改,所以我们这一次可以交换成功。
4. 函数返回值
通常情况下,我们会认为返回值是通过寄存器传递的,但是再 go 语言中,返回值的个数可能比寄存器的数量还要多。因此,在栈上分配返回值空间更为合适。
4.1 案例一:匿名返回值
这次我们来看一个有返回值的例子,这次 main 函数会调用一个叫 incr 的函数,然后将返回值赋值给局部变量 b,还是一样我们先来看看函数调用栈的情况。
func incr(a int) int {
var b int
defer func() {
a++
b++
}()
a++
b = a
return b
}
func main() {
var a, b int
b = incr(a)
fmt.Println(a, b) // 0 1
}
还是一样先分配局部变量的空间,而这次存在返回值,因此需要先分配返回值,这里会初始化为类型的零值。然后再分配参数的空间,对传参进行值拷贝。
而到了 incr 函数栈帧,则是先保存返回值地址和调用者 main 函数的栈基地址,然后就是初始化局部变量 b。
当 incr 函数执行到 a++ 这行代码时,会将参数 a 自增 1。
下一步,把参数 a 赋值给 incr 函数的局部变量 b。
到了 incr 函数的 return 这里,需要先明确一个关键问题,因为函数的最后会有编译器插入的指令负责释放函数栈帧,然后恢复到调用者栈。但是在这之前需要给返回值赋值并执行 defer 函数,而这个操作的顺序为先给返回值赋值,然后再执行 defer 函数。
因此执行到 return b 这行代码时,会先将局部变量 b 的值拷贝到返回值的空间位置。
然后再执行 incr 函数中注册的 defer 函数,而再 defer 函数中,a 和 b 都会自增 1。
然后 incr 函数执行结束,返回到 main 函数中 b = incr(a) 这行代码,因此 incr 函数的返回值 1 会赋值给 main 函数的局部变量 b。
所以我们最后会输出 0 和 1 的值,这其中影响 main 函数局部变量 b 的值的关键就在于上面 incr 函数给返回值赋值和执行 defer 函数的顺序。
4.2 案例二:命名返回值
再来个例子,其它代码都不变,我们只把 incr 函数中的局部变量 b 改成命名返回值,然后再看看有何不同。
func incr(a int) (b int) {
defer func() {
a++
b++
}()
a++
return a
}
func main() {
var a, b int
b = incr(a)
fmt.Println(a, b) // 0 2
}
我们这里的 main 函数栈帧和上个例子的完全相同,而到 incr 函数栈帧这里没有了局部变量
再来看函数的执行,当执行到 incr 函数中 a++ 这行代码时,参数 a 的值会增加 1。
而执行到 return a 这里后,会先把参数 a 赋值给返回值 b。
然后执行 defer 函数,参数 a 和返回值 b 都会自增 1,之后 incr 函数执行完毕。
因此 incr 的返回值为 2,而 main 函数中局部变量 b 的值也会被赋值为 2,最终打印的结果就为 0 和 2。