for range“坑”大阅兵
1)iteration variable重用
for range的idiomatic的使用方式是使用short variable declaration(:=)形式在for expression中声明iteration variable,但需要注意的是这些variable在每次循环体中都会被重用,而不是重新声明。
//details-in-go/5/iterationvariable.go
… …
var m = [...]int{1, 2, 3, 4, 5}
for i, v := range m {
go func() {
time.Sleep(time.Second * 3)
fmt.Println(i, v)
}()
}
time.Sleep(time.Second * 10)
… …
在我的Mac上,输出结果如下:
$go run iterationvariable.go
4 5
4 5
4 5
4 5
4 5
各个goroutine中输出的i,v值都是for range循环结束后的i, v最终值,而不是各个goroutine启动时的i, v值。一个可行的fix方法:
for i, v := range m {
go func(i, v int) {
time.Sleep(time.Second * 3)
fmt.Println(i, v)
}(i, v)
}
2)range expression副本参与iteration
range后面接受的表达式的类型包括:array, pointer to array, slice, string, map和channel(有读权限的)。我们以array为例来看一个简单的例子:
//details-in-go/5/arrayrangeexpression.go
func arrayRangeExpression() {
var a = [5]int{1, 2, 3, 4, 5}
var r [5]int
fmt.Println("a = ", a)
for i, v := range a {
if i == 0 {
a[1] = 12
a[2] = 13
}
r[i] = v
}
fmt.Println("r = ", r)
}
我们期待输出结果:
a = [1 2 3 4 5]
r = [1 12 13 4 5]
a = [1 12 13 4 5]
但实际输出结果却是:
a = [1 2 3 4 5]
r = [1 2 3 4 5]
a = [1 12 13 4 5]
我们原以为在第一次iteration,也就是i = 0时,我们对a的修改(a[1] = 12,a[2] = 13)会在第二次、第三次循环中被v取出,但结果却是v取出的依旧是a被修改前的值:2和3。这就是for range的一个不大不小的坑:range expression副本参与循环。也就是说在上面这个例子里,真正参与循环的是a的副本,而不是真正的a,伪代码如 下:
for i, v := range a' {//a' is copy from a
if i == 0 {
a[1] = 12
a[2] = 13
}
r[i] = v
}
Go中的数组在内部表示为连续的字节序列,虽然长度是Go数组类型的一部分,但长度并不包含的数组的内部表示中,而是由编译器在编译期计算出 来。这个例子中,对range表达式的拷贝,即对一个数组的拷贝,a'则是Go临时分配的连续字节序列,与a完全不是一块内存。因此无论a被 如何修改,其副本a'依旧保持原值,并且参与循环的是a',因此v从a'中取出的仍旧是a的原值,而非修改后的值。
我们再来试试pointer to array:
func pointerToArrayRangeExpression() {
var a = [5]int{1, 2, 3, 4, 5}
var r [5]int
fmt.Println("pointerToArrayRangeExpression result:")
fmt.Println("a = ", a)
for i, v := range &a {
if i == 0 {
a[1] = 12
a[2] = 13
}
r[i] = v
}
fmt.Println("r = ", r)
fmt.Println("a = ", a)
fmt.Println("")
}
这回的输出结果如下:
pointerToArrayRangeExpression result:
a = [1 2 3 4 5]
r = [1 12 13 4 5]
a = [1 12 13 4 5]
我们看到这次r数组的值与最终a被修改后的值一致了。这个例子中我们使用了*[5]int作为range表达式,其副本依旧是一个指向原数组 a的指针,因此后续所有循环中均是&a指向的原数组亲自参与的,因此v能从&a指向的原数组中取出a修改后的值。
idiomatic go建议我们尽可能的用slice替换掉array的使用,这里用slice能否实现预期的目标呢?我们来试试:
func sliceRangeExpression() {
var a = [5]int{1, 2, 3, 4, 5}
var r [5]int
fmt.Println("sliceRangeExpression result:")
fmt.Println("a = ", a)
for i, v := range a[:] {
if i == 0 {
a[1] = 12
a[2] = 13
}
r[i] = v
}
fmt.Println("r = ", r)
fmt.Println("a = ", a)
fmt.Println("")
}
pointerToArrayRangeExpression result:
a = [1 2 3 4 5]
r = [1 12 13 4 5]
a = [1 12 13 4 5]
显然用slice也能实现预期要求。我们可以分析一下slice是如何做到的。slice在go的内部表示为一个struct,由(*T, len, cap)组成,其中*T指向slice对应的underlying array的指针,len是slice当前长度,cap为slice的最大容量。当range进行expression复制时,它实际上复制的是一个 slice,也就是那个struct。副本struct中的*T依旧指向原slice对应的array,为此对slice的修改都反映到 underlying array a上去了,v从副本struct中*T指向的underlying array中获取数组元素,也就得到了被修改后的元素值。
slice与array还有一个不同点,就是其len在运行时可以被改变,而array的len是一个常量,不可改变。那么len变化的 slice对for range有何影响呢?我们继续看一个例子:
func sliceLenChangeRangeExpression() {
var a = []int{1, 2, 3, 4, 5}
var r = make([]int, 0)
fmt.Println("sliceLenChangeRangeExpression result:")
fmt.Println("a = ", a)
for i, v := range a {
if i == 0 {
a = append(a, 6, 7)
}
r = append(r, v)
}
fmt.Println("r = ", r)
fmt.Println("a = ", a)
}
输出结果:
a = [1 2 3 4 5]
r = [1 2 3 4 5]
a = [1 2 3 4 5 6 7]
在这个例子中,原slice a在for range过程中被附加了两个元素6和7,其len由5增加到7,但这对于r却没有产生影响。这里的原因就在于a的副本a'的内部表示struct中的 len字段并没有改变,依旧是5,因此for range只会循环5次,也就只获取a对应的underlying数组的前5个元素。
range的副本行为会带来一些性能上的消耗,尤其是当range expression的类型为数组时,range需要复制整个数组;而当range expression类型为pointer to array或slice时,这个消耗将小得多,仅仅需要复制一个指针或一个slice的内部表示(一个struct)即可。我们可以通过 benchmark test来看一下三种情况的消耗情况对比:
对于元素个数为100的int数组或slice,测试结果如下:
//details-in-go/5/arraybenchmark
go test -bench=.
testing: warning: no tests to run
PASS
BenchmarkArrayRangeLoop-4 20000000 116 ns/op
BenchmarkPointerToArrayRangeLoop-4 20000000 64.5 ns/op
BenchmarkSliceRangeLoop-4 20000000 70.9 ns/op
可以看到range expression类型为slice或pointer to array的性能相近,消耗都近乎是数组类型的1/2。
3)其他range expression类型
【string】
对string来说,由于string的内部表示为struct {*byte, len),并且string本身是immutable的,因此其行为和消耗和slice expression类似。不过for range对于string来说,每次循环的单位是rune(code point的值),而不是byte,index为迭代字符码点的第一个字节的position:
var s = "中国人"
for i, v := range s {
fmt.Printf("%d %s 0x%x\n", i, string(v), v)
}
输出结果:
0 中 0x4e2d
3 国 0x56fd
6 人 0x4eba
如果s中存在非法utf8字节序列,那么v将返回0xFFFD这个特殊值,并且在接下来一轮循环中,v将仅前进一个字节:
//byte sequence of s: 0xe4 0xb8 0xad 0xe5 0x9b 0xbd 0xe4 0xba 0xba
var sl = []byte{0xe4, 0xb8, 0xad, 0xe5, 0x9b, 0xbd, 0xe4, 0xba, 0xba}
for _, v := range sl {
fmt.Printf("0x%x ", v)
}
fmt.Println("\n")
sl[3] = 0xd0
sl[4] = 0xd6
sl[5] = 0xb9
for i, v := range string(sl) {
fmt.Printf("%d %x\n", i, v)
}
输出结果:
输出结果:
0xe4 0xb8 0xad 0xe5 0x9b 0xbd 0xe4 0xba 0xba
0 4e2d
3 fffd
4 5b9
6 4eba
以上例子源码在details-in-go/5/stringrangeexpression.go中可以找到。
以上例子源码在details-in-go/5/stringrangeexpression.go中可以找到。
【map】
对于map来说,map内部表示为一个指针,指针副本也指向真实map,因此for range操作均操作的是源map。
for range不保证每次迭代的元素次序,对于下面代码:
var m = map[string]int{
"tony": 21,
"tom": 22,
"jim": 23,
}
for k, v := range m {
fmt.Println(k, v)
}
输出结果可能是:
tom 22
jim 23
tony 21
或:
tony 21
tom 22
jim 23
或其他可能。
如果map中的某项在循环到达前被在循环体中删除了,那么它将不会被iteration variable获取到。
counter := 0
for k, v := range m {
if counter == 0 {
delete(m, "tony")
}
counter++
fmt.Println(k, v)
}
fmt.Println("counter is ", counter)
反复运行多次,我们得到的两个结果:
tony 21
tom 22
jim 23
counter is 3
tom 22
jim 23
counter is 2
如果在循环体中新创建一个map元素项,那该项元素可能出现在后续循环中,也可能不出现:
m["tony"] = 21
counter = 0
for k, v := range m {
if counter == 0 {
m["lucy"] = 24
}
counter++
fmt.Println(k, v)
}
fmt.Println("counter is ", counter)
执行结果:
tony 21
tom 22
jim 23
lucy 24
counter is 4
or
tony 21
tom 22
jim 23
counter is 3
以上代码可以在details-in-go/5/maprangeexpression.go中可以找到。
【channel】
对于channel来说,channel内部表示为一个指针,channel的指针副本也指向真实channel。
for range最终以阻塞读的方式阻塞在channel expression上(即便是buffered channel,当channel中无数据时,for range也会阻塞在channel上),直到channel关闭:
//details-in-go/5/channelrangeexpression.go
func main() {
var c = make(chan int)
go func() {
time.Sleep(time.Second * 3)
c <- 1
c <- 2
c <- 3
close(c)
}()
for v := range c {
fmt.Println(v)
}
}
运行结果:
1
2
3
如果channel变量为nil,则for range将永远阻塞。
引用来源:https://tonybai.com/2015/09/17/7-things-you-may-not-pay-attation-to-in-go/
如果您觉得阅读本文对您有帮助,请点一下“推荐”按钮,您的“推荐”将是我最大的写作动力!欢迎各位转载,但是未经作者本人同意,转载文章之后必须在文章页面明显位置给出作者和原文连接,否则保留追究法律责任的权利。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· Vue3状态管理终极指南:Pinia保姆级教程