《Go 语言编程入门》2.2 控制流与循环

本节用 TaskAPI 的 list 命令把 Go 的控制流走一遍:if 的初始化语句、for 的三种写法、range 遍历切片与 map 的差异、带标签的 break/continue,以及默认不穿透的 switch 的四种形态;最后把 os.Args 接到一个 switch 派发器上,让 add/list/toggle 真正能跑起来。

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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练