Go 控制流:让程序学会做选择

Go 控制流:让程序学会做选择 到目前为止,我们写的程序都是一条路走到黑——从第一行开始,一行一行地往下执行,直到结束。但现实世界中的问题往往不是这么简单的。想想你每天早上的决策过程:如果下雨了,就带伞;如果没下雨,就不带。如果今天是工作日,就早起;如果是周末,就多睡一会儿。

Go 控制流:让程序学会做选择

到目前为止,我们写的程序都是"一条路走到黑"——从第一行开始,一行一行地往下执行,直到结束。但现实世界中的问题往往不是这么简单的。想想你每天早上的决策过程:如果下雨了,就带伞;如果没下雨,就不带。如果今天是工作日,就早起;如果是周末,就多睡一会儿。程序也需要这样的"判断力",这就是控制流(control flow)要做的事——让程序能够根据条件执行不同的代码,或者重复执行某段代码。

Go 语言在控制流方面的设计非常简洁。和很多语言不同,Go 只有 for 一种循环结构,没有 whiledo-while。这让语言变得更简单,但你可能会担心:够用吗?放心,够用,而且非常好用。让我们一个一个来看。

if/else 条件判断

基本的 if

if 是最简单的条件判断语句:如果条件为真,就执行某段代码。

age := 20

if age >= 18 {
    fmt.Println("你已经成年了")
}

注意几个关键细节:

  1. 条件不需要用括号包起来。在 C、Java、JavaScript 中,你需要写 if (age >= 18),但 Go 不需要。
  2. 花括号是必须的。即使只有一行代码,也不能省略花括号。Go 的设计者认为这样可以避免一些常见的 bug。
  3. 左花括号 { 必须在 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 ifswitch 在条件分支较多时更优雅、更易读。建议在分支数超过 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 一种循环结构,没有 whiledo-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("找到了!")

原则:能用 breakcontinuereturn 解决的问题,就不要用 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 变量共享陷阱?

  1. 在 goroutine 闭包中通过参数传入变量;2. 在循环体内声明局部变量重新赋值;3. 对于切片/指针元素,复制到新变量再使用。

Q8: defer 语句和 return 的执行顺序是怎样的?
defer 在函数返回前执行(确切地说,在 return 语句计算了返回值之后、函数真正返回之前执行)。多个 defer 按后进先出顺序执行。

Q9: Go 支持三元运算符吗?
不支持。Go 设计者认为三元运算符 ?: 往往导致嵌套过于复杂,降低代码可读性。使用简单的 if-else 更清晰。

Q10: 控制流调试有什么技巧?

  1. 善用 log.Printf 打印关键控制分支的命中情况;2. 使用 delve 调试器设置条件断点;3. 对于并发场景,go vet 可以检查部分常见控制流错误。

最佳实践

  1. if-else 嵌套不超过 3 层:使用卫语句提前返回,保持代码扁平化
  2. 优先使用 switch:当条件分支数超过 3 个时,switch 通常比 if-else if-else 更易读
  3. range 优于索引循环:遍历集合时始终用 range,除非确实需要索引操作
  4. 避免 fallthrough:真正需要 fallthrough 的场景极少,通常有更好的设计
  5. 谨慎使用 goto:99% 的情况下有更清晰的替代方案
  6. 始终给循环加退出条件:无限循环一定要包含 break、return 或 channel 退出机制
  7. defer 紧跟资源获取defer close() 应该在 open() 之后立即书写,防止遗漏

延伸阅读

如果你对 Go 的控制流和语言基础有更多兴趣,强烈推荐继续阅读以下文章:

总结

今天我们学习了 Go 语言的控制流,主要内容包括:

  1. if/else:条件判断支持初始化语句,条件不加括号、花括号不能省略
  2. switch:多分支选择,默认自带 break,支持无条件 switch 和 type switch
  3. for:Go 唯一的循环结构,可以当 while 用,也可以无限循环
  4. range:遍历字符串、数组、切片、map 的利器,要注意变量复用陷阱
  5. break/continue:控制循环流程,配合标签可以跳转外层循环
  6. goto 和标签:仅在特定场景下使用,一般有更清晰的替代方案
  7. defer:确保函数结束前执行清理语句

Go 的控制流设计追求简洁统一。只有 for 一种循环、switch 默认 break、没有三元运算符,这些设计决策减少了你需要记住的规则数量。掌握这些控制结构,你就能写出清晰、高效的 Go 程序。

练习时间

  1. 打印 1-100 的素数:用嵌套循环实现
  2. 斐波那契数列:用 for 循环打印前 20 项
  3. 统计字符:给定一个字符串,统计字母、数字、空格和其他字符的数量
  4. 反转字符串:用 for + 索引实现中文安全的字符串反转(注意不能用字节索引)
  5. 三角形输出:用嵌套循环打印不同形状的三角形

我们下篇见!


参考资料:

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南