《Go Range 内部实现》 这篇译文对于 Go 开发者来说,很值得一读。
对比老外的原文和作者的翻译,可以学到些许关于 for...range
语法的奥妙。尤其是对于 Go 初学者来说,可以避免少踩几次关于 range
的「坑」。
缘起
老外在 twitter 上图这个 Go 语言小测试,初看此题,直觉上你可能会认为这个 for
循环会无休止地执行下去,因为每次都往切片尾部新增了一个元素,导致切片无法遍历结束。蛤蛤,这是典型的一知半解式解答。不过,即便你能猜到该程序能正常结束并且 v
最终为 [1 2 3 0 1 2]
,你也未必能讲明白 Why。
剖析
在 《Go 规范文档》 「For statements with range clause」 一节可以找到关于 for...range
语法的相关说明。
1 | for v := range a { |
我不想大段大段地摘抄原文,你需要知道的一个关键点是:
循环变量(
v
)在每一次迭代中都被 赋值 且 复用。
range 右边(上例 a
)的表达式(或表达式展开后的结果),可以是这些数据类型:
- 数组(或指向数组的指针)
- 切片
- 字符串
- map
- 可以接收传输的
channel
, 比如:chan int
或chan<- int
在 Go 里,所有的赋值都是 值拷贝。如果赋值了一个指针,那我们就复制了一个指针副本。如果赋值了一个结构体,那我们就复制了一个结构体的副本。
那么, 对于不同类型 a
,range a
返回的结果是什么呢?
类型 a |
range a 赋值结果 |
---|---|
array | 一个新数组:拷贝原始数组到新数组 |
slice | 一个结构体:拥有一个变量 len 、一个变量 cap 和一个指针指向原有 slice 背后的数组 |
string | 一个结构体:拥有一个变量 len 和一个指针指向原有 string 背后的字符数组 |
map | 一个指针:指向原有 map 底层结构体(哈希表) |
channel | 一个指针:指向原有 channel |
原文作者还去瞅了瞅 Go 里面 for...range
一个 slice 编译的源码:
1 | // for_temp := range |
回到这个测试题;
1 | func main() { |
这段代码之所以会结束是因为它其实可以粗略的翻译成类似下面的这段:
1 | for_temp := v |
在循环开始前对这个 slice 生成副本赋值给 for_temp
,后面的循环实际上是在对 for_temp([1 2 3])
而非原始变量 v
进行迭代。
再深一点
前面列出了不同类型 a
经过 range a
赋值后的结果,有两个地方需要注意;
- 对数组赋值都会拷贝到一个新的数组,若要更改原始数组,可通过引用原数组下标的方式修改, 如
a[i] = val
。 - 在 range 循环里对 map 做添加或删除元素的操作是安全的,添加的元素 不一定 会出现在后续的迭代中。
为什么在 map 后续的迭代中不一定能遍历到当前添加的元素?
译文这样解释道:
如果你知道哈希表是如何工作的(map 本质上就是哈希表),就会明白哈希表内部数组里的元素并不是以特定顺序存放。最后一个添加的元素有可能经过哈希后被放到了内部数组里的第一个索引位,我们确实没有办法预测当前添加的元素是否会出现在后续的迭代中,毕竟在添加元素的时候很可能已经遍历过了第一个索引位。因此,当前添加的元素是否能在后续迭代中遍历到,还是看编译器的心情吧。
举个栗子:
1 | func main() { |
下面是几次运行结果:
1 | one 1 |
我们在遍历时即插入也删除了元素,最终都为 map[one:1 two:2 four:4]
,但可以看到,对 map 元素的遍历是无序的,遍历时插入的元素也不一定出现在后续遍历中。
另一面
前面说了:
循环变量(
v
)在每一次迭代中都被 赋值 且 复用。
赋值讲得差不多了,还有复用呢?
这也是初学者易踩的一个大坑。在 《Go的50度灰:Golang新开发者要注意的陷阱和常见错误》 一文中,作者给了很大的篇幅详解「for 循环变量在每次迭代中会被复用」这一特性。这个特性意味着你在 for 循环中创建的闭包(即函数字面量)将会引用同一个变量(而在那些 goroutine 开始执行时就会得到那个变量的值)。
在 for 循环中开启 go routine
尤其需要注意「复用」这个特性,由于 go 一个协程出来,go 运行环境会为这个协程分配一个协程栈来存放函数参数、闭包引用的外部变量等。
比如下面这个例子:
1 | package main |
解决方法,众所周知的有两种:
1.不需要修改 goroutine,在 for 循环代码块内把当前迭代的变量值保存到一个局部变量中。
1 | for _,v := range data { |
2.把当前的迭代变量作为 goroutine 的参数。
1 | for _,v := range data { |
来看看这个稍微复杂一点的例子:
1 | package main |
相信你已经知道,如果 //data
这行改为 data := []field{ {"one"},{"two"},{"three"} }
,毫无疑问三个协程输出都是 "three"
,但这个例子,切片元素 filed 用指针修饰,输出将会如何呢?
答案是输出: "one","two","three"
。
data := []*field{ {"one"},{"two"},{"three"} }
中,for 循环的 v 是指针,print 这个方法接收的是指针的拷贝,协程栈里保存的 v 指针值都是不同的,所以每次输出不同。
因此,有第三种 fix 办法:将切片元素用指针修饰,同样可以达到 goroutine 引用到每个元素的目的。
总结
其实,不管哪一门语言,即使是所谓的大神,一不小心在某些语法细节上栽跟头也是司空见惯。至于为什么会掉「坑」,无外乎这「三个代表」:
- 理解的得根本就不对,南辕北辙;
- 认知不够彻底,只知其一不知其二;
- 先入为主,把其他语言的思维惯用在这门语言之上。
到这里,希望你的脑中有了一副清晰的图像描绘 for...range
在底层是如何运作的,以后别再掉进那些「坑」里。