前前言
此文翻译自https://www.ardanlabs.com/blog/2017/05/language-mechanics-on-stacks-and-pointers.html,是一篇了解 go 的必备文章。读了会非常有指导意义。
这篇文章让我真正的懂得了编程中栈的实际作用,以及存在的意义。也让我理解了 python 中的LEGB搜索变量的原则。如果从栈上来理解LEGB那就非常简单了。
翻译的可能有点简陋。希望大家看完了有什么不懂、或者觉得翻译的有点晦涩的地方,欢迎评论指出,一定有问必答!
前言
我并没有打算美化指针,指针确实难以理解。在没有正确使用的时候,指针会造成非常恶心的bug甚至性能问题。在编码并发或者多线程的项目的时候,指针就尤其如此了。所以很多语言都在尝试让编码人员和指针想隔离。但是呢,作为一个 go 的开发人员,咱是无法避免接触指针的。如果没有对指针的比较好的理解, 你会非常艰难的写出干净、简单、高效的代码。
帧边界
对于每个函数而言,都在一个帧的范围内运行,其中每个帧都有单独的内存空间。每个帧允许函数在当前的环境中操作,并且为函数提供了流程控制。函数可以通过帧指针来操作帧的内存,但是对于帧外的内存需要使用非直接的方式。当一个函数需要操作帧外的内存的时候,此内存地址必须和函数共享。在开始其他讲解之前,我们需要懂得和了解这些帧边界的机制和限制。
当一个函数被调用的时候,会发生一个由一个帧到另外一个帧的转变。代码会由调用函数的帧,转到被调用函数的帧中去。如果函数需要使用某些值,那么这些值会从一个帧传递到另一个帧。在 go 中,帧之间传递的数据是值,也就是说函数变量是传值的。
传值的优势是可读性。函数调用的时候传递的值是从一端复制到另一端的。这就是为什么我把传值称之为WYSIWYG,因为所见即所得(what you see is what you get)。这个允许你展示在函数调用的时候两个函数间转化过程中的消耗;并且有助于维护好的模型,可以展示在发生转变的时候,函数调用是如何影响程序性能的 。
我们可以看下一个简单的程序,在函数调用传递一个整型值:
Listing 1
01 package main
02
03 func main() {
04
05 // Declare variable of type int with a value of 10.
06 count := 10
07
08 // Display the "value of" and "address of" count.
09 println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
10
11 // Pass the "value of" the count.
12 increment(count)
13
14 println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
15 }
16
17 //go:noinline
18 func increment(inc int) {
19
20 // Increment the "value of" inc.
21 inc++
22 println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")
23 }
当你的 go 程序启动的时候,runtime 会创建 main 协程来执行 main 函数内部的所有初始化代码。一个协程就是一个置于操作系统线程的执行过程,最终会在计算机上某个核上运行。在 go 的 1.8 版本,每个协程在初始化的时候会分配2048个字节的连续内存空间来作为其栈空间。栈的初始内存会随着时间而改变,也可能会在将来再次发生改变。
栈是非常重要的,因为它为帧边界提供了内存空间,也就是为每个函数提供了内存空间。在 Listing 1 中函数 main 协程执行 main函数的时候,协程的栈看起来会想下图一样(较高层级的抽象):
Figure 1
你可以在 Figure 1 中看到 main 函数被分配了一个一帧的栈空间。这个部分称为"栈帧",正是这个"栈帧"表明了 main 函数在栈中的边界。在函数调用的时候,这个帧作为代码的一部分被创建。你可以看到变量 count 的地址为 0x10429fa4,是在帧的内存空间内的。
还有一个非常有趣的点在此 Figure 1 表明的非常清晰。所有的栈空间中,低于此栈的内存空间是无效的(译者注: 无法访问),高于此栈的内存空间是有效的(译者注: 可以访问)。我需要在有效的栈空间和无效的栈空间做好区分。
地址
变量存在的目的是为了通过给内存地址分配一个名称来提高代码的可读性,也可以帮你理解你操作的是什么数据。在你有一个变量的时候,你就有一个在内存中存储的值,也就是在内存中分配了值,这个值就是有地址的。在第9行,main 函数调用了 println 函数来展示变量 count 的值和地址
Listing 2
09 println("count:\tValue Of[", count, "]\tAddr Of[", &count, "]")
通过 & 符号来获取变量的地址并不新颖,在其他的语言中也使用此符号来获取变量的地址。如果你的代码运行在一个32位的机器上,那么第 9 行的输出会和下方的相似:
Listing 3
count: Value Of[ 10 ] Addr Of[ 0x10429fa4 ]
函数调用
第 12 行,main 函数调用了 increment 函数
Listing 4
12 increment(count)
调用函数意味协程需要在栈中创建一个新的帧。然而,实际情况会有点复杂。为了成功的调用函数,数据需要跨越帧的边界被放置到新的帧中去。特别的是在调用的过程中整型值需要复制和传递。你可以通过第 18 行的 increment 的函数声明中看到此要求。
Listing 5
18 func increment(inc int) {
如果你仔细查看第 12 行对 increment 函数的调用,你会发现函数间传递的是变量 count 的值。此值会被赋值、传递并且在新的帧内分配内存以便 increment 函数的调用。我们需要记住,函数 increment 只能直接的读写帧内的内存,所以它需要用 变量 inc 来接收、存储和读取变量 count 所复制的值。
在函数 increment 实际被调用之前,协程的栈(在比较高层次的抽象下)会和下方相似:
Figure 2
你可以看到此栈中含有两帧,一个是 main 函数的帧,在其之下还有一个是 increment 函数的帧。在函数 increment 的帧内,你可以看到变量 inc,其所包含的值 10 是在函数调用过程中复制和传递的。变量 inc 的地址是 0x10429f98, 并且地址小于 main 函数中变量 count 的地址,这仅仅是实现的细节,并不代表着什么。重要的是协程把 main 函数所在帧内的 count 的值复制了一份给函数 increment 所在的帧的变量 inc。
函数 increment 剩下部分的代码对变量 inc 做了自增,并且打印了 inc 的值和址。
Listing 6
21 inc++
22 println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]")
第 22 行打印的结果和下方相似:
Listing 7
inc: Value Of[ 11 ] Addr Of[ 0x10429f98 ]
下面的图表示的就是第 22 行执行时候的栈的样子
Figure 3
在第 21 行和第 22 行执行完成之后,increment 函数会返回并且流程会返回到 mian 函数。然后,main 函数会在第 14 行打局部变量 count 的值和地址。
Listing 8
14 println("count:\tValue Of[",count, "]\tAddr Of[", &count, "]")
函数执行完的 全部输出如下
Listing 9
count: Value Of[ 10 ] Addr Of[ 0x10429fa4 ]
inc: Value Of[ 11 ] Addr Of[ 0x10429f98 ]
count: Value Of[ 10 ] Addr Of[ 0x10429fa4 ]
main 函数帧中的 count 的值在调用 increment 函数前后是一样的。
函数返回
在函数返回并且流程返回到函数调用的时候,栈中的帧内存会发生什么?一个简短的回答就是,什么都不会发生。下方的是在函数 increment 调用返回之后栈的样子:
Figure 4
此时的栈和 Figure 3 中的是一样的,除了函数 increment 所运行的栈空间此时是无效的了。这是因为此时 main 函数是当前活跃的栈。给 increment 函数分配的栈不会发生改动。
在函数调用完成之后,清理其所分配的栈空间是浪费时间,因为不知道此空间是否会再次使用。所以其内存空间会遗留下来。在发生函数调用的时候,其空间被分配给了一个新的函数,此时内存空间会被清除。清除的动作会在帧内的变量初始化的时候发生。因为所有的变量会被初始化为一个"零值"。栈会在函数调用的时候合理的清除自己。
共享值
如果函数 increment 非要直接操作位于 main 函数所在帧中的变量 count 的值呢?这样就需要使用指针了。指针存在的目的就是用来和函数共享变量,所以函数可以读取和修改变量的值,即使此值所在的地址并不直接存在于此函数所在的帧空间内。
如果你不需要共享变量,那么你不需要使用指针。当学习指针的时候,我们不应该以仅仅认为指针是一个操作或者符号,而应该把它当做一个词汇,就是共享变量。所以你要记住,当你阅读代码的时候,你需要使用"共享"来代替操作符号 &。
指针类型
任何一种声明的类型,不管是你创建的或者语言自带的,都有一个对应的指针类型用于共享变量。对于内建的变量 int,有一种对应的类型*int。如果你声明了一个变量类型 User,那么就有着一个对应的指针类型*User。
每个指针类型都有着相同的两个字符。首先,它们以符号*开始。然后,它们占用的内存同样大并且有着相同的表示方法,用 4 或者 8 个字节来表示地址。在 32 位的机器上,指针占用的内存是 4 个字节;在 64 位的机器上,指针占用的是 8 个字节。
间接的操作内存
下面你的程序中的函数传递的是地址的值。这就会让 increment 函数和 main 函数共享变量 count。
Listing 10
01 package main
02
03 func main() {
04
05 // Declare variable of type int with a value of 10.
06 count := 10
07
08 // Display the "value of" and "address of" count.
09 println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
10
11 // Pass the "address of" count.
12 increment(&count)
13
14 println("count:\tValue Of[", count, "]\t\tAddr Of[", &count, "]")
15 }
16
17 //go:noinline
18 func increment(inc *int) {
19
20 // Increment the "value of" count that the "pointer points to". (dereferencing)
21 *inc++
22 println("inc:\tValue Of[", inc, "]\tAddr Of[", &inc, "]\tValue Points To[", *inc, "]")
23 }
此代码和之前的代码有三处非常有意思的改变。首先看第一处改变,在第 12 行:
Listing 11
12 increment(&count)
此时在第12行,代码中并没有复制和传递变量 count 的值,而是复制和传递变量 count 的地址。现在,你就可以说我正在和函数 increment 共享变量 count。这个正是符号 & 所代表的含义:共享。
我们需要知道的是此时传递的也是值,只不过传递的是一个整型变量的地址。地址当然也是值;此时地址的值在函数边界完成复制和传递。
由于变量的地址此时被复制和传递,所以需要在 increment 函数所在帧中分配一个变量来接受和存储此地址。这个正是第 18 行声明的整型变量地址。
Listing 12
18 func increment(inc *int) {
如果你传递的是User 类型的地址,那么变量需要被声明为*User。即使所有的指针类型都存储的是地址,也需要在传递的过程中使用对应的指针类型来接受。这个比较关键,共享数据的目的是因为接受地址的函数需要读取和操作值。你需要具体的变量类型的指针来操作其值。编译器会确保共享的值指针和接受函数的指针类型一致。
下面的是在函数 increment 调用时候栈的样子:
Figure 5
从 fiigure 5 中可以看出来栈在帧之间传递指针的值的时候的样子。在函数 increment 所在帧内的指针值所指向的值是 count 变量,其在 main 函数运行所在的帧内。
现在通过指针,函数可以通过间接的方式起来读取和修改位于 main 函数所在帧内的变量 count 的值。
Listing 13
21 *inc++
此时*是一个操作符,用于操作指针变量所指向的值。指针变量可以通过间接的方式操作位于其所在函数帧外的变量的值。虽然可以操作,但是函数 increment 仍然需要通过位于其帧内的用于存储帧外变量的地址值才可以进行。
在 figure 6 中你可以看到在执行完成 21 行之后栈的样子。
Figure 6
下方是程序的全部输出
Listing 14
count: Value Of[ 10 ] Addr Of[ 0x10429fa4 ]
inc: Value Of[ 0x10429fa4 ] Addr Of[ 0x10429f98 ] Value Points To[ 11 ]
count: Value Of[ 11 ] Addr Of[ 0x10429fa4 ]
通过上述输出可以知道指针 inc 的值和变量 count 的地址一致。正是通过指针的方式,函数可以操作其所在帧外的变量。在函数 increment 通过指针完成变量写的操作之后,流程返回的时候,main 函数也会发现这种改变。
指针变量并不特殊
指针变量并不特殊,因为它和其他变量一样。它们也会被分配内存,也有一个值。虽然指针指向值的类型不同,但是每个指针的大小都一样,并且表示方式也是一样的。可能让人困惑的就是符号*,它在代码中表示的是一个操作,并且可以用于声明指针类型。如果你可以区分变量声明和指针操作,那么你会对此并不会感到困惑。
结论
这篇文章描述了指针背后的目的以及 go 中栈和指针的工作机制。这是理解 go 中的机制、设计哲学以及用于写出一致和可读的代码的指导性意见的第一步。
总而言之,下面的是你学到的:
- 函数运行在为其分配的单独帧空间内
- 当发生函数调用的时候,会发生两个帧之间的转移
- 传值的优势是可读性
- 栈是非常重要的,因为它为每个函数的帧边界提供了运行的空间
- 所有的栈中,其下方是无效的,上方是有效的
- 调用一个函数意味着协程需要在栈中分配一块新的空间
- 指针存在的目的就是为了用于和函数共享值,所以函数可以操作位于其帧之外的变量
- 声明任何一种变量,不管是自定义的还是 go 中自带的,都有一种对应的指针类型
- 指针变量允许函数操作位于其帧外的内存
- 指针变量并不特殊,和其他变量一样,也有内存分配,也含有一个值