跳到主要内容

Go 控制流

控制流语句决定了程序的执行路径。Go 语言的控制在设计上追求简洁与明确,虽然关键字数量不多,但足以应对各种编程场景。本章将深入介绍 Go 的控制流机制,帮助你理解其设计理念并掌握最佳实践。

控制流概述

Go 的控制流语句与 C 语言家族相似,但有重要的简化:

  • 没有括号ifforswitch 的条件表达式不需要括号包围
  • 强制大括号:即使只有一条语句,也必须使用大括号
  • 统一的循环:只有 for 关键字,没有 whiledo-while
  • 自动 breakswitch 的每个 case 默认自动结束,无需手动 break

这些设计使得 Go 代码更加一致和可读,减少了风格争议。

if 语句

if 是最基本的条件判断语句,用于根据条件决定是否执行某段代码。

基本语法

if 条件表达式 {
// 条件为 true 时执行
}

关键规则

  1. 条件表达式必须是布尔类型,Go 不会自动将其他类型转换为布尔值
  2. 大括号 {} 是强制的,即使只有一条语句
  3. 左大括号 { 必须与 if 在同一行(这是 Go 分号插入规则的要求)

if-else 语句

score := 75

if score >= 60 {
fmt.Println("及格")
} else {
fmt.Println("不及格")
}

if-else if-else 链

score := 85

if score >= 90 {
fmt.Println("优秀")
} else if score >= 80 {
fmt.Println("良好")
} else if score >= 60 {
fmt.Println("及格")
} else {
fmt.Println("不及格")
}

初始化语句(if 的特色功能)

Go 的 if 语句支持在条件前执行一个初始化语句,这是 Go 的特色功能之一。初始化语句中声明的变量作用域仅限于 if-else 块内。

// 常见用法:在判断前获取某个值
if err := doSomething(); err != nil {
fmt.Println("发生错误:", err)
return err
}
// err 在这里不可访问

// 文件操作示例
if file, err := os.Open("data.txt"); err != nil {
log.Fatal(err)
} else {
defer file.Close()
// 使用 file...
}
// file 和 err 在这里不可访问

为什么这样设计?

初始化语句让变量的作用域更加精确。在上面的例子中,err 变量只存在于需要处理错误的代码块中,避免了变量污染外层作用域。这也是 Effective Go 中推荐的风格:

// 推荐风格:错误处理后立即返回
f, err := os.Open(name)
if err != nil {
return err
}
codeUsing(f)

// 而不是使用 else
f, err := os.Open(name)
if err != nil {
return err
} else {
codeUsing(f) // 嵌套了一层,不推荐
}

条件表达式的要求

Go 的条件表达式必须是布尔值,这与某些语言不同:

count := 10

// 错误:不能将整数作为条件
// if count { } // 编译错误:non-boolean condition

// 正确:必须显式比较
if count > 0 {
fmt.Println("有数据")
}

// 错误:不能将指针作为条件
// if ptr { } // 编译错误

// 正确:与 nil 比较
if ptr != nil {
fmt.Println("指针有效")
}

设计哲学:显式优于隐式。Go 要求明确的布尔表达式,避免了 "truthy/falsy" 值带来的混淆。

多条件组合

age := 25
hasLicense := true

// 逻辑与:两个条件都满足
if age >= 18 && hasLicense {
fmt.Println("可以驾驶")
}

// 逻辑或:满足任一条件
if age < 18 || !hasLicense {
fmt.Println("不能驾驶")
}

// 逻辑非
if !hasLicense {
fmt.Println("需要考取驾照")
}

短路求值

Go 的 &&|| 采用短路求值(short-circuit evaluation):

// 如果第一个条件为 false,第二个条件不会执行
if false && expensiveFunction() {
// expensiveFunction 不会被调用
}

// 如果第一个条件为 true,第二个条件不会执行
if true || expensiveFunction() {
// expensiveFunction 不会被调用
}

这一特性常用于避免空指针访问:

if ptr != nil && ptr.value > 0 {
// 先检查 ptr 不为 nil,再访问 ptr.value
// 如果 ptr 为 nil,ptr.value 不会被访问
}

常见错误与陷阱

错误一:大括号换行

// 错误:左大括号不能换行
if x > 0
{ // 编译错误!
doSomething()
}

// 正确:左大括号在同一行
if x > 0 {
doSomething()
}

这是因为 Go 的分号插入规则:如果在 if 后换行,编译器会自动插入分号,导致语法错误。

错误二:省略大括号

// 错误:Go 不允许省略大括号
if x > 0
doSomething() // 编译错误!

// 正确:必须有大括号
if x > 0 {
doSomething()
}

错误三:变量作用域混淆

x := 10

if x := 20; x > 5 { // 内层 x 遮蔽了外层 x
fmt.Println(x) // 20
}
fmt.Println(x) // 10(外层的 x)

最佳实践

  1. 优先使用初始化语句:限制变量作用域
  2. 错误优先返回:避免深层嵌套
  3. 条件要清晰:复杂条件可以提取为函数或变量
// 推荐:错误优先返回
func processFile(path string) error {
file, err := os.Open(path)
if err != nil {
return err // 错误立即返回
}
defer file.Close()

data, err := io.ReadAll(file)
if err != nil {
return err // 错误立即返回
}

return processData(data)
}

// 推荐:复杂条件提取
func isEligible(user User) bool {
return user.Age >= 18 &&
user.HasLicense &&
!user.IsSuspended
}

if isEligible(currentUser) {
// ...
}

switch 语句

switch 是多分支选择语句,比 if-else 链更清晰。Go 的 switch 比 C 语言更灵活。

基本语法

switch 表达式 {
case1:
// 处理逻辑
case2:
// 处理逻辑
default:
// 默认处理
}

基本 switch

day := 3

switch day {
case 1:
fmt.Println("星期一")
case 2:
fmt.Println("星期二")
case 3:
fmt.Println("星期三")
case 4:
fmt.Println("星期四")
case 5:
fmt.Println("星期五")
case 6, 7: // 多个值匹配
fmt.Println("周末")
default:
fmt.Println("无效的日期")
}

无表达式 switch(替代 if-else 链)

switch 后不跟表达式时,它等价于 switch true,每个 case 就是一个布尔表达式。这是 Go 中常用的惯用法:

score := 85

switch {
case score >= 90:
fmt.Println("优秀")
case score >= 80:
fmt.Println("良好")
case score >= 60:
fmt.Println("及格")
default:
fmt.Println("不及格")
}

为什么使用 switch 而不是 if-else 链?

  1. 更清晰:每个条件独立成行,结构分明
  2. 更易扩展:添加新条件只需增加 case
  3. 更易维护:条件的顺序和优先级一目了然

多值匹配

一个 case 可以匹配多个值:

char := 'a'

switch char {
case 'a', 'e', 'i', 'o', 'u':
fmt.Println("元音字母")
case 'A', 'E', 'I', 'O', 'U':
fmt.Println("大写元音字母")
default:
fmt.Println("其他字符")
}

带初始化语句的 switch

if 类似,switch 也支持初始化语句:

switch result := calculate(); result {
case 0:
fmt.Println("结果为零")
case 1, -1:
fmt.Println("结果为正负一")
default:
fmt.Printf("结果为: %d\n", result)
}

fallthrough:穿透到下一个 case

默认情况下,Go 的 switch 在匹配到一个 case 后会自动退出。如果需要继续执行下一个 case,使用 fallthrough

n := 1

switch n {
case 1:
fmt.Println("一")
fallthrough
case 2:
fmt.Println("二")
fallthrough
case 3:
fmt.Println("三")
default:
fmt.Println("其他")
}
// 输出:一、二、三
注意

fallthrough 不会检查下一个 case 的条件,而是无条件执行下一个 case 的代码块。这与其他语言的 switch 行为不同,使用时需谨慎。

类型 switch(Type Switch)

类型 switch 用于判断接口变量的动态类型:

func describe(v interface{}) {
switch val := v.(type) {
case nil:
fmt.Println("nil 值")
case bool:
fmt.Printf("布尔值: %t\n", val)
case int:
fmt.Printf("整数: %d\n", val)
case string:
fmt.Printf("字符串: %s\n", val)
case []int:
fmt.Printf("整数切片,长度: %d\n", len(val))
default:
fmt.Printf("未知类型: %T\n", val)
}
}

在类型 switch 中,v.(type) 只能在 switch 语句中使用。每个 case 分支中,val 会自动拥有对应的类型。

switch 与 break

虽然 Go 的 switch 默认会 break,但有时需要提前退出:

switch n := getValue(); n {
case 1:
if someCondition {
break // 提前退出 switch
}
// 其他逻辑
case 2:
// ...
}

switch 的最佳实践

  1. 替代复杂的 if-else 链:当有多个条件分支时优先使用 switch
  2. 合理分组:将相关的值放在同一个 case 中
  3. default 放在最后:虽然语法允许任意位置,但习惯放最后
// 判断 HTTP 状态码类型
func statusCategory(code int) string {
switch {
case code >= 200 && code < 300:
return "成功"
case code >= 300 && code < 400:
return "重定向"
case code >= 400 && code < 500:
return "客户端错误"
case code >= 500:
return "服务器错误"
default:
return "未知"
}
}

for 循环

Go 只有 for 一种循环关键字,但可以通过不同的写法实现多种循环形式。这种设计简化了语言,同时保持了足够的表达能力。

三种基本形式

// 形式一:标准 for(类似 C 语言)
for 初始化; 条件; 后置语句 {
// 循环体
}

// 形式二:类似 while
for 条件 {
// 循环体
}

// 形式三:无限循环
for {
// 循环体
}

标准 for 循环

// 打印 1 到 5
for i := 1; i <= 5; i++ {
fmt.Println(i)
}

// 累加求和
sum := 0
for i := 1; i <= 100; i++ {
sum += i
}
fmt.Println(sum) // 5050

执行顺序

  1. 执行初始化语句(仅一次)
  2. 检查条件,如果为 false 则退出
  3. 执行循环体
  4. 执行后置语句
  5. 回到步骤 2

省略部分语句

for 的三个部分都可以省略:

// 省略初始化和后置(类似 while)
i := 1
for i <= 5 {
fmt.Println(i)
i++
}

// 省略所有(无限循环)
for {
fmt.Println("无限循环")
if someCondition {
break
}
}

// 省略条件(相当于 while true)
for ; ; {
// 无限循环
}

多变量控制

Go 没有 C 语言的逗号表达式,但支持并行赋值:

// 反转切片
a := []int{1, 2, 3, 4, 5}
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
a[i], a[j] = a[j], a[i]
}
fmt.Println(a) // [5 4 3 2 1]

注意:i++i-- 在 Go 中是语句而非表达式,所以不能在 for 子句中使用。

Go 1.22: for range 整数迭代

Go 1.22 引入了一个便捷的新特性:for range 可以直接遍历整数。这为简单的计数循环提供了更简洁的写法:

// 传统写法
for i := 0; i < 5; i++ {
fmt.Println(i)
}

// Go 1.22+ 新写法
for i := range 5 {
fmt.Println(i) // 输出: 0, 1, 2, 3, 4
}

// 从 0 到 n-1
for i := range 10 {
fmt.Println(10 - i) // 倒计时: 10, 9, 8, ..., 1
}
fmt.Println("go1.22 has lift-off!")

工作原理

for i := range n 等价于 for i := 0; i < n; i++,迭代值从 0 到 n-1。

实际应用

// 重复执行 N 次
for range 3 {
fmt.Println("Hello") // 打印 3 次
}

// 创建序列
nums := make([]int, 0, 10)
for i := range 10 {
nums = append(nums, i*i) // 0, 1, 4, 9, ..., 81
}

// 并行处理固定数量的任务
var wg sync.WaitGroup
for i := range 5 {
wg.Add(1)
go func(id int) {
defer wg.Done()
processTask(id)
}(i)
}
wg.Wait()

注意事项

  • 遍历的是 [0, n) 区间,不包含 n
  • n 可以是任何整数类型(int、int32 等)
  • 如果 n <= 0,循环体不会执行
// 这些循环不会执行
for range 0 {
fmt.Println("不会执行")
}

for i := range -5 {
fmt.Println(i) // 不会执行
}

break 和 continue

// break:完全退出循环
for i := 1; i <= 10; i++ {
if i == 5 {
break // 退出循环
}
fmt.Println(i) // 输出 1, 2, 3, 4
}

// continue:跳过本次迭代,继续下一次
for i := 1; i <= 5; i++ {
if i == 3 {
continue // 跳过 3
}
fmt.Println(i) // 输出 1, 2, 4, 5
}

嵌套循环

// 九九乘法表
for i := 1; i <= 9; i++ {
for j := 1; j <= i; j++ {
fmt.Printf("%d×%d=%-2d ", j, i, i*j)
}
fmt.Println()
}

// 查找二维数组中的元素
matrix := [][]int{
{1, 2, 3},
{4, 5, 6},
{7, 8, 9},
}
target := 5
found := false

for i := 0; i < len(matrix); i++ {
for j := 0; j < len(matrix[i]); j++ {
if matrix[i][j] == target {
fmt.Printf("找到 %d,位置:[%d][%d]\n", target, i, j)
found = true
break
}
}
if found {
break
}
}

标签(Label)与 break/continue

当需要在嵌套循环中跳出外层循环时,可以使用标签:

OuterLoop:
for i := 0; i < 3; i++ {
for j := 0; j < 3; j++ {
if i == 1 && j == 1 {
break OuterLoop // 跳出外层循环
}
fmt.Printf("(%d, %d)\n", i, j)
}
}
// 输出:(0, 0), (0, 1), (0, 2), (1, 0)

// continue 也可以使用标签
NextIteration:
for i := 0; i < 3; i++ {
for j := 0; j < 3; j++ {
if i == 1 && j == 1 {
continue NextIteration // 跳到外层循环的下一次迭代
}
fmt.Printf("(%d, %d)\n", i, j)
}
}

for 循环的最佳实践

  1. 优先使用 range:遍历集合时使用 for range
  2. 避免在条件中调用函数:可能导致性能问题
// 不推荐:每次迭代都调用 len
for i := 0; i < len(slice); i++ {
// ...
}

// 推荐:预先计算长度
n := len(slice)
for i := 0; i < n; i++ {
// ...
}

// 更推荐:使用 range
for i, v := range slice {
// ...
}

range 循环

for range 是遍历集合的惯用方式,它可以遍历数组、切片、字符串、映射和通道。

基本语法

for 索引,:= range 集合 {
// 循环体
}

遍历切片/数组

nums := []int{10, 20, 30, 40, 50}

// 获取索引和值
for index, value := range nums {
fmt.Printf("索引: %d, 值: %d\n", index, value)
}

// 只需要值
for _, value := range nums {
fmt.Println(value)
}

// 只需要索引
for index := range nums {
fmt.Println(index)
}

// 只遍历(不关心索引和值)
for range nums {
fmt.Println("迭代一次")
}

遍历字符串:Unicode 码点

range 遍历字符串时,每次迭代返回一个 Unicode 码点(rune),而不是字节:

str := "Hello, 世界"

for index, char := range str {
fmt.Printf("字节索引: %d, 字符: %c, Unicode: %U\n", index, char, char)
}
// 输出:
// 字节索引: 0, 字符: H, Unicode: U+0048
// 字节索引: 1, 字符: e, Unicode: U+0065
// ...
// 字节索引: 7, 字符: 世, Unicode: U+4E16
// 字节索引: 10, 字符: 界, Unicode: U+754C

// 注意:中文字符在 UTF-8 中占 3 个字节
// "世" 的索引是 7,"界" 的索引是 10(7+3=10)

理解字节索引与字符位置的区别

str := "世界"

// 字节长度
fmt.Println(len(str)) // 6(每个中文字符 3 字节)

// 字符数量
fmt.Println(utf8.RuneCountInString(str)) // 2

// range 返回的是码点,不是字节
for i, r := range str {
fmt.Printf("字节索引: %d, 码点: %c\n", i, r)
}
// 字节索引: 0, 码点: 世
// 字节索引: 3, 码点: 界

遍历映射

ages := map[string]int{
"张三": 25,
"李四": 30,
"王五": 28,
}

// 遍历键值对
for name, age := range ages {
fmt.Printf("%s: %d岁\n", name, age)
}

// 只遍历键
for name := range ages {
fmt.Println(name)
}

// 只遍历值
for _, age := range ages {
fmt.Println(age)
}
注意

Map 的遍历顺序是随机的,Go 运行时会随机选择一个起始位置。如果需要固定顺序,需要先排序键:

// 按键名排序遍历
keys := make([]string, 0, len(ages))
for k := range ages {
keys = append(keys, k)
}
sort.Strings(keys)

for _, k := range keys {
fmt.Printf("%s: %d\n", k, ages[k])
}

遍历通道

range 可以遍历通道,直到通道关闭:

ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch) // 必须关闭,否则会死锁

for value := range ch {
fmt.Println(value)
}
// 输出:1, 2, 3

range 的常见陷阱

陷阱一:值是副本

range 返回的值是集合元素的副本,修改它不会影响原集合:

nums := []int{1, 2, 3}

for _, v := range nums {
v *= 2 // 只修改了副本
}
fmt.Println(nums) // [1 2 3],原切片未变

// 正确的修改方式
for i := range nums {
nums[i] *= 2
}
fmt.Println(nums) // [2 4 6]

陷阱二:闭包捕获

在循环中使用 goroutine 时,需要注意变量捕获。这个问题的行为在 Go 1.22 前后有所不同:

Go 1.22 之前

循环变量在整个循环中只创建一次,每次迭代更新同一个变量。这意味着闭包捕获的是同一个变量的引用:

// Go 1.22 之前:所有 goroutine 可能都打印相同的值
for _, v := range []int{1, 2, 3} {
go func() {
fmt.Println(v) // v 是同一个变量的引用
}()
}
// 可能输出: 3, 3, 3(或 2, 3, 3 等不确定结果)

解决方案(Go 1.22 之前)

// 方式一:将值作为参数传递
for _, v := range []int{1, 2, 3} {
go func(n int) {
fmt.Println(n)
}(v)
}

// 方式二:创建新变量
for _, v := range []int{1, 2, 3} {
v := v // 创建新的局部变量
go func() {
fmt.Println(v)
}()
}

Go 1.22 及之后

Go 1.22 修复了这个问题。现在 for 循环的每次迭代都会创建新的变量,而不是复用同一个变量:

// Go 1.22+:每次迭代创建新变量,闭包捕获问题已解决
for _, v := range []int{1, 2, 3} {
go func() {
fmt.Println(v) // 每个 goroutine 捕获不同的 v
}()
}
// 输出: 1, 2, 3(顺序可能不同)

兼容性说明

  • 如果你的代码需要兼容 Go 1.21 及更早版本,仍应使用参数传递或创建新变量的方式
  • 如果只支持 Go 1.22+,可以直接在闭包中使用循环变量
  • 使用 go vet 可以检测 Go 1.22 之前的潜在问题

陷阱三:遍历时修改集合

在遍历映射时添加或删除元素的行为是未定义的:

m := map[int]int{1: 1, 2: 2, 3: 3}

// 危险:遍历时删除元素
for k := range m {
if k == 1 {
delete(m, k) // 删除当前键是安全的
}
// delete(m, k+1) // 删除其他键可能导致问题
}

// 安全的方式:先收集要删除的键
toDelete := make([]int, 0)
for k, v := range m {
if v < 0 {
toDelete = append(toDelete, k)
}
}
for _, k := range toDelete {
delete(m, k)
}

goto 语句

goto 语句可以无条件跳转到同一个函数内的标签。虽然现代编程通常避免使用 goto,但在某些场景下它能简化代码。

基本语法

func example() {
// 一些代码...

goto Label

// 这段代码会被跳过
fmt.Println("不会执行")

Label:
fmt.Println("跳转到这里")
}

合理使用场景

场景一:错误处理和清理

func processFile(path string) error {
var file *os.File
var data []byte
var err error

file, err = os.Open(path)
if err != nil {
goto Error
}

data, err = io.ReadAll(file)
if err != nil {
goto Error
}

err = processData(data)
if err != nil {
goto Error
}

file.Close()
return nil

Error:
if file != nil {
file.Close()
}
return err
}

场景二:跳出多层循环

func findValue(matrix [][]int, target int) (int, int, bool) {
for i := 0; i < len(matrix); i++ {
for j := 0; j < len(matrix[i]); j++ {
if matrix[i][j] == target {
return i, j, true
}
}
}
return -1, -1, false
}

// 使用 goto 的版本
func findValueGoto(matrix [][]int, target int) (int, int, bool) {
var foundI, foundJ int
var found bool

for i := 0; i < len(matrix); i++ {
for j := 0; j < len(matrix[i]); j++ {
if matrix[i][j] == target {
foundI, foundJ, found = i, j, true
goto Found
}
}
}
return -1, -1, false

Found:
return foundI, foundJ, found
}

goto 的限制

goto 只能跳转到同一个函数内的标签,且不能跳过变量声明:

// 错误:跳过变量声明
goto End
x := 10 // 编译错误:goto jumps over declaration
End:

// 正确:将变量声明放在大括号内
goto End
{
x := 10 // OK,在独立的作用域内
}
End:

使用建议

  1. 优先使用其他控制结构:break、continue、return 通常更清晰
  2. 保持跳转距离短goto 应该在视线范围内
  3. 只在必要时使用:如复杂的清理逻辑

控制流最佳实践

错误优先返回

// 推荐:错误立即返回,避免嵌套
func process(data []byte) error {
if len(data) == 0 {
return errors.New("empty data")
}

result, err := step1(data)
if err != nil {
return err
}

result, err = step2(result)
if err != nil {
return err
}

return step3(result)
}

// 不推荐:嵌套过深
func processNested(data []byte) error {
if len(data) > 0 {
result, err := step1(data)
if err == nil {
result, err = step2(result)
if err == nil {
return step3(result)
} else {
return err
}
} else {
return err
}
}
return errors.New("empty data")
}

复杂条件提取

// 不推荐:条件复杂难懂
if user.Age >= 18 && user.HasLicense && !user.IsSuspended && user.PaymentStatus == "active" {
// ...
}

// 推荐:提取为有意义的函数
func canDrive(user User) bool {
return user.Age >= 18 &&
user.HasLicense &&
!user.IsSuspended
}

func hasActiveSubscription(user User) bool {
return user.PaymentStatus == "active"
}

if canDrive(currentUser) && hasActiveSubscription(currentUser) {
// ...
}

选择合适的控制结构

// 简单二选一:用 if
if isValid {
process()
} else {
handleError()
}

// 多个互斥分支:用 switch
switch status {
case "pending":
// ...
case "processing":
// ...
case "completed":
// ...
default:
// ...
}

// 遍历集合:用 for range
for _, item := range items {
process(item)
}

避免过深的嵌套

// 不推荐:嵌套过深
func processData(data []byte) error {
if len(data) > 0 {
if isValid(data) {
result, err := transform(data)
if err == nil {
if save(result) {
return nil
} else {
return errors.New("save failed")
}
} else {
return err
}
} else {
return errors.New("invalid data")
}
}
return errors.New("empty data")
}

// 推荐:提前返回
func processDataClean(data []byte) error {
if len(data) == 0 {
return errors.New("empty data")
}

if !isValid(data) {
return errors.New("invalid data")
}

result, err := transform(data)
if err != nil {
return err
}

if !save(result) {
return errors.New("save failed")
}

return nil
}

小结

本章深入学习了 Go 的控制流语句:

  1. if 语句:支持初始化语句,条件必须是布尔值
  2. switch 语句:无需 break,支持多值匹配和无表达式形式
  3. for 循环:统一的循环关键字,支持多种形式
  4. range 循环:遍历各种集合,注意值是副本
  5. goto 语句:谨慎使用,特定场景有用
  6. 标签:配合 break/continue 跳出嵌套循环

关键要点:

  • 理解 Go 控制流的设计哲学:简洁、明确、无歧义
  • 掌握初始化语句的使用,合理限制变量作用域
  • 注意 range 遍历时的值复制特性
  • 避免深层嵌套,保持代码扁平化

练习

  1. 编写一个程序,判断一个年份是否是闰年
  2. 使用 switch 实现 FizzBuzz:3 的倍数输出 Fizz,5 的倍数输出 Buzz,15 的倍数输出 FizzBuzz
  3. 实现一个函数,找出切片中的最大值和最小值
  4. 使用标签优化嵌套循环,实现二维数组的搜索
  5. 实现一个简单的命令行计算器,支持加减乘除

参考资源