2.2 控制流与循环
Go 的控制流关键字少得出奇:只有 if、for、switch 三个,没有 while、没有 do-while、没有三元运算符。这不是功能缺失,而是刻意收敛——一种循环、一种分支,读代码时不需要在多种等价写法之间切换心智。本节就用 TaskAPI 的 list 命令把这些结构全部用一遍。
本节把 TaskAPI 推进到「list 命令能打印任务清单」:用
for range遍历[]Task,用if判断完成状态,用带标签的break提前退出,最后用switch把os.Args[1]分发到add/list/toggle三条分支上。程序第一次有了「按命令干活」的能力。
2.2.1 if:可以带初始化语句
Go 的 if 与别的语言基本一致,但多了两个特点。第一,条件不需要括号,花括号却必须写:
if len(tasks) > 0 {
fmt.Println("有任务")
}
第二,if 可以带一条初始化语句,该语句声明的变量只在 if 及其 else 分支内可见:
if n := len(tasks); n > 0 {
fmt.Printf("任务数: %d\n", n)
}
任务数: 3
这个设计在错误处理里非常有用,因为 Go 惯用「函数返回 (值, error)」:
if err := doSomething(); err != nil {
return err
}
err 的作用域被限制在这一个 if 里,不会污染外层,也避免了「上一步的 err 忘了重置」这类 bug。这是 Go 代码里出现频率最高的控制流形态之一,第 6 章讲错误处理时会反复见到。
if 也支持 else if 链,但 Go 不鼓励写太长的链——超过三层就该考虑 switch 或提前 return。
2.2.2 for:唯一的循环
Go 只有 for,但它能表达所有循环形态。三种写法:
// 1. 三段式(类 C 的 for)
sum := 0
for i := 0; i < 3; i++ {
sum += i
}
fmt.Println("sum:", sum) // sum: 3
// 2. 只留条件(等价于 while)
i := 0
for i < 2 {
i++
}
fmt.Println("i:", i) // i: 2
// 3. 什么都不写(无限循环)
// for {
// ...
// }
sum: 3
i: 2
注意第 1 种写法里的 i 作用域只在循环内,循环结束后就不可见;而第 2 种写法的 i 声明在循环外,循环后仍能访问。这个差别在把循环计数器当索引用时很重要。
Go 里没有 while,需要时写 for 条件 {} 即可。也没有 do-while,需要「至少执行一次」时把循环体抽成函数、再写 for 调用它。
2.2.3 range:遍历切片与 map
range 是最常用的遍历方式。对切片,它返回索引和元素:
for i, t := range tasks {
fmt.Printf("%d: %s done=%v\n", i, t.Title, t.Done)
}
0: 写第一章 done=true
1: 写第二章 done=false
2: 写第三章 done=false
只想要元素时用 _ 丢掉索引,只想要索引时直接写一个变量:
for _, t := range tasks {
fmt.Println(t.Title) // 只要元素
}
for i := range tasks {
fmt.Println(i) // 只要索引
}
这里的 _ 是空白标识符,它告诉编译器「我明确不要这个值」。Go 不允许声明了却不用的变量,但 _ 例外。
对 map,range 返回键和值,且顺序是随机的——这是 Go 刻意为之,防止程序依赖遍历顺序:
for id, t := range byID {
fmt.Println(id, t.Title)
}
需要稳定顺序时必须自己排序键,这一点在 3.2 map 与集合惯用法 展开。
还有一个经典陷阱:range 返回的 t 是元素的副本,改它不会影响原切片:
for _, t := range tasks {
t.Done = true // 改的是副本,原切片不变
}
for i := range tasks {
tasks[i].Done = true // 这样才能改到原切片
}
Go 1.22 起 for 循环变量是每次迭代新建的,所以在闭包里捕获 t 不再有旧版本的共享陷阱;但「改副本不影响原切片」这条依然成立。
2.2.4 break、continue 与标签
break 跳出当前循环,continue 跳过本次迭代,行为与多数语言一致。麻烦的是嵌套循环里想「跳出外层」:
outer:
for _, t := range tasks {
if t.Done {
fmt.Println("第一个已完成:", t.Title)
break outer
}
}
第一个已完成: 写第一章
标签写在循环之前、以 标识符: 的形式出现,break outer 就能一次性跳出被标记的那层循环。同理还有 continue outer,表示「跳到外层循环的下一次迭代」。标签很少用,但嵌套两层以上时它比布尔标志位干净得多。
2.2.5 switch:默认不穿透
Go 的 switch 有两个和 C/Java 不同的关键行为:
- 每个
case结束即自动跳出,不需要写break。忘了这一点的人常写多余的break,虽然不报错但很啰嗦。 - 想故意穿透时,要显式写
fallthrough,且它只能用在case的最后一行。
cmd := "list"
switch cmd {
case "add":
fmt.Println("新增任务")
case "list":
fmt.Println("列出任务")
case "toggle":
fmt.Println("切换状态")
default:
fmt.Printf("未知命令: %s\n", cmd)
}
列出任务
switch 也可以带初始化语句,把上例改写成:
switch cmd := os.Args[1]; cmd {
case "add":
// 这里能用 cmd
}
变量 cmd 只在 switch 块内可见,和 if 的初始化语句是同一种设计。
2.2.6 switch 的四种形态
switch 的灵活程度常被低估,它有四种常用形态:
| 形态 | 写法 | 典型用途 |
|---|---|---|
| 值匹配 | switch x { case 1: ... } | 枚举分发 |
| 多值匹配 | case 1, 2, 3: | 合并等价分支 |
| 无表达式 | switch { case x > 0: ... } | 替代长 if-else 链 |
| 类型匹配 | switch v := x.(type) | 类型断言,第 5 章展开 |
无表达式形态用得很多,它让条件判断更像一张规则表:
id := int64(2)
switch {
case id <= 0:
fmt.Println("非法 ID")
case id > 2:
fmt.Println("大 ID")
default:
fmt.Println("正常 ID")
}
正常 ID
这种写法比 if-else if-else 链可读性更好,尤其是条件较多时。注意它的每个 case 都是一个布尔表达式,从上到下匹配第一个为真的分支。
2.2.7 命令派发器:把 os.Args 接上 switch
现在把本节所有零件组装起来,写一个真正能跑的 cmd/taskapi/main.go:
package main
import (
"fmt"
"os"
)
type Task struct {
ID int64
Title string
Done bool
}
func main() {
tasks := []Task{
{ID: 1, Title: "写第一章", Done: true},
{ID: 2, Title: "写第二章"},
{ID: 3, Title: "写第三章"},
}
if len(os.Args) < 2 {
fmt.Println("用法: taskapi <add|list|toggle> [参数]")
os.Exit(2)
}
switch cmd := os.Args[1]; cmd {
case "add":
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "add 需要一个标题")
os.Exit(1)
}
fmt.Println("新增:", os.Args[2])
case "list":
for i, t := range tasks {
mark := " "
if t.Done {
mark = "x"
}
fmt.Printf("%d. [%s] #%d %s\n", i+1, mark, t.ID, t.Title)
}
case "toggle":
done := 0
for _, t := range tasks {
if t.Done {
done++
}
}
fmt.Printf("完成 %d / %d\n", done, len(tasks))
default:
fmt.Fprintf(os.Stderr, "未知命令: %s\n", cmd)
os.Exit(1)
}
}
跑起来看看:
GOTOOLCHAIN=go1.27.0 go run . list # 1. [x] #1 写第一章 / 2. [ ] #2 写第二章 / 3. [ ] #3 写第三章
GOTOOLCHAIN=go1.27.0 go run . toggle # 完成 1 / 3
GOTOOLCHAIN=go1.27.0 go run . add 写第四章 # 新增: 写第四章
注意退出码的行为:go run . 不带参数时程序 os.Exit(2),但 go run 本身只把非零退出码统一折叠成 1;换成 go build 后的二进制,才能拿到真实的 2:
GOTOOLCHAIN=go1.27.0 go build -o taskapi .
./taskapi; echo "exit=$?" # exit=2
./taskapi foo; echo "exit=$?" # exit=1
这个差别在 CI 里很关键:如果脚本要区分「用法错误」和「运行时错误」,必须用构建后的二进制,不能依赖 go run。
小结
- Go 只有
if、for、switch三种控制流,没有while、do-while、三元运算符。 if条件不写括号、花括号必须写,且可带初始化语句;if err := f(); err != nil是 Go 最常见的错误处理形态。for有三种写法(三段式、仅条件、无限),分别对应for、while、for(;;)。range遍历切片返回索引与元素副本,遍历 map 返回键值且顺序随机;改副本不影响原切片,要改原切片得用索引。break/continue可用标签指定目标循环,比布尔标志位清晰。switch默认不穿透,需要穿透要显式fallthrough;它有值匹配、多值匹配、无表达式、类型匹配四种形态。- 退出码由程序自己决定(2 = 用法错误,1 = 运行时错误),但
go run会把非零码折叠成 1,要拿真实码得用go build的产物。
派发器现在能分发命令了,但 add 只是打印一行字,toggle 也只是数数——真正的业务逻辑还没写成函数。下一节 2.3 函数、多返回值与 defer
会把 add、list、toggle 抽成三个函数,并讲清 Go 的多返回值、命名返回值与 defer 的执行时机。想回看 Task 字段类型的由来,见 2.1 变量、常量与基本类型
。
阅读导航:上一节:2.1 变量、常量与基本类型 · 下一节:2.3 函数、多返回值与 defer 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。