【go语言控制语句常见的坑】


前言

该篇紧接上篇文章【聊一聊go语言中的控制语句】https://blog.youkuaiyun.com/qq_41705360/article/details/143082692
对go语言控制语句不了解的同学,建议先看看上篇。虽然Go只有一种for语句形式,但可能遇到的“坑”却并不少,这里列出一些典型的“坑”,来看看你有没有踩中吧!
在这里插入图片描述

1.1 循环变量重用

看一下下面代码:

func main() {
    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)
}

你预期的输出是什么呢?因为go协程运行的顺序不同,执行结果的顺序会有差别,但是结果是不是下面这样呢

0 1
4 5
1 2
2 3
3 4

实际输出是什么呢?执行一下,得到如下结果:

4 5
4 5
4 5
4 5
4 5

为什么会输出这个结果呢?我将上述代码做一个等价变换你就明白了:记住这个替换公式,要考的的哟,简称公式1

func main() {
    var m = []int{1, 2, 3, 4, 5}  
             
    {
      i, v := 0, 0
        for i, v = range m {
            go func() {
                time.Sleep(time.Second * 3)
                fmt.Println(i, v)
            }()
        }
    }

    time.Sleep(time.Second * 10)
}

我们看到:i, v两个变量不是在每次循环时重新声明,而是在整个循环过程中只定义了一份,这就是为何所有goroutine输出的都是“4 5”的原因。
正确的写法:因为go语言参数的传递都是值传递,所以方式1其实类似与方式2,在传递给协程i,v参数的时候,会重新拷贝一份。

var m = []int{1, 2, 3, 4, 5}
方式1for i, v := range m {
	go func(i, v int) {
			time.Sleep(time.Second * 3)
		fmt.Println(i, v)
	}(i, v)
}
方式2for i, v := range m {
	i := i
	v := v
	go func() {
		time.Sleep(time.Second * 3)
		fmt.Println(i, v)
	}()
}

1.2 range表达式副本

例1:我们再来看一段代码:

func main() {
    var a = [5]int{1, 2, 3, 4, 5}
    var r [5]int

    fmt.Println("original a =", a)

    for i, v := range a {
        if i == 0 {
            a[1] = 12
            a[2] = 13
        }
        r[i] = v
    }

    fmt.Println("after for range loop, r =", r)
    fmt.Println("after for range loop, a =", a)
}

在你的预期中,上面程序的输出结果是这样的:

original a = [1 2 3 4 5]
after for range loop, r = [1 12 13 4 5]
after for range loop, a = [1 12 13 4 5]

不过实际运行一下,你会看到真正的输出是这样的:

original a = [1 2 3 4 5]
after for range loop, r = [1 2 3 4 5]
after for range loop, a = [1 12 13 4 5]

究其原因,是因为参数range循环的是a的副本,我们用a’来表示,将上面代码等价变换为下面后,就更容易理解了:也记住这个替换公式哟 简称公式2

for i, v := range a' { //a'是a的一个值拷贝
    if i == 0 {
        a[1] = 12
        a[2] = 13
    }
    r[i] = v
}

这样变换后,我们知道for range遍历的是a的副本,对a的修改不会影响后续的遍历。因此,当使用数组、切片作为range后的待遍历的容器集合时,要十分小心。
如果想要对a的修改,影响到r呢,聪明的你肯定想到了,那我们就去a的地址呗,这样即使a’是a的副本,但是因为拷贝的是地址,所以a’和a指向的是同一个底层数组。

var a = [5]int{1, 2, 3, 4, 5}
var r [5]int

fmt.Println("original a =", a)

for i, v := range &a {
	if i == 0 {
		a[1] = 12
		a[2] = 13
	}
	r[i] = v
}

fmt.Println("after for range loop, r =", r)
fmt.Println("after for range loop, a =", a)

例2:再来看看下面这个例子

arr := [2]int{1, 2}
res := []*int{}
for _, v := range arr {
    res = append(res, &v)
}
//expect: 1 2
fmt.Println(*res[0],*res[1]) 
//but output: 2 2

通过查看go编译源码可以了解到, for-range其实是语法糖,内部调用还是for循环,初始化会拷贝带遍历的列表(如array,slice,map),然后每次遍历的v都是对同一个元素的遍历赋值。也就是说如果直接对v取地址,最终只会拿到一个地址,而对应的值就是最后遍历的那个元素所附给v的值。和上面的列子一样,我们使用公式1变形一下代码。上述代码等价与

arr := [2]int{1, 2}
res := []*int{}
{
 v:=0
for _, v := range arr {
    res = append(res, &v)
 }
}
//expect: 1 2
fmt.Println(*res[0],*res[1]) 
//but output: 2 2

如何正确的表达呢?

//使用局部变量
for _, v := range arr {
    //局部变量v替换了v,也可用别的局部变量名
    v := v 
    res = append(res, &v)
}

//这种其实退化为for循环的简写
for k := range arr {
    res = append(res, &arr[k])
}

例3:再来看看这个例子,应该输出什么呢?

v := []int{1, 2, 3}
for i := range v {
    v = append(v, i)
}
fmt.Println(v)

咋一看,好像代码有问题了,边遍历边append增加元素值?这岂不是死循环了?我们使用公式2转换成下述形式

v := []int{1, 2, 3} 
for i := range v' {
    v = append(v, i)
}
fmt.Println(v)

需要注意的是对v的拷贝只在range中(可以替换成v’方便理解),下面对v的操作 v = append(v, i)还是原来声明的v。
总结:在for k := range arr中,其中k只有一份地址,所有操作,都是对同一地址的k,arr也是拷贝。当你想不明白的时候,可以通过公式1公式2进行替换,就会豁然开朗啦。

arr:=[]int{1,2,3}
for i,k:=rang arr{
}
等价于
arr:=[]int{1,2,3}
{
i:=i
k:=k
for i,k:=rang arr'{
 }
}

例4:通过这么多例子,讲到了rang 的拷贝,那在来看看这个例子

//假设值都为1,这里只赋值3个
var arr = [102400]int{1, 1, 1} 
for i, n := range arr {
//just ignore i and n for simplify the example
    _ = i 
    _ = n 
}

这种方式有什么问题呢?因为arr是一个大的数组,遍历前的拷贝对内存是极大浪费。怎么优化?有两种,核心思想是只拷贝地址

1.对数组取地址遍历
for i, n := range &arr
2.对数组做切片引用
for i, n := range arr[:]

1.3对map的遍历?

对map的遍历时删除元素能遍历到么?

var m = map[int]int{1: 1, 2: 2, 3: 3}
//only del key once, and not del the current iteration key
var o sync.Once 
for i := range m {
    o.Do(func() {
        for _, key := range []int{1, 2, 3} {
            if key != i {
                fmt.Printf("when iteration key %d, del key %d\n", i, key)
                delete(m, key)
                break
            }
        }
    })
    fmt.Printf("%d%d ", i, m[i])
}

答案是【不会】map内部实现是一个链式hash表,为保证每次无序,初始化时会随机一个遍历开始的位置,这样,如果删除的元素开始没被遍历到(上边once.Do函数内保证第一次执行时删除未遍历的一个元素),那就后边就不会出现。
对map遍历时新增元素能遍历到么?

var m = map[int]int{1:1, 2:2, 3:3}
for i, _ := range m {
    m[4] = 4
    fmt.Printf("%d%d ", i, m[i])
}

答案是【可能会】,输出中可能会有44。原因同上一个, 可以用以下代码验证。

var createElemDuringIterMap = func() {
    var m = map[int]int{1: 1, 2: 2, 3: 3}
    for i := range m {
        m[4] = 4
        fmt.Printf("%d%d ", i, m[i])
    }
}
for i := 0; i < 50; i++ {
    //some line will not show 44, some line will
    createElemDuringIterMap()
    fmt.Println()
}
  1. 遍历到新增的元素: 有时候您可能会遍历到新增的元素,这是因为在遍历的过程中,某个键的哈希值对应的桶位置发生了变化,使得该键处于当前遍历的范围内。
  2. 遍历不到新增的元素: 有时候您可能会遍历不到新增的元素,这是因为在遍历的过程中,新增的元素的哈希值可能对应的桶位置不在当前遍历的范围内,或者新增的元素在遍历之前就被插入了,遍历过程中并未遍历到它。

1.4 break未跳出for

当for与switch语句联合使用时,也要注意避坑,看一下下面代码:

func main() {
    var sl = []int{5, 19, 6, 3, 8, 12}
    var firstEven int = -1

    // find first even number of the interger slice
    for i := 0; i < len(sl); i++ {
        switch sl[i] % 2 {
        case 0:
            firstEven = sl[i]
            break
        case 1:
            // do nothing
        }        
    }         
    println(firstEven) 
}

执行这个代码,输出结果为12,与我们预期的第一个偶数6不符。原因是什么呢?从输出结果为12来看,应该是break并未跳出for循环,导致循环继续进行到最后。
记住:Go语言规范中明确规定,不带label的break语句中断执行并跳出的,是同一函数内break语句所在的最内层的for、switch或select。所以,上面这个例子的break语句实际上只跳出了switch语句,并没有跳出外层的for循环,这也就是程序未按我们预期执行的原因。

总结

以上通过大量例子例举了for range中常见的坑,其实归根揭底只要我们牢记公式1与公式2,很多问题就疑难而解啦。谢谢您的阅读,欢迎点赞,收藏,评论,关注哟!后续将持续输出编码世界。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

花箫乱

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值