Go 的流程控制只靠 if、唯一的循环关键字 for,以及默认不贯穿的 switch。本文讲解每种写法、Go 1.22 让每次迭代拥有独立循环变量的改动,以及带标签的 break。
Go 的流程控制关键字比大多数语言都少。有 if,有 switch,循环关键字只有一个:for。没有 while,没有 do,也没有 foreach。这些活儿都由不同形式的 for 来干。
本文逐一讲解每种形式,还有 break、continue、标签,以及 Go 1.22 修复一个经典循环 bug 的改动。下面每个程序都在 Go 1.26 上跑过,输出直接从运行结果里复制。
if:条件不加括号,大括号必须写
Go 的 if 条件两边不加括号,而大括号必须写,哪怕只有一行。
package main
import "fmt"
func main() {
temp := 31
if temp > 30 {
fmt.Println("hot")
} else if temp > 15 {
fmt.Println("mild")
} else {
fmt.Println("cold")
}
}
输出:
hot
条件必须是 bool。Go 不会把 0、空字符串或 nil 当成假,所以当 count 是 int 时,if count { ... } 编译不过。你要写 if count > 0,把意思说清楚。
带简短语句的 if
Go 的 if 可以在条件之前先执行一条简短语句,中间用分号隔开。这种写法你会经常见到,因为 Go 代码就是这样在一行里查找某个东西并检查结果的:
package main
import "fmt"
func main() {
ages := map[string]int{"ana": 31, "ben": 0}
if age, ok := ages["ben"]; ok {
fmt.Println("ben is", age)
}
if age, ok := ages["cai"]; ok {
fmt.Println("cai is", age)
} else {
fmt.Println("no age for cai, got", age)
}
}
输出:
ben is 0
no age for cai, got 0
age, ok := ages["ben"] 先执行,然后 ok 作为条件。从 map 里读取不存在的键会得到零值,所以 “ben” 和 “cai” 的 age 都是 0。只有 ok 能告诉你:”ben” 确实存在,年龄是 0,而 “cai” 根本不在里面。讲 map 的那一篇会完整讲解这种 comma-ok 写法。
注意,age 在 else 分支里也能用。在简短语句里声明的变量,在整个 if 里都有效,包括每个 else if 和 else。
if 一结束,变量就没了:
package main
import "fmt"
func main() {
ages := map[string]int{"ana": 31}
if age, ok := ages["ana"]; ok {
fmt.Println("found", age)
}
fmt.Println(age)
}
构建失败:
./main.go:10:14: undefined: age
这正是使用简短写法的理由。变量只存在于用得着它的地方,不会泄漏到函数的其他部分,免得有人误读到过期的值。
for:唯一的循环关键字
Go 只有一个循环关键字 for,它有三种形式,涵盖了其他语言分给 for、while 和 do 的工作。
package main
import "fmt"
func main() {
// Three parts: init; condition; post.
var seen []int
for i := 0; i < 3; i++ {
seen = append(seen, i)
}
fmt.Println(seen)
// Condition only: this is Go's while loop.
n := 1
for n < 100 {
n *= 3
}
fmt.Println(n)
// No condition: loop until something breaks out.
tries := 0
for {
tries++
if tries == 4 {
break
}
}
fmt.Println("tries:", tries)
}
输出:
[0 1 2]
243
tries: 4
三段式就是 C 和 Java 里的那种,只是去掉了括号。去掉初始化和后置部分,就是 while 循环。连条件也去掉,循环会一直跑下去,直到 break 或 return 让它停下。服务器的 accept 循环、等待任务的 worker,都是这种光秃秃的 for。
在 Go 里,i++ 是语句,不是表达式。你不能写 x := i++,也没有 ++i。
用 for range 遍历切片和字符串
for range 循环逐个遍历集合里的元素,每一轮给你一个索引和一个值。
package main
import "fmt"
func main() {
fruits := []string{"fig", "kiwi", "plum"}
for i, f := range fruits {
fmt.Println(i, f)
}
prices := []int{10, 20, 30}
for _, p := range prices {
p *= 2
}
fmt.Println(prices)
for i := range prices {
prices[i] *= 2
}
fmt.Println(prices)
}
输出:
0 fig
1 kiwi
2 plum
[10 20 30]
[20 40 60]
不需要的部分用 _ 代替。Go 拒绝编译未使用的变量,所以 _ 就是在说“我是故意不要索引的”。
第二个循环是个值得见识一次的坑。p 是每个元素的副本,所以把 p 翻倍,切片里什么都不会变。要修改元素,就遍历索引,通过 prices[i] 写回去,像第三个循环那样。
遍历字符串得到的东西稍有不同:
package main
import "fmt"
func main() {
for i, r := range "héllo" {
fmt.Println(i, string(r))
}
}
输出:
0 h
1 é
3 l
4 l
5 o
索引从 1 直接跳到了 3。对字符串 range,遍历的是 rune(Unicode 码点),索引是每个 rune 起始位置的字节偏移。é 在 UTF-8 里占两个字节,所以下一个 rune 从第 3 个字节开始。讲字符串、字节和 rune 的那一篇会解释原因。
用 for range 遍历 map:顺序故意是随机的
遍历 map 会得到每个键和值,但 Go 不保证顺序,而且运行时会故意让每次循环的顺序都不一样:
package main
import "fmt"
func main() {
stock := map[string]int{"apples": 5, "bread": 2, "cheese": 7, "dates": 1}
for k, v := range stock {
fmt.Println(k, v)
}
}
某次运行输出:
cheese 7
apples 5
dates 1
bread 2
再运行一次,各行的顺序可能就变了。map 本身没有任何变化。每次对 map 做 range,都从一个随机选定的位置开始。
Go 团队加入这种随机性,是为了让程序没法悄悄依赖一个从未保证过的顺序。代码之所以能跑,是因为键“总是”按某个顺序出来,那等到哪天顺序变了,它就坏了。每次都打乱顺序,bug 就会在你的测试里暴露出来。
需要稳定的顺序时,就明确要求。maps.Keys 返回所有键,slices.Sorted 把它们收集成排好序的切片,然后遍历这个切片:
package main
import (
"fmt"
"maps"
"slices"
)
func main() {
stock := map[string]int{"apples": 5, "bread": 2, "cheese": 7, "dates": 1}
for _, k := range slices.Sorted(maps.Keys(stock)) {
fmt.Println(k, stock[k])
}
}
输出:
apples 5
bread 2
cheese 7
dates 1
这个版本每次输出都一样。讲 map 的那一篇会讲得更深入。
用 for range 遍历通道
对通道(channel)做 for range,会一直接收值,直到通道被关闭。
package main
import "fmt"
func main() {
jobs := make(chan string, 3)
jobs <- "resize"
jobs <- "upload"
jobs <- "notify"
close(jobs)
for job := range jobs {
fmt.Println("doing", job)
}
fmt.Println("channel closed, loop done")
}
输出:
doing resize
doing upload
doing notify
channel closed, loop done
这里只有一个循环变量,就是值。通道没有索引。如果没人关闭 jobs,循环会永远等着第四个值。本系列后面会专门用一篇讲通道。现在只要知道,读取通道就用 for range。
用 for range 遍历整数
从 Go 1.22 开始,for i := range n 从 0 数到 n,但不包括 n。
package main
import "fmt"
func main() {
var seen []int
for i := range 5 {
seen = append(seen, i)
}
fmt.Println(seen)
total := 0
for i := range 4 {
total += i
}
fmt.Println(total)
}
输出:
[0 1 2 3 4]
6
这和 for i := 0; i < 5; i++ 效果一样,但更不容易写错。没有能误写成 <= 的条件,也没有会忘记写的自增。现在这是循环固定次数的常规写法,本系列里会一直用到。
每次迭代都有自己的循环变量
从 Go 1.22 开始,for 循环每一轮都会得到循环变量的一份新副本。一旦有闭包捕获这个变量,这一点就很重要。
package main
import "fmt"
func main() {
var prints []func()
for i := 0; i < 3; i++ {
prints = append(prints, func() { fmt.Println(i) })
}
for _, p := range prints {
p()
}
}
输出:
0
1
2
第一个循环创建了三个小函数,每个都打印 i。它们要等到第二个循环才运行。那时第一个循环早已结束,可每个函数打印的仍然是它被创建时 i 的值。
这看起来理所当然。但在 Go 1.22 之前并非如此,同一个程序会打印 3 3 3。这曾是最常见的 Go bug 之一,当闭包是在循环里启动的 goroutine 时,坑得最狠。
用十岁孩子能懂的话说
想象你要分发三个封好的信封,每个信封外面都写着“待会儿打开,看看白板上的数字”。
旧版 Go 只有一块白板。你在上面写 0,递出一个信封,然后擦掉写 1,再写 2,再写 3。等朋友们终于打开信封抬头看白板时,看到的都是 3。
新版 Go 给每个信封配了一块自己的小白板,钉在信封里面。第一个信封里的白板写着 0,之后你做什么都碰不到它。
准确的说法
闭包不会复制它用到的变量,而是保留对这些变量的引用。所以关键在于到底有几个变量。
Go 1.22 之前,for 循环只声明一次变量,每次迭代更新的都是同一个变量。每个闭包引用的都是同一个 i,读到的都是它的最终值 3。
从 Go 1.22 开始,每次迭代都声明一个新变量。在三段式里,Go 会在执行后置语句之前,把上一轮的值复制到新变量里。每个闭包引用的是自己那个 i,那一轮结束后就没人再改它。for range 循环也适用同样的规则。
这个比喻的局限: 小白板并不是只读的。如果某一轮里的代码修改了 i,这一轮创建的闭包会看到这个修改。不再发生的,是后面的迭代覆盖它。
用哪条规则由 go.mod 决定
新行为取决于模块 go.mod 里的 go 行,而不是你安装的 Go 版本。用 Go 1.26 运行同一个程序,但放在 go.mod 写着 go 1.21 的模块里,得到的就是旧结果:
$ go run .
3
3
3
编译器是新的,但它为老模块保留了旧的循环语义,这样升级 Go 不会改变现有代码的行为。如果你把循环复制到老项目里,记得查一下它的 go.mod。讲包和模块的那一篇会介绍这个文件。闭包本身会在下一篇讲函数时正式展开。
break、continue 和标签
break 跳出最内层的循环,continue 跳到它的下一轮。
package main
import "fmt"
func main() {
var kept []int
for i := range 10 {
if i%2 == 1 {
continue
}
if i > 6 {
break
}
kept = append(kept, i)
}
fmt.Println(kept)
}
输出:
[0 2 4 6]
奇数遇到 continue,所以永远到不了 append。到 8 时循环遇到 break 结束,所以 8 也没有输出。
这两个关键字都只作用于直接包住它们的那个循环。遇到嵌套循环,这往往不是你想要的那个。比如你在网格里查找,想一找到就停下。在内层循环里写普通的 break,只会结束当前这一行,外层循环照样继续。
标签能解决这个问题。给外层循环起个名字,然后 break 或 continue 这个名字:
package main
import "fmt"
func main() {
grid := [][]int{
{1, 4, 7},
{2, 5, 8},
{3, 6, 9},
}
checked := 0
search:
for r, row := range grid {
for c, v := range row {
checked++
if v == 5 {
fmt.Println("found 5 at", r, c)
break search
}
}
}
fmt.Println("cells checked:", checked)
for r, row := range grid {
cells:
for _, v := range row {
if v%2 == 0 {
fmt.Println("row", r, "first even:", v)
break cells
}
}
}
}
输出:
found 5 at 1 1
cells checked: 5
row 0 first even: 4
row 1 first even: 2
row 2 first even: 6
break search 结束了外层循环,所以查找在检查 5 个格子后就停了,而不是查完全部 9 个。标签单独写一行,后面跟冒号,紧挨在它命名的循环之前。
第二个循环演示了给内层循环加标签。这里的 break cells 和普通的 break 效果完全一样。这样写合法但没意义,实际代码只给需要从更深处跳到的那个循环加标签。continue 也可以带标签,continue search 会跳到下一行。
Go 还有 goto,可以跳到同一函数里的某个标签,但除了生成的代码,你很少会遇到它。
switch:默认不贯穿
Go 的 switch 执行第一个匹配的 case 就停下。你不用在每个 case 末尾写 break。
package main
import "fmt"
func kind(day string) string {
switch day {
case "sat", "sun":
return "weekend"
case "mon", "tue", "wed", "thu", "fri":
return "weekday"
default:
return "not a day"
}
}
func main() {
for _, d := range []string{"sun", "wed", "moon"} {
fmt.Println(d, kind(d))
}
}
输出:
sun weekend
wed weekday
moon not a day
一个 case 可以列出多个值,用逗号隔开,只要其中一个匹配就算匹配。没有其他 case 匹配时执行 default。default 可写可不写,放在列表的任何位置都行。
在 C 和 Java 里,忘写 break 会让一个 case 直接执行到下一个 case 里。Go 把这一点反了过来,因为忘写 break 多数时候是 bug,很少是有意为之。
不带条件的 switch
关键字后面什么都不写的 switch,会把每个 case 当作布尔表达式来判断,这是写长串 if-else 最清爽的方式。
package main
import "fmt"
func grade(score int) string {
switch {
case score >= 90:
return "A"
case score >= 75:
return "B"
case score >= 50:
return "C"
default:
return "F"
}
}
func main() {
for _, s := range []int{95, 75, 60, 12} {
fmt.Println(s, grade(s))
}
}
输出:
95 A
75 B
60 C
12 F
case 从上到下检查,第一个为真的胜出。所以 95 得到的是 A 而不是 B,尽管 95 >= 75 也为真。case 要按从最具体到最宽泛的顺序排列。
switch 也可以像 if 一样带简短语句:switch n := len(items); { ... } 让 n 只在这个 switch 里有效。
fallthrough:真想贯穿时再用
fallthrough 关键字让一个 case 继续执行下一个 case 的代码。你必须明确写出来。
package main
import "fmt"
func main() {
level := 2
switch level {
case 3:
fmt.Println("send a page")
fallthrough
case 2:
fmt.Println("send an email")
fallthrough
case 1:
fmt.Println("write to the log")
}
}
输出:
send an email
write to the log
level 2 匹配了 case 2,打印之后贯穿到了 case 1。注意,fallthrough 不会检查下一个 case 的值,而是无条件执行下一段代码。以为它会继续匹配的人,往往就栽在这里。
fallthrough 必须是 case 里的最后一条语句,也不能用在最后一个 case 里。你很少会需要它。当一个问题看起来需要 fallthrough 时,用一个带多个值的 case 或者一个简短的 if,通常更好读。
初识类型选择
类型选择(type switch)根据值的动态类型来分支,而不是根据值本身。
package main
import "fmt"
func describe(x any) string {
switch v := x.(type) {
case int:
return fmt.Sprintf("an int, doubled is %d", v*2)
case string:
return fmt.Sprintf("a string of %d bytes", len(v))
case nil:
return "nothing at all"
default:
return fmt.Sprintf("something else: %T", v)
}
}
func main() {
fmt.Println(describe(21))
fmt.Println(describe("go"))
fmt.Println(describe(nil))
fmt.Println(describe(2.5))
}
输出:
an int, doubled is 42
a string of 2 bytes
nothing at all
something else: float64
any 表示“任意类型的值”。在每个 case 里,v 都是该 case 的类型,所以 v*2 在 int 分支里合法,len(v) 在 string 分支里合法。讲接口的那一篇会解释 any 到底是什么,以及什么时候适合用类型选择。
要点
if可以以简短语句开头,if v, ok := f(); ok。这个变量在整个if和else链里有效,出了这个范围就没有了。for是 Go 唯一的循环:三段式;只有条件时相当于 while 循环;什么都不写就是“死循环”;还有for range,可遍历切片、字符串、map、通道和整数。- map 的遍历顺序是故意随机的。顺序重要时,用
slices.Sorted(maps.Keys(m))给键排序。 - 从 Go 1.22 开始,每次迭代都有自己的循环变量,所以闭包看到的是自己那一轮的值。用哪条规则由
go.mod里的go行决定。 break和continue作用于最内层循环。要作用于外层循环,给它加标签。switch在第一个匹配的 case 之后就停下。可以在一个 case 里写多个值,或者用不带条件的switch代替 if-else 链。fallthrough会不加检查地执行下一段代码。
Go 只有一种循环,switch 也会自己停下,所以要记的更少,出错的机会也更少。