Go 循环之for循环,仅此一种

Go 循环之for循环,仅此一种

一、for 循环介绍

日常编码过程中,我们常常需要重复执行同一段代码,这时我们就需要循环结构来帮助我们控制程序的执行顺序。一个循环结构会执行循环体中的代码直到结尾,然后回到开头继续执行。 主流编程语言都提供了对循环结构的支持,绝大多数主流语言,比如:Python 提供了不止一种的循环语句,但 Go 却只有一种,也就是 for 语句

二、for 循环结构

2.1 基本语法结构

Go语言的for循环的一般结构如下:

for 初始语句;条件表达式;结束语句{
    循环体语句
}
  1. 初始语句:在循环开始前执行一次的初始化操作,通常用于声明计数器或迭代变量的初始值。
  2. 条件表达式:循环会在每次迭代之前检查条件表达式,只有当条件为真时,循循环才会继续执行。如果条件为假,循环结束。
  3. 结束语句:在每次迭代之后执行的操作,通常用于更新计数器或迭代变量的值。

以下是一个示例,演示了不同类型的for循环基本用法:

var sum int
for i := 0; i < 10; i++ {
    sum += i
}
println(sum)

这种 for 语句的使用形式是 Go 语言中 for 循环语句的进形式。我们用一幅流程图来直观解释一下上面这句 for 循环语句的组成部分,以及各个部分的执行顺序:

WechatIMG209

从图中我们看到,经典 for 循环语句有四个组成部分(分别对应图中的①~④)。我们按顺序拆解一下这张图。

图中①对应的组成部分执行于循环体(③ )之前,并且在整个 for 循环语句中仅会被执行一次,它也被称为循环前置语句。我们通常会在这个部分声明一些循环体(③ )或循环控制条件(② )会用到的自用变量,也称循环变量或迭代变量,比如这里声明的整型变量 i。与 if 语句中的自用变量一样,for 循环变量也采用短变量声明的形式,循环变量的作用域仅限于 for 语句隐式代码块范围内。

图中②对应的组成部分,是用来决定循环是否要继续进行下去的条件判断表达式。和 if 语句的一样,这个用于条件判断的表达式必须为布尔表达式,如果有多个判断条件,我们一样可以由逻辑操作符进行连接。当表达式的求值结果为 true 时,代码将进入循环体(③)继续执行,相反则循环直接结束,循环体(③)与组成部分④都不会被执行。

前面也多次提到了,图中③对应的组成部分是 for 循环语句的循环体。如果相关的判断条件表达式求值结构为 true 时,循环体就会被执行一次,这样的一次执行也被称为一次迭代(Iteration)。在上面例子中,循环体执行的动作是将这次迭代中变量 i 的值累加到变量 sum 中。

图中④对应的组成部分会在每次循环体迭代之后执行,也被称为循环后置语句。这个部分通常用于更新 for 循环语句组成部分①中声明的循环变量,比如在这个例子中,我们在这个组成部分对循环变量 i 进行加 1 操作。

2.2 省略初始值

for 循环的初始语句可以被忽略,但是必须要写初始语句后面的分号

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

2.3 省略初始语句和结束语句

for循环的初始语句和结束语句都可以省略,例如:

func main() {
	var i int
	for i < 10 {
		fmt.Println(i)
		i++
	}
}

这种写法类似于其他编程语言中的while,在while后添加一个条件表达式,满足条件表达式时持续循环,否则结束循环。

2.4 无限循环

无限循环是一种循环结构,它会一直执行,而不受循环条件的限制,同时省略了初始语句,条件表达式,结束语句。基本语法格式如下:

for {
    循环体语句
}

它的形式等价于:

for true {
   // 循环体代码
}

或者等价于:

for ; ; {
   // 循环体代码
}

在日常使用时,建议你用它的最简形式,也就是for {...},更加简单。

举个栗子:

	for {
		fmt.Println("这是一个死循环!")
	}

无限循环通常在编程中用于执行需要持续运行的任务,如服务器监听、事件处理等。

2.5 for 循环支持声明多循环变量

Go 语言的 for 循环支持声明多循环变量,并且可以应用在循环体以及判断条件中,比如下面就是一个使用多循环变量的、稍复杂的例子:

	var sum int
	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)
	}

在这个例子中,我们声明了三个循环自用变量 i、j 和 k,它们共同参与了循环条件判断与循环体的执行。这段代码的执行流程解释如下:

  1. 开始时,i 被初始化为 0,j 被初始化为 1,k 被初始化为 2,sum 被初始化为 0。
  2. 进入循环。在每次迭代中,首先检查三个条件:i < 20j < 10k < 30。只有在这三个条件都为真时,循环才会继续执行。
  3. 在每次迭代中,计算 i + j + k 的和,并将结果添加到 sum 中。
  4. 使用 println 函数打印 sum 的当前值。
  5. 继续迭代,ijk 分别增加 1、1 和 5。
  6. 重复步骤 2、3、4 直到其中一个条件不再满足。在这种情况下,当 i 大于或等于 20、j 大于或等于 10 或 k 大于或等于 30 时,循环结束。

2.6 小练习:打印九九乘法表

	for y := 1; y <= 9; y++ {
		// 遍历, 决定这一行有多少列
		for x := 1; x <= y; x++ {
			fmt.Printf("%d*%d=%d ", x, y, x*y)
		}
		// 手动生成回车
		fmt.Println()
	}

输出结果如下:

1*1=1 
1*2=2 2*2=4 
1*3=3 2*3=6 3*3=9 
1*4=4 2*4=8 3*4=12 4*4=16 
1*5=5 2*5=10 3*5=15 4*5=20 5*5=25 
1*6=6 2*6=12 3*6=18 4*6=24 5*6=30 6*6=36 
1*7=7 2*7=14 3*7=21 4*7=28 5*7=35 6*7=42 7*7=49 
1*8=8 2*8=16 3*8=24 4*8=32 5*8=40 6*8=48 7*8=56 8*8=64 
1*9=9 2*9=18 3*9=27 4*9=36 5*9=45 6*9=54 7*9=63 8*9=72 9*9=81 

执行过程如下:

  1. for y := 1; y <= 9; y++:这是外部的for循环,它初始化一个名为 y 的循环变量,从1开始,每次迭代递增1,一直到 y 的值小于或等于9。
  2. 内部的for循环 for x := 1; x <= y; x++:这是内部的for循环,用于控制每行的列数。循环变量 x 从1开始,每次迭代递增1,一直到 x 的值小于或等于 y。这确保了每一行都只打印与行数相等或更小的列数。
  3. fmt.Printf("%d*%d=%d ", x, y, x*y):在内部循环中,这一行代码用于打印每个乘法表达式。它使用 fmt.Printf 函数,打印了一个格式化的字符串,其中 %d 是占位符,分别用 xyx*y 的值替换。这将打印类似 "11=1 "、"12=2 "、"2*2=4 " 的格式。
  4. fmt.Println():在内部循环结束后,使用 fmt.Println 打印一个换行符,以将每行的输出分开。

三、for range(键值循环)

3.1 基本介绍

在编程中,经常需要遍历和操作集合(如数组、切片、映射等)中的元素。Go语言中可以使用for range遍历数组、切片、字符串、map 及通道(channel)。 通过for range遍历的返回值有以下规律:

  1. 数组、切片、字符串返回索引和值。
  2. map返回键和值。
  3. 通道(channel)只返回通道内的值。

3.2 基本语法格式

for range 循环的基本语法格式如下:

for key, value := range collection {
    // 循环体代码,使用 key 和 value
}
  • key 是元素的索引或键。
  • value 是元素的值。
  • collection 是要遍历的元素,如字符串、数组、切片、映射等。

举个例子,首先我们使用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 作为切片下标,逐一将切片中的元素读取了出来。不过,这样就有点麻烦了。但是使用for range 循环后如下:

var sl = []int{1, 2, 3, 4, 5}
for i, v := range sl {
    fmt.Printf("sl[%d] = %d\n", i, v)
}

我们看到,for range 循环形式除了循环体保留了下来,其余组成部分都“不见”了。其实那几部分已经被融合到 for range 的语义中了

具体来说,这里的 i v 对应的是for 语句形式中循环前置语句的循环变量,它们的初值分别为切片 sl 的第一个元素的下标值和元素值。并且,隐含在 for range 语义中的循环控制条件判断为:是否已经遍历完 sl 的所有元素,等价于i < len(sl)这个布尔表达式。另外,每次迭代后,for range 会取出切片 sl 的下一个元素的下标和值,分别赋值给循环变量 i v,这与 for 经典形式下的循环后置语句执行的逻辑是相同的。

3.3 for range 语句几个常见的“变种”

3.3.1 省略value

有时候,您可能只对元素中的index感兴趣,而不需要值value。在这种情况下,您可以省略值部分,只使用键。示例如下:

fruits := []string{"apple", "banana", "cherry"}
for index := range fruits {
    fmt.Printf("Index: %d\n", index)
}

3.3.2 省略 key

如果我们不关心元素下标,只关心元素值,那么我们可以用空标识符替代代表下标值的变量 i。这里一定要注意,这个空标识符不能省略,否则就与上面形式一样了,Go 编译器将无法区分:

for _, v := range sl {
  // ... 
}

3.3.3 同时省略 key 和 value

如果我们既不关心元素下标值,也不关心元素值,那是否能写成下面这样呢:

for _, _ = range sl {
  // ... 
}

这种形式在语法上没有错误,就是看起来不太优雅。Go 在Go 1.4 版本中就提供了一种优雅的等价形式,后续直接使用这种形式就好了:

for range sl {
  // ... 
}

四、for 循环常用操作

4.1 遍历数组、切片——获得索引和元素

在遍历代码中,key 和 value 分别代表切片的下标及下标对应的值。下面的代码展示如何遍历切片,数组也是类似的遍历方法:

package main

import "fmt"

func main() {
	for key, value := range []int{1, 2, 3, 4} {
		fmt.Printf("key:%d value:%d\n", key, value)
	}
}
/*
代码输出如下:
key:0 value:1
key:1 value:2
key:2 value:3
key:3 value:4
 */

4.2 遍历string 类型--获得字符串

下面这段代码展示了如何遍历字符串:

var s = "中国人"
for i, v := range s {
    fmt.Printf("%d %s 0x%x\n", i, string(v), v)
}

输出结果如下:

00x4e2d
30x56fd
60x4eba

我们看到:for range 对于 string 类型来说,每次循环得到的 v 值是一个 Unicode 字符码点,也就是 rune 类型值,而不是一个字节,返回的第一个值 i 为该 Unicode 字符码点的内存编码(UTF-8)的第一个字节在字符串内存序列中的位置。

遍历map——获得map的键和值

map 就是一个键值对(key-value)集合,最常见的对 map 的操作,就是通过 key 获取其对应的 value 值。但有些时候,我们也要对 map 这个集合进行遍历,这就需要 for 语句的支持了。

但在 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)
}

运行这个示例,我们会看到这样的输出结果:

John 29
Rob 67
Russ 39

通过输出结果我们看到:for range 对于 map 类型来说,每次循环,循环变量 k 和 v 分别会被赋值为 map 键值对集合中一个元素的 key 值和 value。而且,map 类型中没有下标的概念,通过 keyvalue 来循环操作 map 类型变量也就十分自然了。

遍历通道(channel)——接收通道数据

除了可以针对 string、数组 / 切片,以及 map 类型变量进行循环操作控制之外,for range 还可以与 channel 类型配合工作。

c := make(chan int)
go func() {
    c <- 1
    c <- 2
    c <- 3
    close(c)
}()
for v := range c {
    fmt.Println(v)
}

channel 是 Go 语言提供的并发设计的原语,它用于多个 Goroutine 之间的通信。当 channel 类型变量作为 for range 语句的迭代对象时,for range 会尝试从 channel 中读取数据,使用形式是这样的:

var c = make(chan int)
for v := range c {
   // ... 
}

在这个例子中,for range 每次从 channel 中读取一个元素后,会把它赋值给循环变量 v,并进入循环体。当 channel 中没有数据可读的时候, for range 循环会阻塞在对 channel 的读操作上。直到 channel 关闭时,for range 循环才会结束,这也是 for range 循环与 channel 配合时隐含的循环判断条件。

五、跳出循环与终止循环

5.1 continue 语句(继续下次循环)

5.1.1 continue 基本语法

首先,我们来看第一种场景。如果循环体中的代码执行到一半,要中断当前迭代,忽略此迭代循环体中的后续代码,并回到 for 循环条件判断,尝试开启下一次迭代,这个时候我们可以怎么办呢?我们可以使用 continue 语句来应对。基本语法如下:

for initialization; condition; update {
    // 循环体
    if someCondition {
        continue
    }
    // 其他循环体的代码
}

  • initialization 是初始化语句,通常用于初始化循环变量。
  • condition 是循环条件,当条件为真时继续循环,否则退出。
  • update 是在每次迭代后执行的操作,通常用于更新循环变量。

带标签的 continue 语句用于跳过当前迭代中 if 语句中的 someCondition 满足的部分,直接进行下一次迭代。如果没有标签,continue 将默认跳过当前循环的下一次迭代。

以下是一个示例,演示 continue 语句的基本语法:

var sum int
var sl = []int{1, 2, 3, 4, 5, 6}
for i := 0; i < len(sl); i++ {
    if sl[i]%2 == 0 {
        // 忽略切片中值为偶数的元素
        continue
    }
    sum += sl[i]
}
println(sum) // 9

这段代码会循环遍历切片中的元素,把值为奇数的元素相加,然后存储在变量 sum 中。我们可以看到,在这个代码的循环体中,如果我们判断切片元素值为偶数,就使用 continue 语句中断当前循环体的执行,那么循环体下面的 sum += sl[i] 在这轮迭代中就会被忽略。代码执行流会直接来到循环后置语句i++,之后对循环条件表达式(i < len(sl))进行求值,如果为 true,将再次进入循环体,开启新一次迭代。

5.1.2 带标签的continue语句

Go 语言中的 continue 在 C 语言 continue 语义的基础上又增加了对 label 的支持。label 语句的作用,是标记跳转的目标。带标签的continue语句的基本语法格式如下:

loopLabel:
for initialization; condition; update {
    // 循环体
    if someCondition {
        continue loopLabel
    }
}

  • loopLabel 是一个用户定义的标签(标识符),用于标记循环。
  • initialization 是初始化语句,通常用于初始化循环变量。
  • condition 是循环条件,当条件为真时继续循环,否则退出。
  • update 是在每次迭代后执行的操作,通常用于更新循环变量。

带标签的continue语句用于在嵌套循环中指定要跳过的循环,其工作方式是:如果某个条件满足,执行continue loopLabel,其中loopLabel是要跳过的循环的标签,它将控制流转移到带有相应标签的循环的下一次迭代。如果没有指定标签,continue将默认跳过当前循环的下一次迭代。

我们可以把上面的代码改造为使用 label 的等价形式:

func main() {
    var sum int
    var sl = []int{1, 2, 3, 4, 5, 6}

loop:
    for i := 0; i < len(sl); i++ {
        if sl[i]%2 == 0 {
            // 忽略切片中值为偶数的元素
            continue loop
        }
        sum += sl[i]
    }
    println(sum) // 9
}

你可以看到,在这段代码中,我们定义了一个 label:loop,它标记的跳转目标恰恰就是我们的 for 循环。也就是说,我们在循环体中可以使用continue+ loop label的方式来实现循环体中断,这与前面的例子在语义上是等价的。不过这里仅仅是一个演示,通常我们在这样非嵌套循环的场景中会直接使用不带 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
            }
        }
    }
}

在这段代码中,变量 sl 是一个元素类型为[]int 的切片(二维切片),其每个元素切片中至多包含一个整型数 13。main 函数的逻辑就是在 sl 的每个元素切片中找到 13 这个数字,并输出它的具体位置信息。

那这要怎么查找呢?一种好的实现方式就是,我们只需要在每个切片中找到 13,就不用继续在这个切片的剩余元素中查找了。

我们用 for 经典形式来实现这个逻辑。面对这个问题,我们要使用嵌套循环,具体来说就是外层循环遍历 sl 中的元素切片,内层循环遍历每个元素切片中的整型值。一旦内层循环发现 13 这个数值,我们便要中断内层 for 循环,回到外层 for 循环继续执行。

如果我们用不带 label 的 continue 能不能完成这一功能呢?答案是不能。因为它只能中断内层循环的循环体,并继续开启内层循环的下一次迭代。而带 label 的 continue 语句是这个场景下的“最佳人选”,它会直接结束内层循环的执行,并回到外层循环继续执行。

这一行为就好比在外层循环放置并执行了一个不带labelcontinue 语句。它会中断外层循环中当前迭代的执行,执行外层循环的后置语句(i++),然后再对外层循环的循环控制条件语句进行求值,如果为 true,就将继续执行外层循环的新一次迭代。

5.2 goto(跳转到指定标签)

goto语句通过标签进行代码间的无条件跳转。goto语句可以在快速跳出循环、避免重复退出上有一定的帮助。Go语言中使用goto语句能简化一些代码的实现过程。 例如双层嵌套的for循环要退出时:

func main() {
	var breakFlag bool
	for i := 0; i < 10; i++ {
		for j := 0; j < 10; j++ {
			if j == 2 {
				// 设置退出标签
				breakFlag = true
				break
			}
			fmt.Printf("%v-%v\n", i, j)
		}
		// 外层for循环判断
		if breakFlag {
			break
		}
	}
}

使用goto语句能简化代码:

func main() {
	for i := 0; i < 10; i++ {
		for j := 0; j < 10; j++ {
			if j == 2 {
				// 设置退出标签
				goto breakTag
			}
			fmt.Printf("%v-%v\n", i, j)
		}
	}
	return
	// 标签
breakTag:
	fmt.Println("结束for循环")
}

goto 是一种公认的、难于驾驭的语法元素,应用 goto 的代码可读性差、代码难于维护还易错。虽然 Go 语言保留了 goto,在平常开发中,不推荐使用。

5.3 break(跳出循环)

日常编码中,我们还会遇到一些场景,在这些场景中,我们不仅要中断当前循环体迭代的进行,还要同时彻底跳出循环,终结整个循环语句的执行。面对这样的场景,continue 语句就不再适用了,Go 语言为我们提供了 break 语句来解决这个问题。

5.3.1 break基本语法

break语句的基本语法如下:

for initialization; condition; update {
    // 循环体
    if someCondition {
        break
    }
    // 其他循环体的代码
}
  • initialization 是初始化语句,通常用于初始化循环变量。
  • condition 是循环条件,当条件为真时继续循环,否则退出。
  • update 是在每次迭代后执行的操作,通常用于更新循环变量。

当在循环中执行 break 语句时,它会立即终止当前的循环,无论条件是否满足,然后将控制流传递到循环之后的代码。

我们来看下面这个示例中 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
}

这段代码逻辑很容易理解,我们通过一个循环结构来找出切片 sl 中的第一个偶数,一旦找到就不需要继续执行后续迭代了。这个时候我们就通过 break 语句跳出了这个循环。

5.3.2 带标签的break语法

continue 语句一样,Go 也 break 语句增加了对 label 的支持。而且,和前面 continue 语句一样,如果遇到嵌套循环,break 要想跳出外层循环,用不带 label 的 break 是不够,因为不带 labelbreak 仅能跳出其所在的最内层循环。要想实现外层循环的跳出,我们还需给 break 加上 label。所以,带标签的 break 语句允许您从嵌套循环中跳出特定循环,而不是默认跳出当前循环。带标签的 break 语法如下:

loopLabel:
for initialization; condition; update {
    // 循环体
    if someCondition {
        break loopLabel
    }
    // 其他循环体的代码
}

  • loopLabel 是用户定义的标签(标识符),用于标记循环。
  • initialization 是初始化语句,通常用于初始化循环变量。
  • condition 是循环条件,当条件为真时继续循环,否则退出。
  • update 是在每次迭代后执行的操作,通常用于更新循环变量。

当带标签的 break 语句执行时,它会终止带有相应标签的循环,而不是默认的当前循环。

我们来看一个具体的例子:

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 循环常见“坑”与避坑指南

for 语句的常见“坑”点通常和 for range 这个“语法糖”有关。虽然 for range 的引入提升了 Go 语言的表达能力,也简化了循环结构的编写,但 for range 也不是“免费的午餐”,在开发中,经常会遇到一些问题,下面我们就来看看这些常见的问题。

6.1 循环变量的重用

我们前面说过,for range 形式的循环语句,使用短变量声明的方式来声明循环变量,循环体将使用这些循环变量实现特定的逻辑,但你在刚开始学习使用的时候,可能会发现循环变量的值与你之前的“预期”不符,比如下面这个例子:

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)
}

这个示例是对一个整型切片进行遍历,并且在每次循环体的迭代中都会创建一个新的 Goroutine(Go 中的轻量级协程),输出这次迭代的元素的下标值与元素值。

现在我们继续看这个例子,我们预期的输出结果可能是这样的:

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 语句中的短声明变量形式“迷惑”,简单地认为每次迭代都会重新声明两个新的变量 i 和 v。但事实上,这些循环变量在 for range 语句中仅会被声明一次,且在每次迭代中都会被重用。

基于隐式代码块的规则,我们可以将上面的 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)
}

这回的输出结果与我们的预期就是一致的了。不过这里你要注意:你执行这个程序的输出结果的行序,可能与我的不同,这是由 Goroutine 的调度所决定的。

6.2 参与循环的是 range 表达式的副本

在 for range 语句中,range 后面接受的表达式的类型可以是数组、指向数组的指针、切片、字符串,还有 map 和 channel(需具有读权限)。我们以数组为例来看一个简单的例子:

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]

我们原以为在第一次迭代过程,也就是 i = 0 时,我们对 a 的修改 (a[1] =12,a[2] = 13) 会在第二次、第三次迭代中被 v 取出,但从结果来看,v 取出的依旧是 a 被修改前的值:2 和 3。

为什么会是这种情况呢?原因就是参与 for range 循环的是 range 表达式的副本。也就是说,在上面这个例子中,真正参与循环的是 a 的副本,而不是真正的 a。

为了方便你理解,我们将上面的例子中的 for range 循环,用一个等价的伪代码形式重写一下:

for i, v := range a' { //a'是a的一个值拷贝
    if i == 0 {
        a[1] = 12
        a[2] = 13
    }
    r[i] = v
}

现在真相终于揭开了:这个例子中,每次迭代的都是从数组 a 的值拷贝 a’中得到的元素。a’是 Go 临时分配的连续字节序列,与 a 完全不是一块内存区域。因此无论 a 被如何修改,它参与循环的副本 a’依旧保持原值,因此 v 从 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]

我们看到输出的结果与最初的预期终于一致了,显然用切片能实现我们的要求。

那切片是如何做到的呢?切片在 Go 内部表示为一个结构体,由(array, len, cap)组成,其中 array 是指向切片对应的底层数组的指针,len 是切片当前长度,cap 为切片的最大容量。

所以,当进行 range 表达式复制时,我们实际上复制的是一个切片,也就是表示切片的结构体。表示切片副本的结构体中的 array,依旧指向原切片对应的底层数组,所以我们对切片副本的修改也都会反映到底层数组 a 上去。而 v 再从切片副本结构体中 array 指向的底层数组中,获取数组元素,也就得到了被修改后的元素值。

6.3 遍历 map 中元素的随机性

根据上面的讲解,当 map 类型变量作为 range 表达式时,我们得到的 map 变量的副本与原变量指向同一个 map。如果我们在循环的过程中,对 map 进行了修改,那么这样修改的结果是否会影响后续迭代呢?这个结果和我们遍历 map 一样,具有随机性。

比如我们来看下面这个例子,在 map 循环过程中,当 counter 值为 0 时,我们删除了变量 m 中的一个元素:

var m = map[string]int{
    "tony": 21,
    "tom":  22,
    "jim":  23,
}

counter := 0
for k, v := range m {
    if counter == 0 {
        delete(m, "tony")
    }
    counter++
    fmt.Println(k, v)
}
fmt.Println("counter is ", counter)

如果我们反复运行这个例子多次,会得到两个不同的结果。当 k="tony"作为第一个迭代的元素时,我们将得到如下结果:

tony 21
tom 22
jim 23
counter is  3

否则,我们得到的结果是这样的:

tom 22
jim 23
counter is  2

如果我们在针对 map 类型的循环体中,新创建了一个 map 元素项,那这项元素可能出现在后续循环中,也可能不出现:

var m = map[string]int{
    "tony": 21,
    "tom":  22,
    "jim":  23,
}

counter := 0
for k, v := range m {
    if counter == 0 {
        m["lucy"] = 24
    }
    counter++
    fmt.Println(k, v)
}
fmt.Println("counter is ", counter)

这个例子的执行结果也会有两个:

tony 21
tom 22
jim 23
lucy 24
counter is  4

或:

tony 21
tom 22
jim 23
counter is  3

考虑到上述这种随机性,我们日常编码遇到遍历 map 的同时,还需要对 map 进行修改的场景的时候,要格外小心。

本文作者:贾维斯Echo

本文链接:https://www.cnblogs.com/taoxiaoxin/p/17758608.html

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   贾维斯Echo  阅读(231)  评论(0编辑  收藏  举报
历史上的今天:
2020-10-12 操作系统与计算机网络
点击右上角即可分享
微信分享提示
💬
评论
📌
收藏
💗
关注
👍
推荐
🚀
回顶
收起
  1. 1 404 not found REOL
  2. 2 稻香 稻香 (2015中国好声音第四季现场) - 周杰伦;徐林;Will Jay
404 not found - REOL
00:00 / 00:00
An audio error has occurred, player will skip forward in 2 seconds.

作曲 : Reol

作词 : Reol

fade away...do over again...

fade away...do over again...

歌い始めの一文字目 いつも迷ってる

歌い始めの一文字目 いつも迷ってる

どうせとりとめのないことだけど

伝わらなきゃもっと意味がない

どうしたってこんなに複雑なのに

どうしたってこんなに複雑なのに

噛み砕いてやらなきゃ伝わらない

ほら結局歌詞なんかどうだっていい

僕の音楽なんかこの世になくたっていいんだよ

Everybody don't know why.

Everybody don't know why.

Everybody don't know much.

僕は気にしない 君は気付かない

何処にももういないいない

Everybody don't know why.

Everybody don't know why.

Everybody don't know much.

忘れていく 忘れられていく

We don't know,We don't know.

目の前 広がる現実世界がまた歪んだ

目の前 広がる現実世界がまた歪んだ

何度リセットしても

僕は僕以外の誰かには生まれ変われない

「そんなの知ってるよ」

気になるあの子の噂話も

シニカル標的は次の速報

麻痺しちゃってるこっからエスケープ

麻痺しちゃってるこっからエスケープ

遠く遠くまで行けるよ

安定なんてない 不安定な世界

安定なんてない 不安定な世界

安定なんてない きっと明日には忘れるよ

fade away...do over again...

fade away...do over again...

そうだ世界はどこかがいつも嘘くさい

そうだ世界はどこかがいつも嘘くさい

綺麗事だけじゃ大事な人たちすら守れない

くだらない 僕らみんなどこか狂ってるみたい

本当のことなんか全部神様も知らない

Everybody don't know why.

Everybody don't know why.

Everybody don't know much.

僕は気にしない 君は気付かない

何処にももういないいない

Everybody don't know why.

Everybody don't know why.

Everybody don't know much.

忘れていく 忘れられていく

We don't know,We don't know.