Go常用的遍历方式有两种:for和for-range。实际上,for-range也只是for的语法糖,本文试图从汇编代码入手解释for循环是如何工作的。
问题
首先来看看几个令人迷惑的地方。
问题1:遍历过程中取值
func main() {
arr := [5]int{1, 2, 3, 4, 5}
for _, v := range arr {
println(&v)
}
}
上面这段代码里,会打印出什么?
问题2:遍历过程中修改
arr := []int{1, 2, 3, 4, 5}
for v := range arr {
arr = append(arr, v)
}
上面这段代码里,遍历前后arr
有哪些变化?
窥探虚实
对于问题1,我们期待会打印出5个不同的地址,实际上最终打印出来的都是同一个地址,我们可以猜测v
在循环过程中只声明了一次。看看问题1的汇编代码:
0x0028 00040 (main.go:4) MOVQ ""..stmp_0(SB), AX
0x002f 00047 (main.go:4) MOVQ AX, "".arr+24(SP)
0x0034 00052 (main.go:4) MOVUPS ""..stmp_0+8(SB), X0
0x003b 00059 (main.go:4) MOVUPS X0, "".arr+32(SP)
0x0040 00064 (main.go:4) MOVUPS ""..stmp_0+24(SB), X0
0x0047 00071 (main.go:4) MOVUPS X0, "".arr+48(SP)
0x004c 00076 (main.go:5) MOVQ "".arr+24(SP), AX
0x0051 00081 (main.go:5) MOVQ AX, ""..autotmp_2+64(SP)
0x0056 00086 (main.go:5) MOVUPS "".arr+32(SP), X0
0x005b 00091 (main.go:5) MOVUPS X0, ""..autotmp_2+72(SP)
0x0060 00096 (main.go:5) MOVUPS "".arr+48(SP), X0
0x0065 00101 (main.go:5) MOVUPS X0, ""..autotmp_2+88(SP)
0x006a 00106 (main.go:5) XORL AX, AX
0x006c 00108 (main.go:5) JMP 162
0x006e 00110 (main.go:5) MOVQ AX, ""..autotmp_7+16(SP)
0x0073 00115 (main.go:5) MOVQ ""..autotmp_2+64(SP)(AX*8), CX
0x0078 00120 (main.go:5) MOVQ CX, "".v+8(SP)
0x007d 00125 (main.go:6) CALL runtime.printlock(SB)
0x0082 00130 (main.go:6) LEAQ "".v+8(SP), AX
0x0087 00135 (main.go:6) MOVQ AX, (SP)
0x008b 00139 (main.go:6) CALL runtime.printpointer(SB)
0x0090 00144 (main.go:6) CALL runtime.printnl(SB)
0x0095 00149 (main.go:6) CALL runtime.printunlock(SB)
0x009a 00154 (main.go:5) MOVQ ""..autotmp_7+16(SP), AX
0x009f 00159 (main.go:5) INCQ AX
0x00a2 00162 (main.go:5) CMPQ AX, $5
0x00a6 00166 (main.go:5) JLT 110
00040行:MOVQ ""..stmp_0(SB), AX
将stmp_0
变量里的内容放到AX寄存器里,stmp_0
实际上就是arr
数组,在生成的汇编代码里:
""..stmp_0 SRODATA size=40
0x0000 01 00 00 00 00 00 00 00 02 00 00 00 00 00 00 00
0x0010 03 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00
0x0020 05 00 00 00 00 00 00 00
由此可以看到stmp_0正是arr
数组。
00106行:XORL AX AX
是初始化AX寄存器,AX寄存器里包含当前循环位置。
00108行:JMP 162
表示跳转到00162行。
00162行:CMPQ AX $5
比较寄存器AX和5,伪代码:i < 5
,如果满足条件,则跳转到00110行。
00110行00159行为循环体代码,注意到00159行INCQ AX
, 意即AX寄存器值自增,到这里我们可以大致分析出来for-range在汇编层面的伪代码:
for i := 0; i < 5; i++ {
}
这也就验证了上面说的for-range只是普通for的语法糖。
00110到00120行是循环体代码的前半部分。从Go 汇编文档上看:SP寄存器指向当前栈帧的局部变量的开始位置,也就是说局部变量放在了SP寄存器的栈帧里。
00115行:MOVQ ""..autotmp_2+64(SP)(AX*8), CX
,autotmp_*是为临时变量自动生成的名字,这行汇编做的事情是将某个v
值(注意,是值)放在CX寄存器里。
00120行:MOVQ CX, "".v+8(SP)
将CX寄存器里的内容放在SP寄存器指向的位置,00125行代码是一个隔断,00125之后的代码与println
有关。重点在这行代码,每次循环都会将值放在"".v+8(SP)
这个位置,在这个循环体代码里,我们并没有看到其他的临时变量声明,到这里,我们可以总结出:"".v+8(SP)
这个位置就是变量v
在栈帧中的位置,由于位置一直没有发生变化,在进行&v
操作时取到的会是同一个地址。
对于问题1,根据汇编代码的分析,我们得出结论:v
在循环过程中只会声明一次,每次循环只是将v值替换,并未重新声明临时变量,这样解释了问题1代码的输出结果。
再回到问题2,我们期待循环永远不会停下来,但实际上循环5次之后停了下来。我们有理由猜测:循环体中的arr
与arr = append(arr, v)
中的并非同一个。
由于两段代码的汇编代码差不多,这里仍以上面的汇编代码来分析。00106行是初始AX寄存器,也是循环的开始,所以我们关注00106行之前的代码。
根据上面的分析,在00040行已经将数组内容放到了AX寄存器里,00081行到00101行,将数组拷贝到autotmp_2变量内,由SP所指向的栈顶。
在读这段代码的汇编时,发现编译器针对数组内容做了一个小优化,当数组长度小于5时候,编译器会认为这个数组只是临时变量,会直接做栈上赋值,直接将数组内容放到autotmp_2变量中(栈上),省略了从数据只读区到AX的过程(即00040行),数组长度小于5时,汇编代码如下:
0x0024 00036 (main.go:5) MOVQ $1, ""..autotmp_2+24(SP)
0x002d 00045 (main.go:5) MOVQ $2, ""..autotmp_2+32(SP)
0x0036 00054 (main.go:5) MOVQ $3, ""..autotmp_2+40(SP)
0x003f 00063 (main.go:5) XORL AX, AX
分析到这里,我们可以得到一段表示for循环的伪代码:
temp := {1, 2, 3, 4, 5}
for i := 0; i < 5; i++ {
v := temp[i]
}
由此我们可以得到结论:for-range时拷贝了被访问的列表(array、slice、hashmap等)。问题2所带的思考:当数组比较大时,for-range拷贝数组的开销也会比较大,在实际应用中应当避免这个开销。
总结
从上面的汇编代码分析过来看,总结两点:
- 循环过程中位置变量,只会声明一次,也就是说每次循环位置变量的地址都是相同的。
- for-range时拷贝了被访问的列表(array、slice、hashmap等)。
延申阅读
有疑问加站长微信联系(非本文作者)