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 的两种用途:
-
提前退出 case 分支:虽然不需要在每个 case 末尾写
break,但若想提前跳出当前分支,仍可使用break。不过频繁需要用break退出 case,往往说明逻辑过于复杂,建议重构代码消除这种依赖。 -
配合标签跳出外层 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),本质上是语法糖,用来让多分支条件判断在视觉上更有结构感。
评论