Blog

Go 流程控制:if、for 和 switch

Go 的流程控制只靠 if、唯一的循环关键字 for,以及默认不贯穿的 switch。本文讲解每种写法、Go 1.22 让每次迭代拥有独立循环变量的改动,以及带标签的 break。

Go 的流程控制关键字比大多数语言都少。有 if,有 switch,循环关键字只有一个:for。没有 while,没有 do,也没有 foreach。这些活儿都由不同形式的 for 来干。

本文逐一讲解每种形式,还有 breakcontinue、标签,以及 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 当成假,所以当 countint 时,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 写法。

注意,ageelse 分支里也能用。在简短语句里声明的变量,在整个 if 里都有效,包括每个 else ifelse

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,它有三种形式,涵盖了其他语言分给 forwhiledo 的工作。

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 循环。连条件也去掉,循环会一直跑下去,直到 breakreturn 让它停下。服务器的 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。讲包和模块的那一篇会介绍这个文件。闭包本身会在下一篇讲函数时正式展开。

breakcontinue 和标签

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,只会结束当前这一行,外层循环照样继续。

标签能解决这个问题。给外层循环起个名字,然后 breakcontinue 这个名字:

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 匹配时执行 defaultdefault 可写可不写,放在列表的任何位置都行。

在 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*2int 分支里合法,len(v)string 分支里合法。讲接口的那一篇会解释 any 到底是什么,以及什么时候适合用类型选择。

要点

  • if 可以以简短语句开头,if v, ok := f(); ok。这个变量在整个 ifelse 链里有效,出了这个范围就没有了。
  • for 是 Go 唯一的循环:三段式;只有条件时相当于 while 循环;什么都不写就是“死循环”;还有 for range,可遍历切片、字符串、map、通道和整数。
  • map 的遍历顺序是故意随机的。顺序重要时,用 slices.Sorted(maps.Keys(m)) 给键排序。
  • 从 Go 1.22 开始,每次迭代都有自己的循环变量,所以闭包看到的是自己那一轮的值。用哪条规则由 go.mod 里的 go 行决定。
  • breakcontinue 作用于最内层循环。要作用于外层循环,给它加标签。
  • switch 在第一个匹配的 case 之后就停下。可以在一个 case 里写多个值,或者用不带条件的 switch 代替 if-else 链。fallthrough 会不加检查地执行下一段代码。

Go 只有一种循环,switch 也会自己停下,所以要记的更少,出错的机会也更少。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。