📚 Golang 教程系列

  1. 入门与基础类型
  2. 字符串、变量与常量
  3. 流程控制(本文)
  4. 集合类型
  5. 函数、指针与类型
  6. 结构体
  7. 接口
  8. 错误处理、并发与泛型
  9. 测试与工程实践
  10. 杂项:make 与 select
  11. 杂项:embed 与资源嵌入

Go 只有 for 一种循环,配合 if 和 switch 覆盖所有流程控制场景。

流程控制总览

流程控制决定代码"如何执行"。大多数语言提供 if/elseforwhiledo-whileswitchforeachtry/catch 等关键字,Go 则做了激进的"减法":

  • 只保留 ifforswitch 三个关键字(外加 select 用于并发,本篇不涉及)
  • 没有 whiledo-whilefor 一种形式全覆盖
  • 没有三元表达式 ? :if/else 是唯一的条件分支
  • switch 默认自动 break,避免 C 系语言里忘写 break 的经典 bug

本篇系统讲解 ifforrangeswitch 的全部用法,并深入 Go 1.22 的 range over int 与循环变量语义修正,同时剖析循环变量捕获、map 遍历顺序随机等高频陷阱。

Go 与其他语言流程控制关键字对比

能力GoC/C++/JavaPythonJavaScript/TSRust
条件分支if/elseif/else + ? :if/elif/elseif/else + ? :if/else
三元表达式无(x if cond else y
循环forfor/while/do-whilefor/whilefor/while/do-whilefor/while/loop
遍历for rangefor(:) / 范围 forfor infor of / forEachfor in
分支选择switchswitch无(match 3.10+)switchmatch
switch 默认行为自动 breakfallthrough(需手写 break)N/A自动 break自动 break

Go 的流程控制关键字数量是最少的之一(与 Rust 相当),但每种结构都支持初始化语句,表达力并不弱。

if 语句

Go 的 if 与 C 系语言的关键区别:条件不需要括号,但花括号是强制的。

基本语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
x := 10

if x > 5 {
fmt.Println("x 大于 5")
}

if x > 5 {
fmt.Println("x 大于 5")
} else {
fmt.Println("x 不大于 5")
}

// else if 链
if x < 0 {
fmt.Println("负数")
} else if x == 0 {
fmt.Println("零")
} else {
fmt.Println("正数")
}

⚠️ 注意:Go 强制要求左花括号 {if 在同一行。下面写法会编译报错:

1
2
3
4
if x > 5   // 编译错误:unexpected newline, expecting {
{
fmt.Println("yes")
}

这是编译器在词法分析阶段强制的规则,目的是避免 C/Java 中"悬挂 else"和分号自动插入带来的歧义。

初始化语句(if 的"隐藏武器")

Go 的 if 支持在条件前加一条初始化语句,语法为 if init; cond { ... }

1
2
3
4
5
6
7
// 在 if 中声明变量,作用域仅限于 if-else 块
if num := computeValue(); num > 100 {
fmt.Println("大值:", num)
} else {
fmt.Println("小值:", num)
}
// fmt.Println(num) // 编译错误:num 在此处不可见

核心价值是缩小变量作用域num 只在 if-else 块内有效,不污染外层。处理错误时尤其常见:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 典型用法:err 的作用域被限制在 if-else 内
if err := doSomething(); err != nil {
log.Println("失败:", err)
return
}
// err 在这里不可见,避免误用

// 对比:不使用初始化语句时,err 泄漏到外层
err := doSomething()
if err != nil {
log.Println("失败:", err)
return
}
// err 在这里仍然可见,容易误用

初始化语句中声明的变量,在整个 if-else 链(含 else ifelse)中都可见,但在结构之后不可见:

1
2
3
4
5
6
7
8
if v, err := parse(input); err != nil {
// err 可见
return err
} else {
// v 和 err 都可见
fmt.Println(v)
}
// v 和 err 在这里都不可见

⚠️ 注意:当初始化语句声明多个变量(如 v, err := ...)而条件只检查 err 时,v 只能在 else 分支里安全使用–进入 else 意味着 err == nil,此时 v 才有效。

变量作用域与遮蔽

Go 使用词法作用域,内层作用域可以"遮蔽"(shadow)外层同名变量,这在 if 初始化语句中经常出现:

1
2
3
4
5
x := 10
if x := 20; x > 15 { // 内层 x 遮蔽外层 x
fmt.Println(x) // 输出 20
}
fmt.Println(x) // 输出 10,外层 x 未被修改

遮蔽是隐蔽 bug 的来源之一。很多团队用 go vet-shadow 检查或 golangci-lintgovet 检查器捕捉无意的遮蔽:

1
2
3
4
5
6
7
func process() error {
err := doStep1()
if err := doStep2(); err != nil { // 内层 err 遮蔽外层
// 这里的 err 是 doStep2 的错误
}
return err // 返回的是 doStep1 的 err,而非 doStep2 的
}

提示:推荐在 if 初始化语句中使用不同的变量名,或不在嵌套结构中复用 err 名,避免遮蔽陷阱。

无三元表达式的设计取舍

Go 故意不提供三元表达式 cond ? a : b,官方理由:

  1. 可读性优先:三元表达式容易写出难以阅读的嵌套,如 x > 0 ? (y > 0 ? 1 : -1) : 0
  2. 避免歧义if/else 更明确,不会产生优先级问题
  3. 一致性:Go 倾向于"只有一种做事的方式"

替代方案是用 if/else

1
2
3
4
5
6
7
8
// 其他语言:sign := x > 0 ? 1 : -1
// Go:
var sign int
if x > 0 {
sign = 1
} else {
sign = -1
}

Go 1.21 起标准库 cmp 包提供 cmp.Or,简化"取第一个非零值"的场景:

1
2
3
4
5
import "cmp"

// 返回第一个非零值,类似 JS 的 value ?? fallback
port := cmp.Or(config.Port, 8080)
name := cmp.Or(input, "anonymous")

但这只适用于"零值即 falsy"的场景,并非真正的三元替代。

提示:某些库(如 lo)提供 If/Ternary 等泛型函数模拟三元运算,但函数参数会提前求值(不像三元表达式短路),无法完全替代。有副作用的表达式仍需 if/else

if 的底层原理与编译器优化

Go 编译器在 SSA(Static Single Assignment)阶段将 if 翻译为条件跳转指令,主要优化包括:

  • 常量折叠if true { ... } 中的死代码会被消除
  • 分支预测:Go 编译器本身不做 profile-guided 优化,但生成的机器码依赖 CPU 的分支预测
  • 短路径优化if cond { return } 形式的早期返回会被编译为高效的条件跳转

从汇编层面,if x > 5 大致编译为:

1
2
3
4
5
6
7
CMPQ    $5, AX        ; 比较 x 和 5
JLE else_label ; 如果小于等于,跳到 else
; if 分支代码
JMP end_label
else_label:
; else 分支代码
end_label:

for 语句:Go 唯一的循环

Go 把所有循环形式统一到 for 一个关键字下:没有 whiledo-whileforeach,全部用 for 的不同形式表达。

四种基本形式

按"初始化语句的有无"和"条件的有无"区分,for 有四种形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 形式 1:经典三段式(C 风格 for)
for i := 0; i < 5; i++ {
fmt.Println(i)
}

// 形式 2:类 while(只有条件,没有初始化和后置语句)
n := 10
for n > 0 {
n--
fmt.Println(n)
}

// 形式 3:无限循环(没有任何语句)
for {
if done() {
break
}
// ...
}

// 形式 4:range 形式(遍历集合)
for i, v := range slice {
fmt.Println(i, v)
}

经典三段式 for

语法为 for init; cond; post { ... },与 C 的 for(init; cond, post) 完全对应,但少了括号:

1
2
3
4
for i := 0; i < 5; i++ {
fmt.Println(i)
}
// 输出:0 1 2 3 4

三部分都可选:init 循环开始前执行一次(通常是短变量声明),cond 每次迭代前求值、为 false 时退出,post 每次迭代体后执行(通常是 i++)。

多变量并行迭代是实用特性:

1
2
3
4
// 双指针:i 从头走,j 从尾走
for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
s[i], s[j] = s[j], s[i] // 反转切片
}

注意:init 中用 := 声明,post 中必须用 = 赋值(变量已声明)。常见初学者错误:

1
2
3
// 错误:post 中不能用 := 重新声明
for i, j := 0, 10; i < j; i, j := i+1, j-1 { // 编译错误
}

执行顺序init -> cond(为真进入循环体)-> 循环体 -> post -> cond -> …。post 在循环体之后、下次条件检查之前执行,与 C/Java 一致。

1
2
3
4
5
for i := 0; i < 3; i++ {
fmt.Println("body:", i)
// i++ 在这里隐式执行
}
// 顺序:i=0 -> cond(0<3真) -> body -> i++(i=1) -> cond(1<3真) -> body -> i++(i=2) -> cond(2<3真) -> body -> i++(i=3) -> cond(3<3假) -> 退出

while 形式

省略 initpost,只保留条件,就变成类 while 循环:

1
2
3
4
5
6
// 等价于 while n > 0
n := 10
for n > 0 {
n -= 2
fmt.Println(n)
}

Go 用 for 代替 while 的理由:while 只是 for 的特例,多一个关键字只增加心智负担。

1
2
3
4
5
6
// 典型场景:读取直到 EOF
scanner := bufio.NewScanner(file)
for scanner.Scan() { // 类 while:条件为假时退出
line := scanner.Text()
process(line)
}

无限循环

省略所有部分,for {} 就是无限循环,等价于 while(true)

1
2
3
4
5
6
7
for {
msg := receive()
if msg == "quit" {
break
}
process(msg)
}

无限循环常用于:事件循环 / 主循环(服务器监听请求)、消费者循环(从 channel 持续读取)、重试逻辑(直到成功或超时)。

1
2
3
4
5
6
7
8
9
10
11
// 典型:goroutine 中的消费者循环
go func() {
for {
select {
case msg := <-ch:
handle(msg)
case <-ctx.Done():
return // 用 return 退出整个 goroutine
}
}
}()

提示:无限循环通常配合 breakreturncontext 退出。生产代码中务必确保有退出路径,否则 goroutine 会泄漏。

range 形式

for range 是 Go 的遍历语法,用于迭代数组、切片、map、字符串、channel 及整数(Go 1.22+)。它是最灵活也最易出错的循环形式,下文单独深入讲解。

for 与 break / continue / label

break 立即退出当前循环,continue 跳过本次迭代剩余部分、进入下一次迭代:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// break:找到第一个满足条件的就退出
for i := 0; i < 100; i++ {
if isPrime(i) {
fmt.Println("第一个质数:", i)
break
}
}

// continue:跳过偶数
for i := 0; i < 10; i++ {
if i%2 == 0 {
continue
}
fmt.Println(i) // 只输出奇数
}

label(标签) 用于跳出多层循环。Go 没有"跳出 N 层"的语法,但提供 label 机制:

1
2
3
4
5
6
7
8
9
10
11
12
// 在二维数组中查找目标值,找到后立即跳出所有循环
func find(matrix [][]int, target int) (int, int) {
outer:
for i := 0; i < len(matrix); i++ {
for j := 0; j < len(matrix[i]); j++ {
if matrix[i][j] == target {
break outer // 直接跳出外层循环
}
}
}
// break outer 跳到这里
}

label 语法是在循环前写 labelName:,然后在 breakcontinue 后跟 label 名:

  • break outer:跳到 outer 循环之后,完全退出外层循环
  • continue outer:跳到 outer 循环的下一次迭代
1
2
3
4
5
6
7
8
9
10
11
12
13
// continue outer:跳过外层当前迭代,进入下一次
for i := 0; i < 3; i++ {
outer:
for j := 0; j < 3; j++ {
for k := 0; k < 3; k++ {
if k == 1 {
continue outer // 跳到外层 j 的下一次循环
}
fmt.Printf("%d,%d,%d ", i, j, k)
}
}
fmt.Println()
}

提示:label 名惯例上用 outerinnerloop 等描述性名字。label 不污染变量作用域,只在 break/continue 时被查找。label 也能用于 goto,但 goto 在 Go 中极少使用且只能跳到同一函数内的标签,社区共识是用 break/continue + label 代替。

for 与其他语言循环的横向对比

场景GoPythonJavaJavaScriptRust
计数循环for i := 0; i < n; i++for i in range(n)for (int i=0; i<n; i++)for (let i=0; i<n; i++)for i in 0..n
while 循环for cond {}while cond:while (cond)while (cond)while cond
无限循环for {}while True:while (true)while (true)loop {}
遍历数组for i, v := range sfor i, v in enumerate(s)for (var v : arr)for (const v of arr)for (i, v) in s.iter().enumerate()
遍历 mapfor k, v := range mfor k, v in m.items()for (var e : map.entrySet())for (const [k,v] of m)for (k, v) in &m

Go 的 for range 与 Python 的 enumerate 最相似–都同时提供索引和值。Java 的增强 for 只提供值,需要索引时得手动计数。

range 遍历深度解析

for range 形式为 for k, v := range x { ... }kv 的含义随 x 类型变化。理解 range 的关键:不同被遍历类型,返回值语义不同

range 的返回值组合

被遍历类型第一个值(键)第二个值迭代次数
数组 / 切片索引 int元素值len(x)
字符串字节偏移 intrune(Unicode 码点)UTF-8 解码后的字符数
mapKV键值对数量(顺序随机)
channel收到的值 T(无第二个值)直到 channel 关闭
整数(Go 1.22+)计数 int(无第二个值)该整数值

range 数组与切片

第一个值是索引,第二个是元素值。两者都可省略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
nums := []int{10, 20, 30}

// 完整形式:索引 + 值
for i, v := range nums {
fmt.Printf("nums[%d] = %d\n", i, v)
}
// nums[0] = 10
// nums[1] = 20
// nums[2] = 30

// 只要索引
for i := range nums {
fmt.Println(i) // 0 1 2
}

// 只要值(用 _ 丢弃索引)
for _, v := range nums {
fmt.Println(v) // 10 20 30
}

// 都不要(仅执行循环体 len(nums) 次)
for range nums {
fmt.Println("tick")
}
// tick tick tick

range 拷贝语义:range 会拷贝被遍历的值。对于切片,range 在开始时记录长度,之后修改切片不影响迭代次数:

1
2
3
4
5
6
s := []int{1, 2, 3}
for i := range s {
s = append(s, 999) // 修改切片
fmt.Println(i)
}
// 输出:0 1 2(只迭代 3 次,因为 range 开始时长度是 3)

对于数组,range 会拷贝整个数组(值拷贝):

1
2
3
4
5
6
arr := [3]int{1, 2, 3}
for i, v := range arr {
arr[i] = 999 // 修改原数组
fmt.Println(v) // 仍然输出 1 2 3,因为 v 是拷贝的值
}
fmt.Println(arr) // [999 999 999]

⚠️ 注意:range 大数组会有拷贝开销。如果数组很大(如 [1000000]int),应遍历指针或切片:

1
2
3
4
5
6
7
8
var bigArr [1000000]int
// 避免:拷贝整个数组
// for i, v := range bigArr { ... }

// 推荐:遍历切片(切片头只有 24 字节)
for i, v := range bigArr[:] {
_ = i; _ = v
}

range 字符串(rune)

字符串的 range 遍历按 UTF-8 解码,每次返回一个 rune(Unicode 码点),第一个值是该 rune 起始的字节偏移:

1
2
3
4
5
6
7
8
9
10
11
12
s := "Hello你好"

for i, r := range s {
fmt.Printf("字节偏移 %d: %c (U+%04X)\n", i, r, r)
}
// 字节偏移 0: H (U+0048)
// 字节偏移 1: e (U+0065)
// 字节偏移 2: l (U+006C)
// 字节偏移 3: l (U+006C)
// 字节偏移 4: o (U+006F)
// 字节偏移 5: 你 (U+4F60) <- 占 3 字节
// 字节偏移 8: 好 (U+597D) <- 占 3 字节

"Hello你好" 的字节长度是 5 + 3 + 3 = 11,但 range 只迭代 7 次(5 个 ASCII + 2 个中文)。这是 range 字符串与按字节遍历的核心区别:

1
2
3
4
5
6
7
8
9
// 按字节遍历(不推荐,会破坏多字节字符)
for i := 0; i < len(s); i++ {
fmt.Printf("%c", s[i]) // 输出乱码,因为中文被拆成 3 个字节
}

// 按 rune 遍历(推荐)
for _, r := range s {
fmt.Printf("%c", r) // 正确输出每个字符
}

如果字符串包含无效 UTF-8 序列,range 会返回 U+FFFD(替换字符)并跳过 1 字节:

1
2
3
4
5
6
7
bad := "a\xffb"  // \xff 是无效 UTF-8
for i, r := range bad {
fmt.Printf("%d: %c\n", i, r)
}
// 0: a
// 1: � (U+FFFD)
// 2: b

range map

map 的 range 遍历返回键和值,但顺序随机(Go 故意的设计,下文详述):

1
2
3
4
5
6
7
8
9
10
m := map[string]int{
"apple": 5,
"banana": 3,
"cherry": 8,
}

for k, v := range m {
fmt.Printf("%s: %d\n", k, v)
}
// 顺序不确定,每次运行可能不同

只要键或值其一:

1
2
3
4
5
6
7
8
9
// 只要键
for k := range m {
fmt.Println(k)
}

// 只要值
for _, v := range m {
fmt.Println(v)
}

map 遍历顺序随机的原因:Go 运行时遍历 map 时从随机的桶和槽位开始,故意打乱顺序。官方理由:

  1. 防止依赖顺序:若 map 遍历有固定顺序,开发者会不自觉地依赖它,一旦实现变化就出 bug
  2. 安全考虑:可预测的遍历顺序可能被利用(如哈希碰撞攻击)
1
2
3
4
5
6
7
8
9
10
11
12
// 需要有序遍历,先取出键再排序
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s: %d\n", k, m[k])
}
// apple: 5
// banana: 3
// cherry: 8

参见:上例用 make([]string, 0, len(m)) 预分配切片容量避免扩容。make 的完整语义与陷阱见 杂项:make 与 select

range channel

channel 的 range 遍历会持续接收值,直到 channel 关闭

1
2
3
4
5
6
7
8
9
10
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch) // 必须关闭,否则 for range 会永久阻塞

for v := range ch {
fmt.Println(v)
}
// 1 2 3

⚠️ 注意:range channel 时,如果 channel 永不关闭,循环会永久阻塞,导致 goroutine 泄漏。生产者有责任在发送完毕后 close channel。channel 的 range 只有一个返回值(收到的值),当 channel 关闭且缓冲区为空时,循环自动结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 典型:fan-out / worker pool
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs { // 持续接收任务,直到 jobs 关闭
results <- j * j
}
}

func main() {
jobs := make(chan int, 10)
results := make(chan int, 10)

// 启动 3 个 worker
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}

// 发送任务
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs) // 重要:关闭 jobs,worker 的 for range 才能退出

// 收集结果
for r := 1; r <= 5; r++ {
fmt.Println(<-results)
}
}

range 的拷贝陷阱

range 的值变量 v元素的拷贝,修改它不影响原集合:

1
2
3
4
5
6
7
type User struct{ Name string }

users := []User{{"Alice"}, {"Bob"}}
for _, u := range users {
u.Name = "X" // 修改的是拷贝,不影响原 slice
}
fmt.Println(users) // [{Alice} {Bob}]

要修改原元素,必须通过索引:

1
2
3
4
for i := range users {
users[i].Name = "X" // 通过索引修改
}
fmt.Println(users) // [{X} {X}]

如果元素是指针,情况不同–拷贝的是指针,通过指针修改会生效:

1
2
3
4
5
users := []*User{{"Alice"}, {"Bob"}}
for _, u := range users {
u.Name = "X" // u 是指针的拷贝,但指向同一个对象
}
fmt.Println(users[0].Name) // X

Go 1.22 新特性:range over int

Go 1.22(2024 年 2 月发布)引入了 range over int,允许直接 range 一个整数:

1
2
3
4
5
6
7
8
9
10
// Go 1.22 之前:必须写三段式
for i := 0; i < 10; i++ {
fmt.Println(i)
}

// Go 1.22+:直接 range 整数
for i := range 10 {
fmt.Println(i)
}
// 输出:0 1 2 3 4 5 6 7 8 9

for i := range N 等价于 for i := 0; i < N; i++,迭代 N 次,i 从 0 到 N-1。

用法详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 只取计数
for i := range 5 {
fmt.Println(i) // 0 1 2 3 4
}

// 不需要计数,只执行 N 次
for range 3 {
fmt.Println("beep")
}
// beep beep beep

// 负数或零:不迭代
for i := range -1 { // 不执行
fmt.Println("never")
}
for i := range 0 { // 不执行
fmt.Println("never")
}

range 整数也可以带无类型常量:

1
2
3
4
const N = 5
for i := range N {
fmt.Println(i)
}

设计动机

range over int 的引入主要为了:

  1. 减少 boilerplatefor i := 0; i < N; i++ 是 Go 里最常见的模式之一,缩写为 for i := range N 更简洁
  2. 避免 off-by-one 错误:三段式容易写错(如 i <= Ni < N-1),range int 更安全
  3. 与 range over slice 一致for i := range Nfor i := range slice 语义一致–都是"迭代 N 次"

这也呼应了 Python 的 for i in range(N),让从 Python 转来的开发者更熟悉。

与其他 range 形式的统一

Go 1.22 还引入了 range over func(实验性,Go 1.23 正式合入),允许 range 一个函数,让自定义集合支持 range 遍历:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Go 1.23+:range over func(Set 迭代器)
func (s Set) All() func(yield func(int) bool) {
return func(yield func(int) bool) {
for v := range s {
if !yield(v) {
return
}
}
}
}

// 使用
for v := range s.All() {
fmt.Println(v)
}

本篇聚焦流程控制基础,range over func 属于高级特性,后续篇章会涉及。

性能考量

range over int 与三段式 for 在性能上完全等价。Go 编译器会将 for i := range N 编译为与 for i := 0; i < N; i++ 几乎相同的机器码,可放心在性能敏感代码中使用。

1
2
3
// 基准测试(示意)
// BenchmarkForThreePart-8 1000000000 0.3 ns/op
// BenchmarkRangeInt-8 1000000000 0.3 ns/op

Go 1.22 新特性:loopvar 语义修正

这是 Go 1.22 最具影响力的变化之一。Go 1.22 之前,for 循环变量在整个循环中只创建一次,每次迭代复用同一个变量。这导致经典陷阱:在循环内启动 goroutine 或闭包捕获循环变量时,所有闭包都引用同一个变量

历史陷阱(Go 1.21 及之前)

1
2
3
4
5
6
7
8
9
10
11
// Go 1.21 及之前:所有 goroutine 打印 3
var fns []func()
for i := 0; i < 3; i++ {
fns = append(fns, func() {
fmt.Println(i)
})
}
for _, fn := range fns {
fn()
}
// 输出:3 3 3(而非 0 1 2)

原因:i 只创建一次,三次迭代都复用同一个 i。闭包执行时,i 的值已是循环结束后的 3。

传统 workaround 是在循环内创建局部副本:

1
2
3
4
5
6
7
for i := 0; i < 3; i++ {
i := i // 创建局部副本,遮蔽外层 i
fns = append(fns, func() {
fmt.Println(i)
})
}
// 输出:0 1 2

i := i 看似无意义,实则关键–它在内层作用域声明了新的 i,闭包捕获的是这个新 i,每次迭代都独立。这个陷阱在 goroutine 中尤其常见:循环内 go func() { fmt.Println(i) }() 在 Go 1.21 会大概率全部打印 5。

Go 1.22 的修正

从 Go 1.22 开始,for 循环变量在每次迭代都重新创建。闭包和 goroutine 会捕获每次迭代独立的变量值:

1
2
3
4
5
6
7
8
9
10
11
// Go 1.22+:正确打印 0 1 2
var fns []func()
for i := 0; i < 3; i++ {
fns = append(fns, func() {
fmt.Println(i)
})
}
for _, fn := range fns {
fn()
}
// 输出:0 1 2

goroutine 版本也修复了:每个 goroutine 会打印自己的 i(顺序随机,但值正确)。这个变化对 for range 同样生效:

1
2
3
4
5
6
7
8
9
10
11
// Go 1.22+:range 变量也每次迭代独立
var fns []func()
for _, v := range []int{10, 20, 30} {
fns = append(fns, func() {
fmt.Println(v)
})
}
for _, fn := range fns {
fn()
}
// 输出:10 20 30

兼容性与迁移

这是破坏性变更,但 Go 团队通过 GODEBUG 环境变量提供向后兼容:

  • Go 1.22 默认启用新语义(loopvar=1
  • 设置 GODEBUG=loopvar=0 可恢复旧语义(仅用于排查问题)
  • 从 Go 1.22 起,go.mod 中的 go 1.22 指令决定是否启用新语义–若 go.mod 声明 go 1.21 或更早,则保留旧语义以保证兼容
1
2
3
4
// go.mod
module example.com/myapp

go 1.22 // 启用新 loopvar 语义

提示:若项目 go.mod 声明 go 1.22,就自动获得新语义。升级 Go 版本时,只要 go.modgo 指令同步升级,无需改代码。

迁移建议

  1. 升级后删除 i := i 的 workaround:Go 1.22+ 不再需要
  2. 检查是否依赖旧语义:极少数代码可能"故意"利用旧语义(如所有 goroutine 共享最后一个值),需显式改写
  3. 使用 go vet 检查:Go 1.22 的 vet 能检测出可能受影响的代码
1
2
3
4
5
6
7
8
9
10
// 这些在 Go 1.22+ 都可以删除 "i := i"
for i := 0; i < n; i++ {
// i := i // 删除这行
go process(i)
}

for _, item := range items {
// item := item // 删除这行
go process(item)
}

底层实现

Go 1.22 之前,循环变量在 SSA 中是一个 PHI 节点,每次迭代更新值。Go 1.22 之后,编译器在每次迭代开始时为循环变量分配新的变量,闭包捕获的是每次迭代的独立变量。

从汇编层面,Go 1.22 的循环大致变为:

1
2
3
4
5
6
7
8
loop_start:
; 每次迭代创建新的 i(在栈上分配新位置)
MOVQ counter, i_addr
; 循环体(闭包捕获 i_addr,每次迭代 i_addr 不同)
...
INCQ counter
CMPQ counter, N
JL loop_start

实际实现更复杂,涉及编译器对闭包捕获的逃逸分析,但核心思想是"每次迭代独立变量"。

switch 语句

Go 的 switch 与 C/Java 的关键区别:每个 case 默认自动 break,不会"穿透"到下一个 case。

基本语法

1
2
3
4
5
6
7
8
9
10
switch os := runtime.GOOS; os {
case "darwin":
fmt.Println("macOS")
case "linux":
fmt.Println("Linux")
case "windows":
fmt.Println("Windows")
default:
fmt.Println("Other:", os)
}

if 一样,switch 支持初始化语句:switch init; expr { ... }init 中声明的变量作用域限于整个 switch。

自动 break:Go 的安全默认

在 C/C++/Java 中,switch 的 case 默认会 fallthrough(除非显式写 break),导致大量"忘记 break"的 bug:

1
2
3
4
5
6
7
8
9
// C 语言:忘写 break 的经典 bug
switch (x) {
case 1:
printf("one");
// 忘了 break
case 2:
printf("two"); // x==1 时也会执行
break;
}

Go 反其道而行,默认不 fallthrough,每个 case 执行完就退出 switch:

1
2
3
4
5
6
7
8
switch x {
case 1:
fmt.Println("one")
// 自动 break,不会执行 case 2
case 2:
fmt.Println("two")
}
// x==1 时只输出 "one"

这避免了 C 系语言最常见的 switch bug,也无需每个 case 后写 break

多值匹配

一个 case 可以匹配多个值,用逗号分隔:

1
2
3
4
5
6
switch day {
case "Sat", "Sun":
fmt.Println("周末")
default:
fmt.Println("工作日")
}

比 C/Java 更简洁–C 里需写成 case 'Sat': case 'Sun':(利用 fallthrough)。

fallthrough:显式穿透

确实需要 fallthrough 时,用 fallthrough 关键字显式声明:

1
2
3
4
5
6
7
8
9
10
11
switch x {
case 1:
fmt.Println("一")
fallthrough
case 2:
fmt.Println("二")
fallthrough
case 3:
fmt.Println("三")
}
// x==1 时输出:一 二 三

fallthrough 的行为要点:

  1. 必须是 case 块的最后一条语句:不能在中间使用
  2. 无条件跳到下一个 case:即使下一个 case 的值不匹配,也会执行其代码
  3. 不能用在 type switch 中:类型 switch(switch v := x.(type))不允许 fallthrough
1
2
3
4
5
6
7
8
9
// fallthrough 是无条件的
switch 1 {
case 1:
fmt.Println("one")
fallthrough // 无条件跳到 case 2 的代码,不管值是否匹配
case 99:
fmt.Println("ninety-nine") // 会执行
}
// 输出:one ninety-nine

⚠️ 注意fallthrough 在实际代码中很少使用,主要用于模拟 C 风格 switch,或在"分层"逻辑时(如权限检查:低权限做的事高权限也要做)。大多数情况下,重构为多个独立 case 或提取函数更清晰。

无表达式 switch(替代 if-else 链)

switch 可以不写表达式,此时等价于 if-else if-else 链–每个 case 的条件是布尔表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
score := 85
switch {
case score >= 90:
fmt.Println("A")
case score >= 80:
fmt.Println("B")
case score >= 70:
fmt.Println("C")
case score >= 60:
fmt.Println("D")
default:
fmt.Println("F")
}

比冗长的 if-else if 链更整洁:

1
2
3
4
5
6
7
8
9
10
11
12
// 等价的 if-else 写法(更啰嗦)
if score >= 90 {
fmt.Println("A")
} else if score >= 80 {
fmt.Println("B")
} else if score >= 70 {
fmt.Println("C")
} else if score >= 60 {
fmt.Println("D")
} else {
fmt.Println("F")
}

无表达式 switch 在处理多个互斥条件时是 Go 推荐写法,还可配合初始化语句:

1
2
3
4
5
6
7
8
9
10
switch n := computeValue(); {
case n < 0:
handleNegative(n)
case n == 0:
handleZero()
case n > 100:
handleLarge(n)
default:
handleNormal(n)
}

switch 的执行流程与 break

switch 内部使用 break 可提前退出,但只退出当前 case(由于自动 break,很少需要显式 break)。break 在 switch 中的主要用途是配合循环–当 switch 在循环内部时,break 只退出 switch,不退出循环:

1
2
3
4
5
6
7
8
9
for i := 0; i < 10; i++ {
switch i {
case 5:
break // 只退出 switch,不退出 for
case 6:
// 要退出 for,需要 label
break outer // 假设有 outer: label
}
}

类型 switch 简介

Go 还有一种类型 switch,用于接口值的类型断言:

1
2
3
4
5
6
7
8
switch v := x.(type) {
case int:
fmt.Println("整数:", v)
case string:
fmt.Println("字符串:", v)
default:
fmt.Println("未知类型")
}

类型 switch 属于接口主题,本篇不展开。switch v := x.(type) 是唯一能在 case 中绑定类型化变量的 switch 形式。

switch 与其他语言对比

特性GoC/C++/JavaPython(match 3.10+)JavaScriptRust(match)
默认行为自动 breakfallthrough(需手写 break)自动 break自动 break自动 break
多值匹配case 1, 2:需要多个 case + fallthroughcase 1 | 2:case 1: case 2:1 | 2 =>
显式穿透fallthrough(无条件)默认行为不支持不支持不支持
无表达式 switch支持(switch {}不支持match _:不支持不支持
模式匹配无(类型 switch 除外)支持强大的模式匹配
条件 casecase x > 0:(无表达式 switch)不支持case _ if cond:不支持x if x > 0 =>

Go 的 switch 功能上比 C 强(多值匹配、无表达式形式),但比 Rust 的 match 或 Python 的 match 弱(无模式匹配、无解构),体现了 Go"够用就好"的哲学。

实战与最佳实践

场景 1:使用 if 初始化语句处理错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 推荐:err 作用域被限制
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}

var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}

if cfg.Port == 0 {
cfg.Port = 8080 // 默认值
}

return &cfg, nil
}

每处 err 都用 if err := ...; err != nil 限制作用域,避免外层 err 被误用。

场景 2:for range 实现批处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func processBatch(items []Item, batchSize int) {
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
batch := items[i:end]

// 处理一批
for _, item := range batch {
process(item)
}
}
}

// Go 1.22+ 可以用 range int 更简洁
func processBatchV122(items []Item, batchSize int) {
for i := range (len(items) + batchSize - 1) / batchSize {
start := i * batchSize
end := start + batchSize
if end > len(items) {
end = len(items)
}
batch := items[start:end]
for _, item := range batch {
process(item)
}
}
}

场景 3:switch 实现状态机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
type State int

const (
StateIdle State = iota
StateConnecting
StateConnected
StateDisconnecting
)

func (s State) String() string {
switch s {
case StateIdle:
return "idle"
case StateConnecting:
return "connecting"
case StateConnected:
return "connected"
case StateDisconnecting:
return "disconnecting"
default:
return fmt.Sprintf("unknown(%d)", s)
}
}

func handleEvent(state State, event string) State {
switch state {
case StateIdle:
switch event {
case "connect":
return StateConnecting
default:
return state // 忽略其他事件
}
case StateConnecting:
switch event {
case "connected":
return StateConnected
case "error":
return StateIdle
default:
return state
}
case StateConnected:
switch event {
case "disconnect":
return StateDisconnecting
case "data":
// 处理数据
return state
default:
return state
}
case StateDisconnecting:
switch event {
case "done":
return StateIdle
default:
return state
}
default:
return state
}
}

场景 4:label 跳出嵌套搜索

在嵌套数据结构中搜索时,return 往往比 label 更直接:

1
2
3
4
5
6
7
8
9
10
11
// 在二维网格中查找目标,找到立即返回
func searchGrid(grid [][]int, target int) (int, int, bool) {
for i, row := range grid {
for j, val := range row {
if val == target {
return i, j, true
}
}
}
return 0, 0, false
}

但需要跳出多层循环又不能直接 return 时(如循环后还有清理逻辑),label 更合适:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 多阶段验证,任一阶段失败就跳到清理
func validate(data *Data) error {
var err error

phase1:
if err = checkPhase1(data); err != nil {
break cleanup
}
if err = checkPhase2(data); err != nil {
break cleanup
}
// 更多阶段...
return nil

cleanup:
log.Println("validation failed:", err)
return err
}

提示:上例其实用 if err != nil 逐层返回更地道,label 在这里只是演示。label 真正不可替代的场景是"嵌套循环中需要跳出多层"。

场景 5:for range channel 实现管道

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 生成器:产生数据
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}

// 平方:消费数据,产生新数据
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in { // 持续接收,直到 in 关闭
out <- n * n
}
}()
return out
}

// 使用:chained pipeline
func main() {
c := generate(2, 3, 4)
out := square(c)

for r := range out { // 持续接收,直到 out 关闭
fmt.Println(r) // 4 9 16
}
}

场景 6:无表达式 switch 处理业务逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func calculateShipping(weight float64, distance float64, urgent bool) float64 {
switch {
case urgent && distance > 1000:
return weight * 5.0 + distance * 0.5
case urgent:
return weight * 3.0 + distance * 0.3
case distance > 1000:
return weight * 2.0 + distance * 0.2
case weight > 10:
return weight * 1.5 + distance * 0.1
default:
return weight * 1.0 + distance * 0.05
}
}

最佳实践总结

  1. 优先用 if 初始化语句:缩小变量作用域,尤其错误处理
  2. for range 优先于三段式:遍历集合时更安全(避免越界)
  3. Go 1.22+ 用 range int:简化计数循环
  4. switch 优于长 if-else 链:多分支时更清晰
  5. 谨慎使用 label:只在跳出多层循环时用
  6. 避免 fallthrough:除非有充分理由
  7. map 遍历不要依赖顺序:需要有序就先排序
  8. range 大数组用切片:避免拷贝开销

陷阱与避坑

陷阱 1:for-range 取地址陷阱(Go 1.22 前后差异)

这是 Go 最经典的面试题之一。Go 1.22 之前,range 的值变量是共享的,取地址会得到同一个地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Go 1.21 及之前:所有指针指向同一个值
type User struct{ Name string }

users := []User{{"Alice"}, {"Bob"}, {"Charlie"}}
ptrs := make([]*User, 0, len(users))

for _, u := range users {
ptrs = append(ptrs, &u) // 取 u 的地址
}
for _, p := range ptrs {
fmt.Println(p.Name)
}
// Go 1.21: Charlie Charlie Charlie(所有指针指向最后一个值)
// Go 1.22: Alice Bob Charlie(正确)

原因(Go 1.21 及之前):u 在整个循环中只有一个实例,每次迭代复用。&u 取的是这个唯一实例的地址,循环结束后 u 的值是最后一个元素。Go 1.22 修正:每次迭代创建新的 u&u 每次都不同。

Go 1.21 的 workaround

1
2
3
4
5
6
7
8
9
10
// 方法 1:通过索引取地址
for i := range users {
ptrs = append(ptrs, &users[i]) // 取原 slice 元素的地址
}

// 方法 2:创建局部副本
for _, u := range users {
u := u // 创建副本
ptrs = append(ptrs, &u)
}

提示:Go 1.22+ 不再有这个问题,但若代码需兼容 Go 1.21 及更早版本,仍需注意。检查 go.modgo 指令确定适用哪个语义。

陷阱 2:循环变量捕获与 goroutine

这是陷阱 1 的并发变体:循环内启动 goroutine 捕获循环变量,Go 1.21 及之前所有 goroutine 会共享同一个变量(bug 现象与修正见上文 loopvar 语义修正一节)。Go 1.21 的 workaround 有两种,传参最为推荐:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 方法 1:传参(推荐,Go 1.22+ 仍是好习惯)
for i := 0; i < 5; i++ {
go func(i int) {
fmt.Println(i)
}(i)
}

// 方法 2:局部副本
for i := 0; i < 5; i++ {
i := i
go func() {
fmt.Println(i)
}()
}

提示:传参让代码意图更明确,不依赖 loopvar 语义。很多团队即使在 Go 1.22+ 也保留传参风格。

陷阱 3:map 遍历顺序随机

1
2
3
4
5
6
7
m := map[string]int{"a": 1, "b": 2, "c": 3}

// 错误假设:期望按插入顺序遍历
for k, v := range m {
fmt.Println(k, v)
}
// 实际:顺序随机,每次运行可能不同

避坑:需要有序遍历时,先取键排序(写法见上文 range map 一节)。如需保持插入顺序的"有序 map",可用第三方库(如 orderedmap)或自己维护键切片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type OrderedMap struct {
keys []string
values map[string]int
}

func (om *OrderedMap) Set(k string, v int) {
if _, ok := om.values[k]; !ok {
om.keys = append(om.keys, k)
}
om.values[k] = v
}

func (om *OrderedMap) Range(f func(k string, v int) bool) {
for _, k := range om.keys {
if !f(k, om.values[k]) {
return
}
}
}

陷阱 4:range 遍历中修改切片

1
2
3
4
5
6
7
8
9
s := []int{1, 2, 3, 4, 5}

// 想删除所有偶数
for i, v := range s {
if v%2 == 0 {
s = append(s[:i], s[i+1:]...) // 修改切片
}
}
fmt.Println(s) // 结果可能不正确

原因:range 在开始时记录了切片长度,修改切片后长度变化,但 range 仍按原长度迭代,导致越界或跳过元素。

避坑:使用反向遍历或构建新切片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 方法 1:反向遍历(删除不影响未遍历的索引)
for i := len(s) - 1; i >= 0; i-- {
if s[i]%2 == 0 {
s = append(s[:i], s[i+1:]...)
}
}

// 方法 2:过滤到新切片
result := s[:0] // 复用底层数组
for _, v := range s {
if v%2 != 0 {
result = append(result, v)
}
}

// 方法 3:Go 1.21+ 用 slices.DeleteFunc
s = slices.DeleteFunc(s, func(v int) bool {
return v%2 == 0
})

陷阱 5:switch 的 fallthrough 误用

1
2
3
4
5
6
7
8
9
10
// 误以为 fallthrough 会检查下一个 case 的条件
switch x {
case 1:
fmt.Println("one")
fallthrough
case 2:
fmt.Println("two") // x==1 时会执行,即使 x != 2
case 3:
fmt.Println("three") // 不会执行,fallthrough 只跳到紧邻的下一个 case
}

原因fallthrough无条件跳到紧邻的下一个 case 代码体,不检查其值。需要"满足多个条件之一就执行同一段代码"时,用多值匹配(case 1, 2, 3:,写法见上文)而非 fallthrough。

陷阱 6:无限循环中的资源泄漏

1
2
3
4
5
6
7
8
9
10
// 错误:无限循环中没有退出机制
func leak() {
ch := make(chan int)
go func() {
for {
ch <- compute() // 如果没人接收,永久阻塞
}
}()
// 函数返回,goroutine 泄漏
}

避坑:用 context 或 done channel 控制生命周期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func worker(ctx context.Context) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for {
select {
case <-ctx.Done():
return // 优雅退出
case ch <- compute():
}
}
}()
return ch
}

陷阱 7:range 拷贝导致性能问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
type BigStruct struct {
data [1024]byte
// ... 很多字段
}

items := make([]BigStruct, 10000)

// 慢:每次迭代拷贝整个 BigStruct
for _, v := range items {
process(v) // v 是拷贝,每次拷贝 1KB
}

// 快:通过索引避免拷贝
for i := range items {
process(items[i]) // 传引用(如果 process 接收指针)
}

// 或:range 指针切片
ptrs := make([]*BigStruct, len(items))
for i := range items {
ptrs[i] = &items[i]
}
for _, p := range ptrs {
process(p) // p 是指针,拷贝成本低
}

提示:对于大结构体(超过 64 字节),range 的拷贝开销可能显著。可用 go test -bench 实测,或直接用索引遍历。

陷阱 8:for range 字符串的字节偏移

1
2
3
4
5
6
7
8
9
10
11
s := "你好世界"

// 误以为 i 是"字符索引"
for i, r := range s {
fmt.Printf("第 %d 个字符: %c\n", i, r)
}
// 实际输出:
// 第 0 个字符: 你
// 第 3 个字符: 好 <- 字节偏移,不是字符序号
// 第 6 个字符: 世
// 第 9 个字符: 界

原因:range 字符串的第一个值是字节偏移而非字符序号(详见 range 字符串一节)。中文字符在 UTF-8 中占 3 字节,所以偏移是 0, 3, 6, 9。需要字符序号时自己计数:

1
2
3
4
5
idx := 0
for _, r := range s {
fmt.Printf("第 %d 个字符: %c\n", idx, r)
idx++
}

本篇小结

Go 的流程控制体现了语言核心的设计哲学:用最少的语法元素覆盖最多的场景ifforswitch 三个关键字加上 rangebreakcontinuefallthroughlabel,构成了完整的工具箱。

  1. if 支持初始化语句缩小作用域;无三元表达式
  2. for 四种形式(三段式、while、无限、range)覆盖所有循环场景
  3. range 统一遍历各类集合,注意拷贝语义与返回值差异
  4. range over int(Go 1.22):for i := range N 简化计数循环
  5. loopvar(Go 1.22):每次迭代创建新变量,修复捕获陷阱
  6. switch 默认自动 break;fallthrough 显式穿透;无表达式 switch 替代 if-else 链
  7. 陷阱集中在循环变量捕获、map 顺序随机、range 拷贝语义、遍历时修改切片

掌握这些内容,就掌握了 Go 流程控制的全部基础。下一篇将进入集合类型–slice、map、channel 的底层结构与高级用法。