Go 控制流:让程序学会做选择
到目前为止,我们写的程序都是"一条路走到黑"——从第一行开始,一行一行地往下执行,直到结束。但现实世界中的问题往往不是这么简单的。想想你每天早上的决策过程:如果下雨了,就带伞;如果没下雨,就不带。如果今天是工作日,就早起;如果是周末,就多睡一会儿。程序也需要这样的"判断力",这就是控制流(control flow)要做的事——让程序能够根据条件执行不同的代码,或者重复执行某段代码。
Go 语言在控制流方面的设计非常简洁。和很多语言不同,Go 只有 for 一种循环结构,没有 while、do-while。这让语言变得更简单,但你可能会担心:够用吗?放心,够用,而且非常好用。让我们一个一个来看。
if/else 条件判断
基本的 if
if 是最简单的条件判断语句:如果条件为真,就执行某段代码。
age := 20
if age >= 18 {
fmt.Println("你已经成年了")
}
注意几个关键细节:
- 条件不需要用括号包起来。在 C、Java、JavaScript 中,你需要写
if (age >= 18),但 Go 不需要。 - 花括号是必须的。即使只有一行代码,也不能省略花括号。Go 的设计者认为这样可以避免一些常见的 bug。
- 左花括号
{必须在if的同一行。这是 Go 的强制格式要求。
if-else 与 if-else if-else
当需要处理条件不满足的情况时,使用 else:
age := 15
if age >= 18 {
fmt.Println("你可以考驾照了")
} else {
fmt.Println("你还不能考驾照")
}
当有多个互斥条件时,使用 else if 链:
score := 85
if score >= 90 {
fmt.Println("优秀")
} else if score >= 80 {
fmt.Println("良好")
} else if score >= 60 {
fmt.Println("及格")
} else {
fmt.Println("不及格")
}
// 输出:良好
⚠️ 踩坑提示:Go 的 else 必须和对应的 if 所在行的右花括号在同一行,否则编译报错。这是 Go 强制格式化的一部分。
if 的初始化语句
这是 Go 语言的一个独特特性:你可以在 if 语句中添加一个初始化语句,用分号与条件隔开。
if score := 85; score >= 90 {
fmt.Println("优秀")
} else if score >= 80 {
fmt.Println("良好")
}
在初始化语句中声明的变量只在 if-else 的作用域内有效。一旦 if-else 执行完毕,这个变量就不存在了。这个特性在实际开发中特别实用,尤其处理函数返回值时:
if err := doSomething(); err != nil {
fmt.Println("出错了:", err)
}
// err 在这里已经不可见了,清理了变量污染
不需要在外面单独声明 err 变量,代码更加整洁。这是 Go 推崇的卫语句(guard clause)风格的最佳实践。
卫语句重构嵌套 if
当嵌套层级很深时,使用卫语句能让代码扁平化:
// ❌ 不推荐:嵌套过深
func processOrder(userID int, orderID int) error {
user, err := getUser(userID)
if err != nil {
return err
}
if user.IsActive {
order, err := getOrder(orderID)
if err != nil {
return err
}
if order.Status == "pending" {
if order.Amount > 0 {
return processPayment(order)
}
}
}
return nil
}
// ✅ 推荐:使用卫语句
func processOrder(userID int, orderID int) error {
user, err := getUser(userID)
if err != nil {
return err
}
if !user.IsActive {
return fmt.Errorf("用户未激活")
}
order, err := getOrder(orderID)
if err != nil {
return err
}
if order.Status != "pending" {
return fmt.Errorf("订单状态错误: %s", order.Status)
}
if order.Amount <= 0 {
return fmt.Errorf("订单金额异常")
}
return processPayment(order)
}
实战:根据时间问候
package main
import (
"fmt"
"time"
)
func main() {
hour := time.Now().Hour()
if hour < 6 {
fmt.Println("凌晨了,快去睡觉!")
} else if hour < 12 {
fmt.Println("早上好!新的一天开始了")
} else if hour < 14 {
fmt.Println("中午好!该吃午饭了")
} else if hour < 18 {
fmt.Println("下午好!继续加油")
} else if hour < 22 {
fmt.Println("晚上好!放松一下吧")
} else {
fmt.Println("夜深了,早点休息")
}
}
switch 多分支选择
当你有很多个条件需要判断时,写一长串 if-else if 会很丑陋。这时候 switch 就派上用场了。
基本的 switch
day := "Monday"
switch day {
case "Monday":
fmt.Println("星期一,新的一周开始了")
case "Tuesday":
fmt.Println("星期二")
case "Wednesday":
fmt.Println("星期三")
case "Thursday":
fmt.Println("星期四")
case "Friday":
fmt.Println("星期五,快到周末了!")
case "Saturday", "Sunday":
fmt.Println("周末!开心!")
default:
fmt.Println("未知的一天")
}
Go 的 switch 和其他语言的一个重要区别:Go 的 case 默认自带 break,不需要手动写。在 C 和 Java 中,每个 case 后面都需要写 break,否则会继续执行下一个 case(fall through)。Go 把这个反直觉的行为去掉了,让代码更安全。
带初始化表达式的 switch
switch hour := time.Now().Hour(); {
case hour < 6:
fmt.Println("凌晨")
case hour < 12:
fmt.Println("上午")
case hour < 18:
fmt.Println("下午")
default:
fmt.Println("晚上")
}
注意这里的分号 ; 是必需的,hour 变量只在 switch 作用域内有效。
无条件 switch
switch 后面什么都不写,等同于 switch true,可完美替代长串 if-else if:
switch {
case score >= 90:
grade = "A"
case score >= 80:
grade = "B"
case score >= 70:
grade = "C"
case score >= 60:
grade = "D"
default:
grade = "F"
}
相比 if-else if,switch 在条件分支较多时更优雅、更易读。建议在分支数超过 3 个时考虑使用 switch。
fallthrough 关键字
虽然默认不会 fall through,但如果你确实需要这种行为,可用 fallthrough 关键字:
num := 5
switch num {
case 5:
fmt.Println("num 是 5")
fallthrough
case 10:
fmt.Println("这行也会执行")
fallthrough
case 15:
fmt.Println("这行也会执行")
default:
fmt.Println("结束")
}
// 输出:
// num 是 5
// 这行也会执行
// 这行也会执行
⚠️ 踩坑提示:fallthrough 必须是 case 的最后一条语句,且无条件跳转到下一个 case,不再判断下一个 case 的条件。实际开发中 fallthrough 很少用到,如果你发现需要用它,先想想是否有更清晰的写法。
type switch
switch 还可以用来判断接口变量的具体类型:
func checkType(i any) {
switch v := i.(type) {
case int:
fmt.Printf("是整数: %d\n", v)
case string:
fmt.Printf("是字符串: %s\n", v)
case bool:
fmt.Printf("是布尔值: %v\n", v)
case []int:
fmt.Printf("是整数切片: %v\n", v)
case map[string]int:
fmt.Printf("是 map: %v\n", v)
default:
fmt.Printf("未知类型: %T\n", v)
}
}
checkType(42) // 是整数:42
checkType("hello") // 是字符串:hello
checkType([]int{1,2,3}) // 是整数切片:[1 2 3]
type switch 在处理 JSON 反序列化等场景中非常有用。更多关于接口和类型断言的细节,可以参考 Go 接口与隐式实现。
for 循环
Go 语言只有 for 一种循环结构,没有 while、do-while。但 Go 的 for 非常灵活,可以覆盖所有循环场景。
经典的 for 循环
for i := 0; i < 5; i++ {
fmt.Println("第", i+1, "次循环")
}
和 if 一样,条件表达式不需要括号。
只有条件的 for(相当于 while)
省略初始化语句和后置语句,for 就变成了 while:
n := 10
for n > 0 {
fmt.Println(n)
n--
}
fmt.Println("发射!")
这就是 Go 不需要 while 的原因——for 已经涵盖了它的功能。
无限循环
for {
fmt.Println("这个循环永远不会停止...")
// 记得用 break 或 return 退出
}
无限循环在服务端编程中很常见,比如一个服务器需要一直运行等待客户端连接。
break 退出循环
for i := 0; i < 100; i++ {
if i == 5 {
break // 当 i 等于 5 时退出循环
}
fmt.Println(i)
}
// 输出:0 1 2 3 4
continue 跳过当前迭代
for i := 0; i < 10; i++ {
if i%2 == 0 {
continue // 跳过偶数
}
fmt.Println(i)
}
// 输出:1 3 5 7 9
带标签的 break 和 continue
处理多层循环时,可以给循环加标签:
outer:
for i := 0; i < 3; i++ {
for j := 0; j < 3; j++ {
if i == 1 && j == 1 {
fmt.Println("跳出外层循环!")
break outer // 直接跳出外层循环
}
fmt.Printf("i=%d, j=%d\n", i, j)
}
}
这比用多个 break 配合标志变量更清晰。
range 遍历
range 是 Go 遍历集合数据的利器。它可用于字符串、数组、切片、map 和 channel。
range 遍历字符串
for i, ch := range "Hello" {
fmt.Printf("索引 %d: %c\n", i, ch)
}
// 索引返回的是字节位置,字符是 Unicode rune
for i, ch := range "你好世界" {
fmt.Printf("索引 %d: %c\n", i, ch)
}
// 索引跳跃——每个中文字符占 3 个字节
// 索引 0: 你
// 索引 3: 好
// 索引 6: 世
// 索引 9: 界
⚠️ 陷阱:如果用 for i := 0; i < len(str); i++ 遍历中文字符串,会得到乱码。range 是遍历字符串的正确方式。
range 遍历切片
fruits := []string{"苹果", "香蕉", "橙子"}
for i, fruit := range fruits {
fmt.Printf("%d: %s\n", i, fruit)
}
// ignore index
for _, fruit := range fruits {
fmt.Println(fruit)
}
// only index
for i := range fruits {
fmt.Println(i)
}
range 遍历 map
scores := map[string]int{"张三": 85, "李四": 92, "王五": 78}
for name, score := range scores {
fmt.Printf("%s 的成绩是 %d\n", name, score)
}
⚠️ 重要提醒:map 的遍历顺序是随机的!每次运行结果可能不同。若需要按 key 排序,必须先取 keys 再排序:
keys := make([]string, 0, len(scores))
for k := range scores {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s: %d\n", k, scores[k])
}
range 经典陷阱:变量复用
这是 Go 面试中最常考的控制流陷阱之一:
// ❌ 错误:所有 goroutine 看到的都是同一个变量
s := []string{"a", "b", "c"}
for _, v := range s {
go func() {
fmt.Println(v) // 可能全部输出 "c"
}()
}
// ✅ 正确:将变量传入闭包
for _, v := range s {
go func(v string) {
fmt.Println(v)
}(v)
}
这个陷阱在切片元素是指针时尤其致命,每个 goroutine 最终操作的可能都是最后一个元素。
range 遍历 Channel
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
for v := range ch {
fmt.Println(v) // 1 2 3,通道关闭后自动退出循环
}
⚠️ 注意:channel range 只有在通道被关闭后才会退出。如果没有 close,会发生死锁。这个模式与错误处理配合使用时,要特别注意通道关闭的时机。
range 与切片扩容陷阱
s := []int{1, 2, 3}
for i, v := range s {
// 在遍历时动态添加元素
s = append(s, v)
fmt.Println(i, v)
}
// 输出只有原来的 3 个元素,range 使用的是遍历时切片的副本
range 在循环开始时就已经确定迭代的元素个数和底层数组副本,后续对原切片的 append 不会影响当前遍历。但如果修改的是切片中存储的指针指向的数据,那变化会立刻反映在遍历中。
嵌套循环与常用算法模式
九九乘法表
package main
import "fmt"
func main() {
for i := 1; i <= 9; i++ {
for j := 1; j <= i; j++ {
fmt.Printf("%d×%d=%-4d", j, i, i*j)
}
fmt.Println()
}
}
冒泡排序(复习嵌套循环)
func bubbleSort(arr []int) {
n := len(arr)
for i := 0; i < n-1; i++ {
for j := 0; j < n-1-i; j++ {
if arr[j] > arr[j+1] {
arr[j], arr[j+1] = arr[j+1], arr[j]
}
}
}
}
goto:谨慎使用
Go 保留了 goto 语句,但大多数情况下不建议使用。不过在特定场景下(比如跳出多层嵌套循环),goto 反而更简洁:
for i := 0; i < len(matrix); i++ {
for j := 0; j < len(matrix[i]); j++ {
if matrix[i][j] == target {
goto found
}
}
}
fmt.Println("没找到")
return
found:
fmt.Println("找到了!")
原则:能用 break、continue、return 解决的问题,就不要用 goto。
defer 语句简介
defer 虽然不是严格的控制流语句,但它深刻影响着程序的执行顺序。defer 指定的函数调用会在当前函数返回前执行:
func processFile(filename string) error {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close() // 确保文件最终关闭
// ... 处理文件
return nil
}
多个 defer 语句按后进先出(LIFO)的顺序执行。defer 是 Go 中处理资源清理的惯用法,与错误处理配合得天衣无缝。
实战:猜数字游戏
package main
import (
"fmt"
"math/rand"
"time"
)
func main() {
rand.Seed(time.Now().UnixNano())
secret := rand.Intn(100) + 1
fmt.Println("=== 猜数字游戏 ===")
fmt.Println("我已经想好了 1 到 100 之间的一个数字")
fmt.Println("你有 7 次机会来猜它")
maxAttempts := 7
for attempt := 1; attempt <= maxAttempts; attempt++ {
fmt.Printf("\n第 %d 次猜测,请输入一个数字: ", attempt)
var guess int
fmt.Scan(&guess)
if guess < 1 || guess > 100 {
fmt.Println("请输入 1 到 100 之间的数字!")
attempt-- // 不合法输入不算次数
continue
}
switch {
case guess == secret:
fmt.Printf("恭喜你!答案就是 %d,你用了 %d 次就猜到了!\n", secret, attempt)
return
case guess < secret:
fmt.Println("太小了!再大一点")
case guess > secret:
fmt.Println("太大了!再小一点")
}
remaining := maxAttempts - attempt
if remaining > 0 {
fmt.Printf("你还有 %d 次机会\n", remaining)
}
}
fmt.Printf("\n很遗憾,次数用完了!答案是 %d\n", secret)
}
经典面试陷阱:For + Goroutine 闭包绑定
这是 Go 面试中最常考的 Go 语言陷阱题目之一,背后的本质是 range 变量复用:
// ❌ 错误:所有 goroutine 看到的都是同一个 i
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
// i 是外层循环变量,闭包捕获的是它的引用
fmt.Println(i) // 很可能全部输出 4
wg.Done()
}()
}
wg.Wait()
// ✅ 正确方案 1:将变量传入闭包参数
for i := 0; i < 5; i++ {
wg.Add(1)
go func(i int) {
fmt.Println(i) // 输出 0 1 2 3 4(顺序不确定)
wg.Done()
}(i)
}
// ✅ 正确方案 2:循环体内声明局部变量
for i := 0; i < 5; i++ {
i := i // 创建一个只属于本迭代的局部变量
wg.Add(1)
go func() {
fmt.Println(i)
wg.Done()
}()
}
这个陷阱不仅出现在 goroutine 闭包中,订阅回调函数时也常犯这个错误。只有在 Go 1.22+(for 循环变量每次迭代创建新变量)才从语言层面解决了这个问题,但了解其背后的原因依然非常重要。关于 Go 并发编程的更多细节,可以参考 Go 并发编程:Goroutine。
实战:FizzBuzz
经典面试算法题,综合练习控制流:
func main() {
for i := 1; i <= 100; i++ {
switch {
case i%3 == 0 && i%5 == 0:
fmt.Println("FizzBuzz")
case i%3 == 0:
fmt.Println("Fizz")
case i%5 == 0:
fmt.Println("Buzz")
default:
fmt.Println(i)
}
}
}
常见问题 FAQ
Q1: Go 没有 while 语句,这不会造成困扰吗?
不会。Go 的 for condition { ... } 完全等同于其他语言的 while 循环。Go 团队认为减少语法关键词数量可以让语言更统一、更易记。
Q2: if 初始化语句中的变量可以在 else 分支中使用吗?
可以。if 初始化语句中声明的变量在整个 if-else if-else 链中都有效,但在该控制流结构之外不可见。
Q3: switch 总结应该按什么顺序排列?
通用原则是将最具体的、最可能被命中的条件放在前面。对于输入校验类的 switch,通常也应该将最常见的 case 前置以提高可读性。default 分支一般放在最后。
Q4: 为什么 range 遍历 map 时顺序是随机的?
这是因为 Go 的 map 底层实现使用的是哈希表,遍历顺序由哈希值决定,而哈希值在每次运行时可能变化。这是 Go 的刻意设计,为了防止开发者依赖特定的遍历顺序。
Q5: for-select 通道退出时怎么优雅处理?
最优雅的方式是使用一个单独的 done 通道作为退出信号,配合 select 监听:case <-done: return。这比硬编码 break 更清晰。
Q6: fallthrough 能用在 type switch 中吗?
不能。fallthrough 只能用于基于常量和表达式的 switch 语句,type switch 本质上是对类型的模式匹配,不支持 fallthrough。
Q7: 如何避免 range 变量共享陷阱?
- 在 goroutine 闭包中通过参数传入变量;2. 在循环体内声明局部变量重新赋值;3. 对于切片/指针元素,复制到新变量再使用。
Q8: defer 语句和 return 的执行顺序是怎样的?defer 在函数返回前执行(确切地说,在 return 语句计算了返回值之后、函数真正返回之前执行)。多个 defer 按后进先出顺序执行。
Q9: Go 支持三元运算符吗?
不支持。Go 设计者认为三元运算符 ?: 往往导致嵌套过于复杂,降低代码可读性。使用简单的 if-else 更清晰。
Q10: 控制流调试有什么技巧?
- 善用
log.Printf打印关键控制分支的命中情况;2. 使用delve调试器设置条件断点;3. 对于并发场景,go vet可以检查部分常见控制流错误。
最佳实践
- if-else 嵌套不超过 3 层:使用卫语句提前返回,保持代码扁平化
- 优先使用 switch:当条件分支数超过 3 个时,switch 通常比 if-else if-else 更易读
- range 优于索引循环:遍历集合时始终用
range,除非确实需要索引操作 - 避免 fallthrough:真正需要 fallthrough 的场景极少,通常有更好的设计
- 谨慎使用 goto:99% 的情况下有更清晰的替代方案
- 始终给循环加退出条件:无限循环一定要包含 break、return 或 channel 退出机制
- defer 紧跟资源获取:
defer close()应该在open()之后立即书写,防止遗漏
延伸阅读
如果你对 Go 的控制流和语言基础有更多兴趣,强烈推荐继续阅读以下文章:
- Go 变量与类型详解 — 夯实数据类型的基础
- Go 函数与多返回值 — 掌握 Go 最核心的函数设计哲学
- Go 数组与切片 — 理解 range 底层的数据结构
- Go map 详解 — range 遍历 map 必知必会
- Go 接口与隐式实现 — type switch 与类型断言的核心依赖
- Go 错误处理 — if err != nil 的正确心态
- Go 并发编程:Goroutine — for-select 与 channel 的完美配合
- Go 指针详解 — 循环与地址操作的避坑指南
总结
今天我们学习了 Go 语言的控制流,主要内容包括:
- if/else:条件判断支持初始化语句,条件不加括号、花括号不能省略
- switch:多分支选择,默认自带 break,支持无条件 switch 和 type switch
- for:Go 唯一的循环结构,可以当 while 用,也可以无限循环
- range:遍历字符串、数组、切片、map 的利器,要注意变量复用陷阱
- break/continue:控制循环流程,配合标签可以跳转外层循环
- goto 和标签:仅在特定场景下使用,一般有更清晰的替代方案
- defer:确保函数结束前执行清理语句
Go 的控制流设计追求简洁统一。只有 for 一种循环、switch 默认 break、没有三元运算符,这些设计决策减少了你需要记住的规则数量。掌握这些控制结构,你就能写出清晰、高效的 Go 程序。
练习时间
- 打印 1-100 的素数:用嵌套循环实现
- 斐波那契数列:用 for 循环打印前 20 项
- 统计字符:给定一个字符串,统计字母、数字、空格和其他字符的数量
- 反转字符串:用 for + 索引实现中文安全的字符串反转(注意不能用字节索引)
- 三角形输出:用嵌套循环打印不同形状的三角形
我们下篇见!
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。