Golang for-range / switch

for-range

示例 4.13:for-range 循环遍历切片

evenVals := []int{2, 4, 6, 8, 10, 12}
for i, v := range evenVals {
    fmt.Println(i, v)
}

运行上述代码,将生成如下输出:

0 2
1 4
2 6
3 8
4 10
5 12

for-range 循环的特别之处是它会返回两个循环变量。第一个变量是正在遍历的数据结构中的位置(或键),第二个变量是该位置(或键)对应的值。

在使用 for-range 循环时,如果不使用第一个变量(比如索引或键),应该如何处理呢?根据 Go 的规则,所有声明的变量都必须被使用,否则会编译错误。此规则同样适用于 for-range 循环返回的变量。若不需要使用 for-range 返回的第一个变量(例如键或索引),可以用下划线 _ 作为占位符,指示 Go 忽略该值。

示例 4.14:在 for-range 循环中忽略切片的位置

evenVals := []int{2, 4, 6, 8, 10, 12}
for _, v := range evenVals { // 用 _ 作为位置变量的占位符,告诉 Go 忽略位置变量
    fmt.Println(v)
}

运行上述代码,将生成如下输出:

2
4
6
8
10
12

根据 Go 的规范,当使用 for-range 遍历 map、数组、切片或字符串时,如果只需要索引或键(不需要值时),可以只声明一个变量(即只保留第一个变量,而省略第二个变量)。以下为合法代码:

uniqueNames := map[string]bool{"Fred": true, "Raul": true, "Wilma": true}

for k := range uniqueNames { // 只保留第一个变量(索引或键),而省略第二个变量(值)
    fmt.Println(k)
}

仅需遍历键(key)的典型场景是“将映射(map)作为集合(set)使用时,此时值(value)并无实际意义”。

尽管 Go 也允许在遍历数组(array)或切片(slice)时省略值,但这种情况极为罕见——因为遍历线性数据结构的目的通常是访问元素。若对数组或切片使用了这种仅遍历键(key)的编程模式,很可能是因为选择了不恰当的数据结构,应考虑重构代码。

4.4.5.1 遍历映射(map)

for-range 循环在遍历映射(map)时,其遍历顺序是随机的(如示例 4.15 所示)。可以在 The Go Playground 或本书资源库的 sample_code/iterate_map 目录中运行此代码。

示例 4.15:for-range 遍历映射的顺序是随机的

m := map[string]int{
    "a": 1,
    "c": 3,
    "b": 2,
}

for i := 0; i < 3; i++ {
    fmt.Println("Loop", i)

    for k, v := range m { // 每次遍历映射时,都会得到随机的键值对顺序
        fmt.Println(k, v)
    }
}

示例中的一次运行结果如下:

Loop 0
c 3
b 2
a 1
Loop 1
a 1
c 3
b 2
Loop 2
b 2
a 1
c 3

映射(map)的键值遍历顺序具有不确定性,这实际上是 Go 的安全设计特性。在早期的 Go 版本中,若向映射插入相同的键,其遍历顺序通常(但不总是)保持一致,这导致两个严重问题:

  • 代码假设风险:开发者编写依赖固定遍历顺序的代码,这些代码会在不可预测的时刻崩溃。
  • 哈希拒绝服务攻击(Hash DoS):若映射始终将键哈希至相同的值,攻击者可通过构造特定键(全部哈希至同一桶中),使映射性能急剧下降

为了避免这两个问题,Go 团队对映射的实现进行了两项更改。首先,修改了映射(Map)的哈希算法,在每次创建映射变量时都加入一个随机数。其次,让映射的 for-range 遍历顺序在每次循环时都有一些变化。这两项改动大大增加了哈希 DoS 攻击的难度。

这条规则有一个例外。为了便于调试和记录映射,格式化函数(如 fmt.Println)总是按键(Key)的顺序(升序)输出映射。

4.4.5.2 遍历字符串(string)

示例 4.16:遍历字符串中的码点

samples := []string{"hello", "apple_π!"} // 字符串切片,包含两个字符串

for _, sample := range samples { // 遍历字符串切片中的每个字符串
    for i, r := range sample { // 遍历一个字符串中的 Unicode 码点(rune)
        fmt.Println(i, r, string(r))
    }

    fmt.Println()
}

当代码遍历单词 "hello" 时,输出结果符合预期:

0 104 h    // 索引 0 → Unicode 104 → 字符 "h"
1 101 e    // 索引 1 → Unicode 101 → 字符 "e"
2 108 l    // 索引 2 → Unicode 108 → 字符 "l"
3 108 l    // 索引 3 → Unicode 108 → 字符 "l"
4 111 o    // 索引 4 → Unicode 111 → 字符 "o"

第一列是索引(字符在字符串中的位置);第二列是字符的 Unicode 码点数值;第三列是将数值类型转换后的字符串形式。

而字符串 apple_π! 的遍历结果,则可能会让你感到困惑:

0 97 a     // 索引 0 → 'a'
1 112 p    // 索引 1 → 'p'
2 112 p    // 索引 2 → 'p'
3 108 l    // 索引 3 → 'l'
4 101 e    // 索引 4 → 'e'
5 95 _     // 索引 5 → '_'
6 960 π    // 索引 6 → 'π'(Unicode U+03C0)
8 33 !     // 索引 8 → '!'

你所看到的是用 for-range 循环遍历字符串时的特殊行为。它遍历的是字符(rune),而不是字节(byte)。每当 for-range 循环在字符串中遇到一个多字节的字符(rune)时,它会将 UTF-8 编码转换为单个 32 位数字,并将其赋值给第二个变量。偏移量会递增该字符(rune)中的字节数。如果 for-range 循环遇到一个不代表有效 UTF-8 值的字节,它将返回 Unicode 替代字符(十六进制值为 0xfffd)。

使用 for-range 循环按顺序访问字符串中的 Unicode 字符(rune)。第一个变量保存从字符串起始位置开始的字节数,即字节偏移量;而第二个变量的类型是 rune,表示解码后的 Unicode 码点。

4.4.5.3 for-range 得到的值是副本

需特别注意:每次遍历时,for-range 循环会将复合类型中的元素值拷贝到循环变量中。修改循环变量不会影响原始复合类型中的数据

示例 4.17:修改 for-range 循环变量不会影响原始数据

evenVals := []int{2, 4, 6, 8, 10, 12}

for _, v := range evenVals {
    v *= 2
}

fmt.Println(evenVals) // [2 4 6 8 10 12]

运行上述代码,将得到如下输出:

[2 4 6 8 10 12]

遍历 map 时,键和值都会被拷贝到循环变量中,修改循环变量不会影响原始 map

scores := map[string]int{
    "Alice": 90,
    "Bob":   80,
}

for _, v := range scores {
    v *= 2  // 只是修改了拷贝的副本
}

fmt.Println(scores) // map[Alice:90 Bob:80],没有变化

如果 map 的 value 本身是引用类型(如 slice、map),虽然循环变量拷贝的是这个引用类型的"头部"(比如 slice 的指针、长度、容量),但它们指向的底层数据是共享的,所以通过循环变量修改底层数据是有效的

在 Go 1.22 之前的版本中,for 循环中的值变量只会被创建一次,并在每次迭代中被重复使用。自 Go 1.22 起,默认行为改为在每次迭代时创建新的索引和值变量

这个改变看似不重要,实际修复了一个常见隐患。Go 1.22 之前,像下面这个循环

values := []string{"a", "b", "c"}

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

表面上看,好像每次循环的 goroutine 分别拿到了 "a""b""c"。但实际上 goroutine 里的闭包捕获的并不是“创建 goroutine 那一刻的值”,而是变量 v 本身。Go 官方把这称为 loop variable accidental sharing,是非常经典的 Go 坑。

Go 1.21 及更早版本通常有两种修复方法。

  • 循环体内创建一个新的变量,因此每个 goroutine 捕获不同的变量

    for _, v := range values {
        v := v
    
        go func() {
            fmt.Println(v)
        }()
    }
    
  • 通过函数参数传值,(v) 会把当前的 v 作为参数值传进去,因此每次 goroutine 都有自己的参数变量。

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

Go 1.22 Release Notes - The Go Programming Language

Previously, the variables declared by a “for” loop were created once and updated by each iteration. In Go 1.22, each iteration of the loop creates new variables, to avoid accidental sharing bugs.

switch 语句

Go 语言与许多 C 衍生语言一样,也提供了 switch 语句。但 C 衍生语言的多数开发人员都会避免使用 switch 语句,因为这些语言对 switch 语句可切换的值和默认的 fall-through 行为有限制。但 Go 语言不同,它使 switch 语句变得更易用。

对已熟悉 Go 基础语法的读者,本章重点介绍“表达式 switch”语句,而“类型 switch”语句将在“七 类型、方法与接口<127页>”中讨论接口时,再深入介绍。ᵃ

ᵃ 在 Go 中,表达式 switch(expression switch)是基于表达式的值进行分支判断,而类型 switch(type switch)用于处理接口变量的具体类型。

乍一看,Go 语言中的 switch 语句与 C/C++、Java、JavaScript 等语言中的 switch 语句并无太大差异。但实际上,Go 语言中的 switch 语句有几个关键特性,使其变得更为强大和灵活(如示例 4.19 所示)。

words := []string{"a", "cow", "smile", "gopher", "octopus", "anthropologist"}

for _, word := range words {
    switch size := len(word); size { // size 作用域仅限于本 switch 块
    case 1, 2, 3, 4: // 逗号分隔,同时匹配多个值
        fmt.Println(word, "is a short word!")
    case 5:
        wordLen := len(word) // wordLen 作用域仅限当前 case 分支
        fmt.Println(word, "is exactly the right length:", wordLen)
    case 6, 7, 8, 9:
        // 空分支:什么都不做
    default:
        fmt.Println(word, "is a long word!")
    }
}

输出:

a is a short word!
cow is a short word!
smile is exactly the right length: 5
anthropologist is a long word!

语法要点:

  • 无需括号:与 if 语句一致,switch 中比较的值不需要用括号包裹。
  • 变量作用域:可在 switch 语句中声明变量(如 size),其作用域覆盖所有 case 分支;在某个 case 内声明的变量(如 wordLen),作用域仅限于该分支内部。
  • 代码块规则:所有 case(及 default)子句包含在同一组花括号内,每个 case 可包含多行代码,但不能用小括号包裹 case 的内容。
  • 多值匹配:使用逗号分隔多个匹配值,如 case 1, 2, 3, 4:,效果等同于"或"逻辑。

关于 fall-through(穿透)行为:

**Switch fall through(穿透)**是指在 switch 语句中,当某个 case 匹配成功并执行完代码后,由于缺少 break 语句,程序不会跳出 switch,而是继续向下执行紧随其后的下一个 case 代码块,这个过程会一直重复,直到遇到 break 或整个 switch 结束。

Go 的 switch 默认不会穿透到下一个 case(这点与 C/Java/C++ 不同,但与 Ruby、Pascal 一致):

  • 不需要在每个 case 末尾写 break
  • 空的 case 分支表示"什么都不做",而非穿透执行下一个分支

例如上例中,处理 octopus(7个字母)或 gopher(6个字母)时,命中空的 case 6, 7, 8, 9:,不会有任何输出。

⚠️ Go 提供了 fallthrough 关键字实现穿透效果,但应尽量避免使用。如果发现必须依赖它才能实现逻辑,说明应该重构代码,消除 case 之间的依赖关系。

switch 支持的类型:

switch 可用于所有支持 == 运算符的类型,包括整数、浮点数、字符串等基础类型。

switch 不能直接用于切片(slice)、映射(map)、通道(channel),或包含以上类型字段的结构体。若需基于这些类型进行分支判断,请使用类型 switch(详见第七章"类型、方法与接口")。

break 的两种用途:

  1. 提前退出 case 分支:虽然不需要在每个 case 末尾写 break,但若想提前跳出当前分支,仍可使用 break。不过频繁需要用 break 退出 case,往往说明逻辑过于复杂,建议重构代码消除这种依赖。

  2. 配合标签跳出外层 for 循环:如果在 for 循环中使用 switch,而想直接跳出外层循环,需要给 for 语句加上标签,并在 break 语句中指定该标签名。如果不加标签,break 默认只会跳出当前 case 分支,而非外层循环。

示例 4.20:跳出 case 分支

func main() {
	for i := 0; i < 10; i++ {
		switch i {
		case 0, 2, 4, 6:
			fmt.Println(i, "is even")
		case 3:
			fmt.Println(i, "is divisible by 3 but not 2")
		case 7:
			fmt.Println("exit the loop!")
			break
		default:
			fmt.Println(i, "is boring")
		}
	}
}

运行上述代码,将得到如下输出:

0 is even
1 is boring
2 is even
3 is divisible by 3 but not 2
4 is even
5 is boring
6 is even
exit the loop!
8 is boring
9 is boring

示例 4.20 并非本意。本意是当循环变量 i 等于 7 时,退出 for 循环。但因为缺少标签,所以 break 语句实际上退出了 case 分支(而非整个 for 循环)。为了解决此问题,需要为 for 循环引入标签,并在 break 处使用该标签(如下所示)。

func main() {
loop:
	for i := 0; i < 10; i++ {
		switch i {
		case 0, 2, 4, 6:
			fmt.Println(i, "is even")
		case 3:
			fmt.Println(i, "is divisible by 3 but not 2")
		case 7:
			fmt.Println("exit the loop!")
			break loop // 直接退出了 for 循环,而非当前 case 分支,符合本意
		default:
			fmt.Println(i, "is boring")
		}
	}
}

运行上述代码,可得到预期的输出结果(遇到 7 时,退出 for 循环):

0 is even
1 is boring
2 is even
3 is divisible by 3 but not 2
4 is even
5 is boring
6 is even
exit the loop!

4.5.1 空白 switch 语句

还可以用另一种更强大的方式使用 switch 语句。与 for 循环可省略部分声明类似,Go 允许编写不指定比较值的 switch 语句,称为“空白 switch”。常规 switch:仅支持等值(==)比较;而在空白 switch 中,每个 case 可以使用任意布尔表达式,实现更灵活的条件分支。

示例 4.21:空白 switch 语句

words := []string{"hi", "salutations", "hello"}

for _, word := range words {
	switch wordLen := len(word); { // 空白 switch,省略了比较值表达式
	case wordLen < 5: // 每个 case 分支可使用任意布尔表达式
		fmt.Println(word, "is a short word!")
	case wordLen > 10:
		fmt.Println(word, "is a long word!")
	default:
		fmt.Println(word, "is exactly the right length.")
	}
}

运行上述代码将得到如下输出:

hi is a short word!
salutations is a long word!
hello is exactly the right length.

与常规 switch 语句一样,可以在空白 switch 中包含一个简短的变量声明。但与常规 switch 不同,可以为 case 分支编写逻辑测试。空白 switch 非常酷,但不要过度使用。如果发现编写了一个空白 switch,其中所有 case 都是针对同一变量的相等性比较(如下所示):

switch {
case a == 2: // 所有 case 都是对 a 的相等性比较
	fmt.Println("a is 2")
case a == 3: // 所有 case 都是对 a 的相等性比较
	fmt.Println("a is 3")
case a == 4: // 所有 case 都是对 a 的相等性比较
	fmt.Println("a is 4")
default:
	fmt.Println("a is ", a)
}

则应该将其重构为常规 switch 语句(如下所示):

switch a {
case 2:
	fmt.Println("a is 2")
case 3:
	fmt.Println("a is 3")
case 4:
	fmt.Println("a is 4")
default:
	fmt.Println("a is ", a)
}

4.5.2 如何选择 if 与 switch

for i := 1; i <= 100; i++ {
	switch { // 空白 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)
	}
}

这种空白 switch(省略比较值表达式)在语义上与 if-else if...else 完全等价。

当条件分支较多时,switch 的写法比一长串 if-else if 更整洁清晰,尤其是当每个 case 条件本身比较简短时(如上面的例子)。这是 Go 语言中一种常见的惯用写法(idiom),本质上是语法糖,用来让多分支条件判断在视觉上更有结构感。

评论