Go 控制流 for range 坑点详解

代码地址:go控制流

Go 控制流

这一章要记住什么

这一章主要讲四个点:

  • Go 的循环只有 for,没有 while
  • if 可以带初始化语句,变量作用域只在 if 内。
  • switch 默认每个 case 自动结束,不需要手写 break
  • for range 很方便,但要注意循环变量、切片长度、map 顺序这些细节。

1. if 和 if 初始化语句

代码里:

if localStr == "case3" {
	fmt.Println("into true logic")
} else {
	fmt.Println("into false logic")
}

这是最普通的条件判断。

更常见的是带初始化语句:

if num, ok := dic["apple"]; ok {
	fmt.Printf("apple num %d\n", num)
}

num, ok := dic["apple"] 先执行。

ok 表示这个 key 是否存在。

dic["apple"]
    |
    v
返回两个值:num, ok
    |
    v
ok == true  -> key 存在
ok == false -> key 不存在

总结一下

if 初始化; 条件 {} 很适合处理 map 查询、函数返回值检查这类场景。

初始化出来的变量只在这个 if 里有效。


2. switch

代码里:

switch localStr {
case "case1":
	fmt.Println("case1")
case "case2":
	fmt.Println("case2")
case "case3":
	fmt.Println("case3")
default:
	fmt.Println("default")
}

Go 的 switch 匹配到一个 case 后,默认不会继续往下执行。

localStr = "case3"
        |
        v
case1? no
case2? no
case3? yes
        |
        v
执行 case3,然后结束 switch

总结一下

Go 的 switch 默认自带 break 效果,不需要每个分支都写 break


3. for 的几种写法

代码注释里总结了三种:

for i := 0; i < 5; i++ {}

for condition {}

for {}

可以对应理解成:

传统 for:
初始化 -> 条件判断 -> 循环体 -> 后置语句

类似 while:
条件判断 -> 循环体

死循环:
一直执行,通常配合 break

总结一下

Go 只有 for 一个循环关键字,但它可以覆盖 C/C++ 里的 forwhile 和死循环。


4. for range 的循环变量

当前代码里重点放在闭包捕获循环变量:

var funcs []func()

for i := 0; i < 3; i++ {
	funcs = append(funcs, func() {
		fmt.Println(i)
	})
}

匿名函数捕获了外层的 i

在老版本 Go 中,循环变量可能复用同一块内存,所以最后执行这些函数时,容易拿到同一个最终值。

循环阶段:
i = 0 -> 保存函数,函数记住 i
i = 1 -> 保存函数,函数还是记住同一个 i
i = 2 -> 保存函数,函数还是记住同一个 i

执行阶段:
循环结束后 i 已经变了
多个函数读到同一个 i

常见修复方式:

for i := 0; i < 3; i++ {
	i := i
	funcs = append(funcs, func() {
		fmt.Println(i)
	})
}
每轮循环:
外层 i -> 拷贝出一个新的内层 i
匿名函数捕获的是这一轮自己的 i

总结一下

循环里创建闭包时,要确认闭包捕获的是不是每轮独立的变量。

i := i 或者通过函数参数传进去,是很常见的保护写法。


5. for range 的几个细节

代码注释里还提到了几个常见点:

  • range 遍历切片时,循环开始前会先确定长度。
  • range 得到的值变量是元素副本,改它不会改原切片。
  • range 遍历 map 的顺序是随机的。
  • range 遍历字符串时,拿到的是 rune,不是单纯的字节。
range slice:
每轮拿到 index 和 value
value 是元素副本

如果要改原切片:
用 slice[index] 修改

总结一下

range 很好用,但不要误以为它拿到的一定是原始元素本身。

需要修改原数据时,优先用索引。


易错点

  1. if num, ok := dic[key]; ok {} 里的 numok 只在 if 内有效。
  2. Go 的 switch 默认不会贯穿到下一个 case。
  3. for range 的 value 通常是副本。
  4. map 遍历顺序不固定。
  5. 循环里创建闭包时,要注意捕获变量的问题。

快问快答

Q1:Go 有 while 吗?

答:

没有。Go 只有 for,可以用 for condition {} 表达 while 的效果。

Q2:map 查询为什么常写 num, ok := dic[key]

答:

因为 value 的零值可能也是合法值,必须用 ok 判断 key 是否真的存在。

Q3:range 遍历切片时,修改 value 会影响原切片吗?

答:

不会。value 通常是元素副本。要改原切片,应该用索引修改 slice[i]

Q4:map 遍历顺序稳定吗?

答:

不稳定。不能依赖 map 的遍历顺序,需要稳定顺序时,先取 key 排序再遍历。


一句话总结

Go 控制流语法不多,但 if 初始化switch 默认 breakfor range 副本和闭包 这些细节很容易考。

for range 有什么注意的坑点?怎么解决?

1. 变量作用域的版本差异

arr := [2]int{1, 2}
res := []*int{}
for _, v := range arr {
    res = append(res, &v)
}

1.22 版本之前,整个循环中的 v 的地址不变->相当于一个公用垃圾桶,res中添加的都是同一个地址 &v,解引用打印出都是一样的 2

1.22 版本之后,每轮循环都会创建一个新的变量 v,->相当于一个新的一次性纸杯,这下打印的就是 1 2

为了兼容老版本:

  1. 引入同名或者异名的局部变量强制拷贝

    res2 := []*int{}
    for _, v := range arr {
        r := v // 或者写成显式的作用域遮蔽:v := v
        res2 = append(res2, &r)
    }
    
  2. 直接用索引访问原始内存地址

总结:

  • 老版本:整个循环从头到尾就一个变量,一个内存地址,每轮循环都往这个内存中塞入新值,新值覆盖旧值
  • 新版本:把变量声明放到循环体内部,每轮循环给一个全新的、独立的变量和内存地址,不会存在新值覆盖旧值的现象

2.循环动态切片追加的死循判定

v := []int{1, 2, 3}
// 在循环体内不断往切片里 append 元素,这个循环会演变成死循环吗?
for i := range v {
    v = append(v, i)
}

// 不会,在循环开始之前,记录当前切片的 cap,此时循环次数就是当前切片的 cap,次数锁死了

// 编译期展开的底层伪代码:
rangeSlice := v
length := len(rangeSlice) // 💡 重点:在循环开始前,长度就已经被锁死在临时的寄存器/栈变量中了!
for i := 0; i < length; i++ {
    v = append(v, i) // 后面修改的只是 v 的 len 属性,但控制循环次数的是固定好的 length!
}

3. 闭包捕获局部变量的逃逸分析

var funcs []func()
for i := 0; i < 3; i++ {
    funcs = append(funcs, func() {
        fmt.Println(i) // 匿名函数捕获了外部的 i
    })
}
for _, f := range funcs { f() }

1.22 版本之前,i 只有一块内存,闭包捕获的是同一个 i 的地址,最后打印三个 3 (第四轮,i加到3,循环不成立,退出)

1.22 版本之后,每轮 i 的地址相互隔离, 打印出 0 1 2

兼容老版本解法:

  1. 利用同名变量进行作用域遮蔽

    var funcs2 []func()
    for i := 0; i < 3; i++ {
        i := i // 💡 重点:在当前循环代码块的局部栈帧内,强行分配一个独立的同名变量
        funcs2 = append(funcs2, func() {
            fmt.Println(i) // 此时闭包捕获的是全新独立的、长在不同内存地址上的局部 i
        })
    }
    
  2. 使用函数的参数强制拷贝

    var funcs3 []func()
    for i := 0; i < 3; i++ {
        funcs3 = append(funcs3, func(val int) {
            fmt.Println(val)
        }(i))	// 立即执行func(val) 匿名函数,发生拷贝
    }
    

总结:

  • 闭包底层是一个包含函数指针+捕获变量引用的结构体
  • 1.22 版本前,所有结构体都抓住同一个 i 的地址,循环结束,i 存的是最新值,遍历执行匿名函数,都去了同一个堆内存上找值
  • 1.22 版本后,i 变量地址隔离,每个闭包抓到的地址是不同的

4. 迭代遍历的值拷贝和副本隔离机制

slice := []int{1, 2, 3}

for _, v := range slice {
    v *= 10 // ❌ 徒劳无功!它仅仅把那个叫 v 的临时副本乘以了 10
}

for _, v := range slice执行时,底层执行了一次值拷贝 v = slice[index],所以修改的是这个拷贝的副本,而不是slice本身

总结:在 for range 里直接修改迭代变量的值,原切片会变吗?为什么?

for range 展开后,遍历获取元素的方法是先通过索引拿到值,再通过内存复制把数据拷贝给vv拥有全新的、独立的栈内存,改动v影响不了原切片的数据

5. map的随机遍历和排序输出

dic := map[string]int{
    "a": 1,
    "b": 2,
    "c": 3,
}
for k, v := range dic { fmt.Printf("%s:%d ", k, v) }

每次遍历出来的顺序可能不一样

why?

for range map底层会转成调用mapiterinit函数初始化迭代器,使用了一个随机数种子来及挑选遍历的起始桶和桶内偏移量,是防止开发者依赖特定顺序导致代码在底层哈希扩容时发生错乱,所以不是物理随机,底层的数据存放是有序的,只是在代码层重新洗了一次牌

6. 字符串按照字节遍历和按照 Rune 遍历

str := "hello 世界"

// 🎯 核心特性一:for range 遍历字符串 (按 UTF-8 字符/rune 代码点解码)
// 如果遇到多字节的中文字符,它会自动向后嗅探,合并计算,绝不乱码。
fmt.Println("🔍 尝试 for range 遍历 (Rune 视角):")
for i, r := range str {
    // 注意看打印出的 index 下标:0,1,2,3,4,5(空),6('世'),9('界') 
    // 下标直接从 6 跳到了 9!因为一个 UTF-8 的“世”字在内存里足足占了 3 个字节!
    fmt.Printf("  index: %d, rune字符: %c\n", i, r)
}

// 🎯 核心特性二:常规经典 for 循环遍历 (纯粹的底层 Byte 物理裸内存字节流视角)
fmt.Println("\n🔍 尝试常规经典 for 循环遍历 (Byte 字节流视角):")
// len(str)拿到的是字符串在内存中的总字节数!
for i := 0; i < len(str); i++ {
    // 乱码横飞现场:中文的 3 个字节被拆开单独打印成了十六进制。根本拼不出完整的“世界”!
    fmt.Printf("  index: %d, byte裸字节(十六进制): %x\n", i, str[i])
}

为什么用 for range 遍历包含中文的字符串,它的 index 会不连续?

Go 默认是 UTF-8编码,英文占1字节,汉字占3字节,使用for range遍历,底层会调用decoderune函数,遇到中文编码时,会自动向后强行找3个字节,同时合并并解码成一个单独的rune代码点,下一次迭代的起始索引就跨越3个字节

总结

1. 变量作用域的版本差异

1.22 之前是一块公用内存轮番卸货,新值洗掉旧值;1.22 之后是每轮流水线各自独立打包,变量生而隔离。

2. 循环动态切片追加的死循环判定

控制循环次数的是启动那一刻被编译器‘拍照锁死’的临时长度,后面原切片再怎么追加扩容,都惊动不了早已经注定好的退场时刻。

3. 闭包捕获局部变量的逃逸分析

闭包在 1.22 之前抓到的是同一个‘变量的地址钱包’,循环结束后钱包里只剩最后的尾款;而 1.22 之后抓到的是每一轮‘当场结算的现金分身’。

4. 迭代遍历的值拷贝和副本隔离机制

for rangev 只是舞台上的一个临时‘替身’,通过内存复制接纳了原数据的投影,你对替身动刀,根本伤不到原切片的真身

5. map 的随机遍历和排序输出

底层数据在哈希桶里其实死死固定,但 Go 官方为了打消开发者对特定顺序的依赖幻想,故意在每次初始化迭代器时,都用一枚随机数种子重新洗了一次牌

6. 字符串按照字节遍历和按照 Rune 遍历

传统 for 循环是盲人摸象的‘单字节裸内存’视角,而 for range 则是内置了 UTF-8 解码器的‘多字节智能嗅探’字符视角

Go 的 for range 本质上是一个由编译器在前端自动展开的‘数据快照语法糖’。它在循环开始的刹那间,就已经通过‘浅拷贝’锁死了遍历目标的生命周期与边界;我们应对所有坑点的核心法门,就是始终保持清醒:你当前正在操作的,究竟是那张被编译器固化下来的‘历史快照’,还是时刻动态变化着的‘原始内存’

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值