Go 语言的 for 循环支持声明多循环变量,并且可以应用在循环体以及判断条件中,比如下面就是一个使用多循环变量的、稍复杂的例子:
for i, j, k := 0, 1, 2; (i < 20) && (j < 10) && (k < 30); i, j, k = i+1, j+1, k+5 {
sum += (i + j + k)
println(sum)
}
for i := 0; i < 10; {
i++
}
i := 0
for ; i < 10; i++{
println(i)
}
i := 0
for ; i < 10; {
println(i)
i++
}
细心的你可能已经发现了,虽然我们对前置语句或后置语句进行了省略,但经典 for 循环形式中的分号依然被保留着,你要注意这一点,这是 Go 语法的要求。
不过有一个例外,那就是当循环前置与后置语句都省略掉,仅保留循环判断条件表达式时,我们可以省略经典 for 循环形式中的分号。也就是说,我们可以将上面的例子写出如下形式:
i := 0
for i < 10 {
println(i)
i++
}
没错。当 for 循环语句的循环判断条件表达式的求值结果始终为 true 时,我们就可以将它省略掉了:
for {
// 循环体代码
}
for range 循环形式
for range 循环形式是怎么一种形式呢?我们先来看一个例子。如果我们要使用 for 经典形式遍历一个切片中的元素,我们可以这样做:
var sl = []int{1, 2, 3, 4, 5}
for i := 0; i < len(sl); i++ {
fmt.Printf("sl[%d] = %d\n", i, sl[i])
}
在这个经典形式的例子中,我们使用循环前置语句中声明的循环变量 i 作为切片下标,逐一将切片中的元素读取了出来。不过,这样就有点麻烦了。其实,针对像切片这样的复合数据类型,还有 Go 原生的字符串类型(string),Go 语言提供了一个更方便的“语法糖”形式:for range。现在我们就来写一个等价于上面代码的 for range 循环:
for i, v := range sl {
fmt.Printf("sl[%d] = %d\n", i, v)
}
for i := range sl {
// ...
}
for _, v := range sl {
// ...
}
for range sl {
// ...
}
string 类型
var s = "中国人"
for i, v := range s {
fmt.Printf("%d %s 0x%x\n", i, string(v), v)
}
运行这个例子,输出结果是这样的:
0 中 0x4e2d
3 国 0x56fd
6 人 0x4eba
们看到:for range 对于 string 类型来说,每次循环得到的 v 值是一个 Unicode 字符码点,也就是 rune 类型值,而不是一个字节,返回的第一个值 i 为该 Unicode 字符码点的内存编码(UTF-8)的第一个字节在字符串内存序列中的位置。
map
在 Go 语言中,我们要对 map 进行循环操作,for range 是唯一的方法,for 经典循环形式是不支持对 map 类型变量的循环控制的。下面是通过 for range,对一个 map 类型变量进行循环操作的示例:
var m = map[string]int {
"Rob" : 67,
"Russ" : 39,
"John" : 29,
}
for k, v := range m {
println(k, v)
}
channel
var c = make(chan int)
for v := range c {
// ...
}
带 label 的 continue 语句
而带 label 的 continue 语句,通常出现于嵌套循环语句中,被用于跳转到外层循环并继续执行外层循环语句的下一个迭代,比如下面这段代码:
func main() {
var sl = [][]int{
{1, 34, 26, 35, 78},
{3, 45, 13, 24, 99},
{101, 13, 38, 7, 127},
{54, 27, 40, 83, 81},
}
outerloop:
for i := 0; i < len(sl); i++ {
for j := 0; j < len(sl[i]); j++ {
if sl[i][j] == 13 {
fmt.Printf("found 13 at [%d, %d]\n", i, j)
continue outerloop
}
}
}
}
看到这里,一些学习过 goto 语句的同学可能就会问了,如果我把上述代码中的 continue 换成 goto 语句,是否也可以实现同样的效果?
答案是否定的!一旦使用 goto 跳转,那么不管是内层循环还是外层循环都会被终结,代码将会从 outerloop 这个 label 处,开始重新执行我们的嵌套循环语句,这与带 label 的 continue 的跳转语义是完全不同的。
我还要特别提醒你,goto 是一种公认的、难于驾驭的语法元素,应用 goto 的代码可读性差、代码难于维护还易错。虽然 Go 语言保留了 goto,但在我们这个入门课中,我们不会系统讲解 goto 语句。
break 语句的使用
func main() {
var sl = []int{5, 19, 6, 3, 8, 12}
var firstEven int = -1
// 找出整型切片sl中的第一个偶数
for i := 0; i < len(sl); i++ {
if sl[i]%2 == 0 {
firstEven = sl[i]
break
}
}
println(firstEven) // 6
}
和 continue 语句一样,Go 也 break 语句增加了对 label 的支持。而且,和前面 continue 语句一样,如果遇到嵌套循环,break 要想跳出外层循环,用不带 label 的 break 是不够,因为不带 label 的 break 仅能跳出其所在的最内层循环。要想实现外层循环的跳出,我们还需给 break 加上 label。我们来看一个具体的例子:
var gold = 38
func main() {
var sl = [][]int{
{1, 34, 26, 35, 78},
{3, 45, 13, 24, 99},
{101, 13, 38, 7, 127},
{54, 27, 40, 83, 81},
}
outerloop:
for i := 0; i < len(sl); i++ {
for j := 0; j < len(sl[i]); j++ {
if sl[i][j] == gold {
fmt.Printf("found gold at [%d, %d]\n", i, j)
break outerloop
}
}
}
}
这个例子和我们前面讲解的、带 label 的 continue 语句的例子很像,main 函数的逻辑就是,在 sl 这个二维切片中找到 38 这个数字,并输出它的位置信息。整个二维切片中至多有一个值为 38 的元素,所以只要我们通过嵌套循环发现了 38,我们就不需要继续执行这个循环了。这时,我们通过带有 label 的 break 语句,就可以直接终结外层循环,从而从复杂多层次的嵌套循环中直接跳出,避免不必要的算力资源的浪费。
for 语句的常见“坑”与避坑方法
问题一:循环变量的重用
func main() {
var m = []int{1, 2, 3, 4, 5}
for i, v := range m {
go func() {
time.Sleep(time.Second * 3)
fmt.Println(i, v)
}()
}
time.Sleep(time.Second * 10)
}
现在我们继续看这个例子,作为一个初学者,我们预期的输出结果可能是这样的:
0 1
1 2
2 3
3 4
4 5
实际输出如下:
4 5
4 5
4 5
4 5
4 5
我们看到,Goroutine 中输出的循环变量,也就是 i 和 v 的值都是 for range 循环结束后的最终值,而不是各个 Goroutine 启动时变量 i 和 v 的值,与我们最初的“预期”不符,这是为什么呢?
我们可以将上面的 for range 语句做一个等价转换,这样可以帮助你理解 for range 的工作原理。等价转换后的结果是这样的:
func main() {
var m = []int{1, 2, 3, 4, 5}
{
i, v := 0, 0
for i, v = range m {
go func() {
time.Sleep(time.Second * 3)
fmt.Println(i, v)
}()
}
}
time.Sleep(time.Second * 10)
}
通过等价转换后的代码,我们可以清晰地看到循环变量 i 和 v 在每次迭代时的重用。而 Goroutine 执行的闭包函数引用了它的外层包裹函数中的变量 i、v,这样,变量 i、v 在主 Goroutine 和新启动的 Goroutine 之间实现了共享,而 i, v 值在整个循环过程中是重用的,仅有一份。在 for range 循环结束后,i = 4, v = 5,因此各个 Goroutine 在等待 3 秒后进行输出的时候,输出的是 i, v 的最终值。
那么如何修改代码,可以让实际输出和我们最初的预期输出一致呢?我们可以为闭包函数增加参数,并且在创建 Goroutine 时将参数与 i、v 的当时值进行绑定,看下面的修正代码:
func main() {
var m = []int{1, 2, 3, 4, 5}
for i, v := range m {
go func(i, v int) {
time.Sleep(time.Second * 3)
fmt.Println(i, v)
}(i, v)
}
time.Sleep(time.Second * 10)
}
运行修改后的例子代码,输出结果是这样的:
0 1
1 2
2 3
3 4
4 5
这回的输出结果与我们的预期就是一致的了。不过这里你要注意:你执行这个程序的输出结果的行序,可能与我的不同,这是由 Goroutine 的调度所决定的,我们在后面课程中会详细讲解。
问题二:参与循环的是 range 表达式的副本
func main() {
var a = [5]int{1, 2, 3, 4, 5}
var r [5]int
fmt.Println("original a =", a)
for i, v := range a {
if i == 0 {
a[1] = 12
a[2] = 13
}
r[i] = v
}
fmt.Println("after for range loop, r =", r)
fmt.Println("after for range loop, a =", a)
}
这个例子说的是对一个数组 a 的元素进行遍历操作,当处理下标为 0 的元素时,我们修改了数组 a 的第二个和第三个元素的值,并且在每个迭代中,我们都将从 a 中取得的元素值赋值给新数组 r。我们期望这个程序会输出如下结果:
original a = [1 2 3 4 5]
after for range loop, r = [1 12 13 4 5]
after for range loop, a = [1 12 13 4 5]
但实际运行该程序的输出结果却是:
original a = [1 2 3 4 5]
after for range loop, r = [1 2 3 4 5]
after for range loop, a = [1 12 13 4 5]
为什么会是这种情况呢?原因就是参与 for range 循环的是 range 表达式的副本。也就是说,在上面这个例子中,真正参与循环的是 a 的副本,而不是真正的 a。
那么应该如何解决这个问题,让输出结果符合我们前面的预期呢?我们前面说过,在 Go 中,大多数应用数组的场景我们都可以用切片替代,这里我们也用切片来试试看:
func main() {
var a = [5]int{1, 2, 3, 4, 5}
var r [5]int
fmt.Println("original a =", a)
for i, v := range a[:] {
if i == 0 {
a[1] = 12
a[2] = 13
}
r[i] = v
}
fmt.Println("after for range loop, r =", r)
fmt.Println("after for range loop, a =", a)
}
你可以看到,在 range 表达式中,我们用了 a[:]替代了原先的 a,也就是将数组 a 转换为一个切片,作为 range 表达式的循环对象。运行这个修改后的例子,结果是这样的:
original a = [1 2 3 4 5]
after for range loop, r = [1 12 13 4 5]
after for range loop, a = [1 12 13 4 5]
所以,当进行 range 表达式复制时,我们实际上复制的是一个切片,也就是表示切片的结构体。表示切片副本的结构体中的 array,依旧指向原切片对应的底层数组,所以我们对切片副本的修改也都会反映到底层数组 a 上去。而 v 再从切片副本结构体中 array 指向的底层数组中,获取数组元素,也就得到了被修改后的元素值。
问题三:遍历 map 中元素的随机性
考虑到上述这种随机性,我们日常编码遇到遍历 map 的同时,还需要对 map 进行修改的场景的时候,要格外小心。
此文章为3月Day10学习笔记,内容来源于极客时间《Tony Bai · Go 语言第一课》。