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正是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-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时,汇编代码如下:
分析到这里,我们可以得到一段表示for循环的伪代码:
由此我们可以得到结论:for-range时拷贝了被访问的列表(array、slice、hashmap等)。问题2所带的思考:当数组比较大时,for-range拷贝数组的开销也会比较大,在实际应用中应当避免这个开销。
总结
从上面的汇编代码分析过来看,总结两点:
1. 循环过程中位置变量,只会声明一次,也就是说每次循环位置变量的地址都是相同的。 2. for-range时拷贝了被访问的列表(array、slice、hashmap等)。
(责任编辑:admin)