7.Go 流程控制

背景与动机#

Go 的流程控制刻意做得很少:没有 while、没有 do...while,核心就围绕 if/elseswitchforrange 展开。表面上是“语法少”,本质上是把分支、循环和遍历统一成几种稳定模型,降低控制流复杂度。

学习这一节,重点不是记住语法模板,而是理解:

  1. ifswitch 为什么支持“先执行一条简单语句再判断”。
  2. Go 的 switch 为什么默认不贯穿分支。
  3. Go 为什么只有 for 一种循环。
  4. range 遍历出来的到底是索引、值、键、rune 还是副本。

以下说明基于 Go 官方规范与文档:Go 语言规范Effective GoGo Wiki: Range

核心原理拆解#

1. if/else:布尔条件分支,支持局部初始化语句#

Go 的 if 最基础形式是:

if condition {
// ...
} else {
// ...
}

有两个关键点:

  • 条件必须是 bool,不能把整数、字符串直接当条件。
  • 大括号必须写,不能省略。

Go 的 if 还有一个很实用的能力:判断前先执行一条简单语句。

if err := do(); err != nil {
// 处理错误
}

这里的语义是:

  1. 先执行 err := do()
  2. 再判断 err != nil
  3. err 的作用域只在这个 if/else 结构内部

这让临时变量能被限制在更小作用域里,特别适合错误处理。

else 在 Go 里经常能省#

如果 if 分支里已经 returnbreakcontinue,Go 风格通常直接让正常路径继续向下,不再包一层 else。这不是语法要求,而是控制流简化的惯用法。

2. switch:多路分支,默认只执行命中的一个分支#

Go 的 switch 比很多语言更灵活:

switch x {
case 1:
fmt.Println("one")
case 2:
fmt.Println("two")
default:
fmt.Println("other")
}

它的核心语义是:

  • switch 表达式只计算一次
  • case 从上到下匹配
  • 命中第一个匹配分支后执行并结束
  • 默认不会自动贯穿到下一个 case

这和 C 系语言差别很大。Go 默认不需要手写 break,因为“不自动穿透”就是默认行为。

switch 没有表达式时,等价于 switch true#

switch {
case x < 0:
fmt.Println("negative")
case x == 0:
fmt.Println("zero")
default:
fmt.Println("positive")
}

这本质上是在写一个更清晰的 if/else if/else 链。

fallthrough 可以强制继续执行下一分支#

Go 允许用 fallthrough 明确要求继续进入紧邻的下一个 case,但它只会无条件落到下一分支,不会重新判断下一个 case 条件,因此要谨慎使用。

image.png
image.png

3. for:Go 唯一的循环结构#

Go 只有 for,但它覆盖了三种常见循环形态。

条件循环#

for n < 10 {
n++
}

这就是 Go 的“while 风格”。

经典三段式循环#

for i := 0; i < 10; i++ {
fmt.Println(i)
}

结构是:

  • 初始化语句
  • 条件判断
  • 后置语句

其中后置部分不能写短变量声明。

无限循环#

for {
// ...
}

这通常用于:

  • 事件循环
  • 服务器监听循环
  • 配合 break 的主动退出循环

breakcontinue#

  • break:终止当前最近一层 forswitchselect
  • continue:直接进入下一轮循环

二者都支持标签,用于跳出外层循环,但标签版属于“必要时使用”的能力,不应滥用。

4. range:统一的遍历语法,但遍历对象不同,产出的值也不同#

range 不是独立控制结构,而是 for 的一种子形式:

for k, v := range x {
// ...
}

重点在于:不同被遍历对象,range 产出的内容不同。

遍历数组或切片#

for i, v := range s {
// i 是索引,v 是元素副本
}
  • 第一个值是索引
  • 第二个值是该位置元素的拷贝结果

如果只需要索引:

for i := range s {
}

遍历字符串#

for i, r := range str {
// i 是字节起始位置,r 是 rune
}

这里很容易误解:

  • i 不是“第几个字符”,而是当前 rune 的字节下标
  • r 是 Unicode 码点,不是单字节

遍历 map#

for k, v := range m {
}
  • 第一个值是键
  • 第二个值是值的拷贝
  • 遍历顺序不保证稳定

遍历 channel#

for v := range ch {
}

会持续接收,直到 channel 被关闭。

Go 1.22 的版本相关点#

Go 1.22 起,range 还支持对整数遍历:

for i := range 5 {
fmt.Println(i) // 0..4
}

这是版本相关行为,旧版本 Go 不支持。

图意:这张图具体说明 slicestringmapchannelrange 下如何产出不同的值,重点看左边数组/切片返回 index + element、中间字符串返回 byte index + rune,最后理解 map 返回 key + valuechannel 只返回元素值。

image.png
image.png

5. range 的值通常是“拷贝出来的当前项”,不是原对象别名#

这是最容易踩坑的点之一。

例如:

for _, v := range s {
v++
}

这里改的是循环变量 v,不是切片元素本身。因为 v 是当前元素值的拷贝。

如果要原地改切片,通常应写成:

for i := range s {
s[i]++
}

map 和字符串遍历时也有类似边界:

  • map 里的 v 也是拷贝
  • 字符串遍历拿到的是解码后的 rune,不是可写字符槽位

6. Go 1.22+ 里 range 变量捕获问题的理解方式#

这是一个版本相关点。

历史上,很多人会写出这样的代码:

for _, v := range items {
go func() {
fmt.Println(v)
}()
}

旧版本 Go 中,这类写法容易因为循环变量复用而出现闭包捕获问题。Go 1.22 对 for range 的迭代变量语义做了调整,常见闭包误捕获问题大幅缓解。

但要注意:

  • 这不等于所有循环变量共享问题都消失了
  • 如果你显式取地址、共享可变外部对象,仍然可能出错

工程上最稳妥的理解仍然是:看清变量作用域和是否共享底层状态,不要只靠“语法看起来像独立副本”来判断。

最小可运行代码示例#

package main
import "fmt"
func main() {
n := 7
if x := n * 2; x > 10 {
fmt.Println("if:", x)
} else {
fmt.Println("else:", x)
}
switch {
case n < 0:
fmt.Println("negative")
case n == 0:
fmt.Println("zero")
default:
fmt.Println("positive")
}
sum := 0
for i := 0; i < 5; i++ {
sum += i
}
fmt.Println("sum:", sum)
s := []string{"go", "is", "simple"}
for i, v := range s {
fmt.Println(i, v)
}
}

这个例子同时覆盖了:

  • if 的初始化语句
  • 无表达式 switch
  • 三段式 for
  • range 遍历切片

常见陷阱与错误示例#

1. 把非布尔值直接放进 if#

错误示例:

if 1 {
}

Go 不支持“truthy/falsy”风格,条件必须显式产生 bool

2. 误以为 switch 会像 C 一样自动贯穿#

错误理解:

switch n {
case 1:
fmt.Println("one")
case 2:
fmt.Println("two")
}

这里命中 case 1 后不会自动进入 case 2。只有显式写 fallthrough 才会继续。

3. 认为 range 修改了原切片元素#

错误示例:

nums := []int{1, 2, 3}
for _, v := range nums {
v *= 2
}

nums 不会变,因为 v 是拷贝。应改成:

for i := range nums {
nums[i] *= 2
}

4. 依赖 map 的遍历顺序#

错误示例:

for k := range m {
// 假设每次顺序一致
}

Go 规范不保证 map 遍历顺序,不能把它当作稳定排序结果。

5. 忽略字符串 range 的字节下标语义#

错误理解:

for i, r := range "你好" {
// 把 i 当成第几个字符
_ = r
}

这里的 i 是当前 rune 在原字符串中的字节起始位置,不是字符序号。

性能影响或设计取舍#

1. Go 用单一 for 统一循环语义,降低语言复杂度#

好处是学习成本低,控制流模型统一;代价是需要习惯“没有 while”这件事。

2. switch 默认不贯穿,提高了分支安全性#

这样可以减少忘写 break 的经典错误,但如果你真的想共享逻辑,需要显式组织代码或使用 fallthrough

3. range 提升了遍历可读性,但也隐藏了“拷贝语义”#

读代码时更简洁,但如果忘了第二个值通常是拷贝,就容易写出“看起来改了,实际上没改”的代码。

4. 遍历方式影响性能与语义#

  • 只需要索引时,for i := range s 通常更直接
  • 既要索引又要值时,用 for i, v := range s
  • 需要精细控制、避免某些拷贝语义时,经典下标循环可能更清楚

面试高频问题#

1. Go 为什么只有 for,没有 while#

因为 Go 把循环统一进 forfor condition {} 就等价于很多语言里的 while,从而减少一种独立语法结构。

2. Go 的 switch 和 C 的 switch 最大区别是什么#

Go 的 switch 默认命中一个分支后就结束,不会自动贯穿;如果需要继续执行下一个分支,必须显式写 fallthrough

3. ifswitch 前面的初始化语句有什么作用#

它允许在判断前声明一个局部临时变量,并把作用域限制在当前控制结构内部,常见于错误处理和局部中间值判断。

4. range 遍历字符串时返回的两个值是什么#

第一个是当前 Unicode 码点在原字符串中的字节起始下标,第二个是解码得到的 rune

5. 为什么说 range 出来的值很多时候是拷贝#

因为对数组、切片、map 等遍历时,第二个返回值通常是当前元素值的副本。修改循环变量本身,不等于修改原容器中的元素。

6. Go 1.22 对 range 有什么版本相关变化#

Go 1.22 起支持对整数做 range 遍历,例如 for i := range 5;同时 for range 的迭代变量语义也有版本相关调整,常见闭包捕获问题比旧版本更少,但仍应关注变量作用域与共享状态。

一句话总结#

Go 的流程控制可以概括为:if/else 负责显式布尔分支,switch 负责多路匹配且默认不贯穿,for 是唯一循环结构,而 range 则把遍历统一成一种语法;真正要掌握的是它们背后的作用域规则、匹配规则和拷贝语义。

文章目录

文章目录