gitbook/Tony Bai · Go语言第一课/docs/450944.md
2022-09-03 22:05:03 +08:00

745 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 19|控制结构:Go的for循环,仅此一种
你好,我是Tony Bai。
上一节课,我们开始了对程序控制结构的学习,学习了分支结构中的if语句。上节课我们也说过,针对程序的分支结构,Go提供了if和switch-case两种语句形式。那你肯定在想,这节课肯定是要讲switch-case语句了吧!我不想按常规出牌,这一节课我们换换口味,挑战一下程序控制结构中最复杂的一款:循环结构。
为什么这么设置呢?因为我想让你能更早开始动手编写具有循环结构的Go代码。虽然switch-case分支结构也非常重要,但毕竟我们已经有了if分支语句的基础了,很多时候用if也可以替代switch-case,所以把它往后放放也没关系。
日常编码过程中,我们常常需要重复执行同一段代码,这时我们就需要循环结构来帮助我们控制程序的执行顺序。一个循环结构会执行循环体中的代码直到结尾,然后回到开头继续执行。 主流编程语言都提供了对循环结构的支持,绝大多数主流语言,包括C语言、C++、Java和Rust,甚至连动态语言Python还提供了不止一种的循环语句,但Go却只有一种,也就是for语句。
所以这节课,我们就来系统学习一下Go语言循环结构中的这一支独苗,for语句,聚焦于它的使用形式和常见坑点,让你能更快上手Go编码。
首先,我们就来认识一下Go语言中的for语句。
## 认识for语句的经典使用形式
C语言是很多现代编程语言的“祖先”,要学习Go语言中for语句的使用形式,我们要先看看C语言中for语句是怎么使用的。
下面这段C代码就是C语言中for语句的经典使用形式:
```plain
int i;
int sum = 0;
for (i = 0; i < 10; i++) {
sum += i;
}
printf("%d\n", sum);
```
这种形式也被其它后继语言延承了下来,Go语言的for语句也不例外,这段C代码在Go语言中的等价形式是这样的:
```plain
var sum int
for i := 0; i < 10; i++ {
sum += i
}
println(sum)
```
这种for语句的使用形式是Go语言中**for循环语句的经典形式**,也是我们在这节课要介绍的for循环语句的第一种形式。我们用一幅流程图来直观解释一下上面这句for循环语句的组成部分,以及各个部分的执行顺序:
![图片](https://static001.geekbang.org/resource/image/f7/5c/f71546ecca1f03964c29d573d9b27e5c.jpg?wh=1920x1080)
从图中我们看到,经典for循环语句有四个组成部分(分别对应图中的①~④)。我们按顺序拆解一下这张图。
图中①对应的组成部分执行于循环体(③ )之前,并且在整个for循环语句中**仅会被执行一次**,它也被称为**循环前置语句**。我们通常会在这个部分声明一些循环体(③ )或循环控制条件(② )会用到的自用变量,也称循环变量或迭代变量,比如这里声明的整型变量i。与if语句中的自用变量一样,for循环变量也采用短变量声明的形式,循环变量的作用域仅限于for语句隐式代码块范围内。
图中②对应的组成部分,是用来决定循环是否要继续进行下去的**条件判断表达式**。和if语句的一样,这个用于条件判断的表达式必须为布尔表达式,如果有多个判断条件,我们一样可以由逻辑操作符进行连接。当表达式的求值结果为true时,代码将进入循环体(③)继续执行,相反则循环直接结束,循环体(③)与组成部分④都不会被执行。
前面也多次提到了,图中③对应的组成部分是for循环语句的**循环体**。如果相关的判断条件表达式求值结构为true时,循环体就会被执行一次,这样的一次执行也被称为一次迭代(Iteration)。在上面例子中,循环体执行的动作是将这次迭代中变量i的值累加到变量sum中。
图中④对应的组成部分会在每次循环体迭代之后执行,也被称为**循环后置语句**。这个部分通常用于更新for循环语句组成部分①中声明的循环变量,比如在这个例子中,我们在这个组成部分对循环变量i进行加1操作。
现在你应该理解Go语言中的经典for语句的形式了吧?不过,Go语言的for循环也在C语言的基础上有一些突破和创新。具体一点,**Go语言的for循环支持声明多循环变量,并且可以应用在循环体以及判断条件中**,比如下面就是一个使用多循环变量的、稍复杂的例子:
```plain
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,它们共同参与了循环条件判断与循环体的执行。
我们继续按四个组成部分分析这段代码。其实,除了循环体部分(③)之外,其余的三个部分都是**可选的**。比如下面代码中,我们省略了循环后置语句④,将对循环变量的更新操作放在了循环体中:
```plain
for i := 0; i < 10; {
i++
}
```
我们也可以省略循环前置语句。比如下面例子中,我们就没有使用前置语句声明循环变量,而是直接使用了已声明的变量i充当循环变量的作用:
```plain
i := 0
for ; i < 10; i++{
println(i)
}
```
当然,循环前置与后置语句也可以都省略掉,比如下面代码:
```plain
i := 0
for ; i < 10; {
println(i)
i++
}
```
细心的你可能已经发现了,**虽然我们对前置语句或后置语句进行了省略,但经典for循环形式中的分号依然被保留着,你要注意这一点,这是Go语法的要求。**
不过有一个例外,那就是当循环前置与后置语句都省略掉,仅保留循环判断条件表达式时,我们可以省略经典for循环形式中的分号。也就是说,我们可以将上面的例子写出如下形式:
```plain
i := 0
for i < 10 {
println(i)
i++
}
```
这种形式也是我们在日常Go编码中经常使用的for循环语句的**第二种形式**,**也就是除了循环体之外,我们仅保留循环判断条件表达式。**
不过看到这里,你可能就问了:“老师,前面你不是说过,除了循环体,其他组成部分都是可选项么?”
没错。**当for循环语句的循环判断条件表达式的求值结果始终为true时,我们就可以将它省略掉了:**
```plain
for {
// 循环体代码
}
```
这个for循环就是我们通常所说的**“无限循环”**。它的形式等价于:
```plain
for true {
// 循环体代码
}
```
或者:
```plain
for ; ; {
// 循环体代码
}
```
不过,虽然我这里给出这些等价形式,但在日常使用时,我还是建议你**用它的最简形式**,也就是`for {...}`,更加简单。
那么,无限循环是什么意思呢?是不是意味着代码始终在运行循环体而无法跳出来呢?不是的。这点你可以先思考一下,我们后面会讲。这里我们先继续看Go语言中for循环最常使用的第三种形式,**for range**。
## for range循环形式
for range 循环形式是怎么一种形式呢?我们先来看一个例子。如果我们要使用for经典形式遍历一个切片中的元素,我们可以这样做:
```plain
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循环:
```plain
for i, v := range sl {
fmt.Printf("sl[%d] = %d\n", i, v)
}
```
我们看到,for range循环形式与for语句经典形式差异较大,除了循环体保留了下来,其余组成部分都“不见”了。其实那几部分已经被**融合到for range的语义中了**。
具体来说,这里的i和v对应的是经典for语句形式中循环前置语句的循环变量,它们的初值分别为切片sl的第一个元素的下标值和元素值。并且,隐含在for range语义中的循环控制条件判断为:是否已经遍历完sl的所有元素,等价于`i < len(sl)`这个布尔表达式。另外,每次迭代后,for range会取出切片sl的下一个元素的下标和值,分别赋值给循环变量i和v,这与for经典形式下的循环后置语句执行的逻辑是相同的。
for range语句也有几个常见“变种”,我们继续以上面对切片的迭代为例分析一下。
**变种一**:当我们不关心元素的值时,我们可以省略代表元素值的变量v,只声明代表下标值的变量i:
```plain
for i := range sl {
// ...
}
```
**变种二:**如果我们不关心元素下标,只关心元素值,那么我们可以用**空标识符**替代代表下标值的变量i。这里一定要注意,**这个空标识符不能省略**,否则就与上面的“变种一”形式一样了,Go编译器将无法区分:
```plain
for _, v := range sl {
// ...
}
```
**变种三:**到这里,你肯定要问:如果我们既不关心下标值,也不关心元素值,那是否能写成下面这样呢:
```plain
for _, _ = range sl {
// ...
}
```
这种形式在语法上没有错误,就是看起来不太优雅。Go核心团队早在[Go 1.4版本](https://golang.google.cn/doc/go1.4#forrange)中就提供了一种优雅的等价形式,你后续直接使用这种形式就好了:
```plain
for range sl {
// ...
}
```
好了,讲完了for range针对切片这种复合类型的各种形式后,我们再来看看for range应该如何用于对其他复合类型,或者是对string类型进行循环操作。for range针对不同复合数据类型进行循环操作时,虽然语义是相同的,但它声明的循环变量的含义会有所不同,我们有必要逐一看一下。
### string类型
我们在[第13讲](https://time.geekbang.org/column/article/440804)讲解string类型时,就提到过如何通过for range对一个字符串类型变量进行循环操作。我们再通过下面的例子简单回顾一下:
```plain
var s = "中国人"
for i, v := range s {
fmt.Printf("%d %s 0x%x\n", i, string(v), v)
}
```
运行这个例子,输出结果是这样的:
```plain
0 中 0x4e2d
3 国 0x56fd
6 人 0x4eba
```
我们看到:for range对于string类型来说,**每次循环得到的v值是一个Unicode字符码点**,也就是rune类型值,而不是一个字节,返回的第一个值i为**该Unicode字符码点的内存编码(UTF-8)的第一个字节在字符串内存序列中的位置**。
另外我要在这里再次提醒你,使用for经典形式与使用for range形式,对string类型进行循环操作的语义是不同的,你可以回到[13讲](https://time.geekbang.org/column/article/440804)复习一下这块的内容。
### map
在[第16讲](https://time.geekbang.org/column/article/446032)我们学习过,map就是一个键值对(key-value)集合,最常见的对map的操作,就是通过key获取其对应的value值。但有些时候,我们也要对map这个集合进行遍历,这就需要for语句的支持了。
但在Go语言中,**我们要对map进行循环操作,for range是唯一的方法**,for经典循环形式是不支持对map类型变量的循环控制的。下面是通过for range,对一个map类型变量进行循环操作的示例:
```plain
var m = map[string]int {
"Rob" : 67,
"Russ" : 39,
"John" : 29,
}
for k, v := range m {
println(k, v)
}
```
运行这个示例,我们会看到这样的输出结果:
```plain
John 29
Rob 67
Russ 39
```
通过输出结果我们看到:for range对于map类型来说,**每次循环,循环变量k和v分别会被赋值为map键值对集合中一个元素的key值和value值**。而且,map类型中没有下标的概念,通过key和value来循环操作map类型变量也就十分自然了。
### channel
除了可以针对string、数组/切片,以及map类型变量进行循环操作控制之外,for range还可以与channel类型配合工作。
channel是Go语言提供的并发设计的原语,它用于多个Goroutine之间的通信,我们在后面的课程中还会详细讲解channel。当channel类型变量作为for range语句的迭代对象时,for range会尝试从channel中读取数据,使用形式是这样的:
```plain
var c = make(chan int)
for v := range c {
// ...
}
```
在这个例子中,for range每次从channel中读取一个元素后,会把它赋值给循环变量v,并进入循环体。当channel中没有数据可读的时候,for range循环会阻塞在对channel的读操作上。直到channel关闭时,for range循环才会结束,这也是for range循环与channel配合时隐含的循环判断条件。我们在后面讲解channel的部分,还会对for range与channel的配合做更细致地讲解,这一节课就不涉及那么多了,我们简单了解就可以。
到这里,我们已经对Go语言支持的所有for循环形式有了一个初步的了解。那么,在日常开发中,一旦我们执行for循环,是不是就只能等循环条件判断表达式求值为false时,才能离开循环呢?如果是前面提到的无限循环,我们是不是就会被一直困于循环之中呢?
不是的。日常开发中,出于算法逻辑的需要,我们可能会有中断当前循环体并继续下一次迭代的时候,也会有中断循环体并彻底结束循环语句的时候。针对这些情况,Go语言提供了continue语句和break语句。
## 带label的continue语句
首先,我们来看第一种场景。如果循环体中的代码执行到一半,要中断当前迭代,忽略此迭代循环体中的后续代码,并回到for循环条件判断,尝试开启下一次迭代,这个时候我们可以怎么办呢?我们可以使用continue语句来应对。
我们先来学习一下continue语句的使用方法,你看看下面这个代码示例:
```plain
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,将再次进入循环体,开启新一次迭代。
如果你学过C语言,你可能会说:这个continue与C语言中的continue也没有什么差别啊!别急,Go语言中的continue在C语言continue语义的基础上**又增加了对label的支持**。
label语句的作用,是标记跳转的目标。我们可以把上面的代码改造为使用label的等价形式:
```plain
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语句,通常出现于**嵌套循环语句**中,**被用于跳转到外层循环并继续执行外层循环语句的下一个迭代**,比如下面这段代码:
```plain
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语句是这个场景下的“最佳人选”,它会直接结束内层循环的执行,并回到外层循环继续执行。
这一行为就好比在外层循环放置并执行了一个不带label的continue语句。它会中断外层循环中当前迭代的执行,执行外层循环的后置语句(i++),然后再对外层循环的循环控制条件语句进行求值,如果为true,就将继续执行外层循环的新一次迭代。
看到这里,一些学习过goto语句的同学可能就会问了,如果我把上述代码中的continue换成goto语句,是否也可以实现同样的效果?
答案是否定的!一旦使用goto跳转,那么**不管是内层循环还是外层循环都会被终结,代码将会从outerloop这个label处,开始重新执行我们的嵌套循环语句,这与带label的continue的跳转语义是完全不同的**。
我还要特别提醒你,goto是一种公认的、难于驾驭的语法元素,应用goto的代码可读性差、代码难于维护还易错。虽然Go语言保留了goto,但在我们这个入门课中,我们不会系统讲解goto语句。
## break语句的使用
在前面的讲解中,你可能也注意到了,无论带不带label,continue语句的本质都是继续循环语句的执行。但日常编码中,我们还会遇到一些场景,在这些场景中,我们不仅要中断当前循环体迭代的进行,还要同时彻底跳出循环,终结整个循环语句的执行。面对这样的场景,continue语句就不再适用了,Go语言为我们提供了**break语句**来解决这个问题。
我们先来看下面这个示例中break语句的应用:
```plain
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语句跳出了这个循环。
和continue语句一样,Go也break语句增加了对label的支持。而且,和前面continue语句一样,如果遇到嵌套循环,break要想跳出外层循环,用不带label的break是不够,因为不带label的break仅能跳出其所在的最内层循环。要想实现外层循环的跳出,我们还需给break加上label。我们来看一个具体的例子:
```plain
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语句,就可以直接终结外层循环,从而从复杂多层次的嵌套循环中直接跳出,避免不必要的算力资源的浪费。
好了,到这里,关于Go语言中for语句的相关语法,我们已经全部讲完了,通过for语句我们可以实现**重复执行同一段代码**的逻辑。针对原生字符串类型以及一些复合数据类型,诸如数组/切片、map、channel等,Go还提供了for range“语法糖”形式来简化循环结构的编写。
不过,我们也看到,相较于分支结构,以for语句为代表的循环结构的逻辑要复杂许多。在日常编码实践中,我们也会遇到一些与for循环语句相关的常见问题,下面我们就聊聊究竟有哪些与for相关的常见“坑”点。
## for语句的常见“坑”与避坑方法
for语句的常见“坑”点通常和for range这个“语法糖”有关。虽然for range的引入提升了Go语言的表达能力,也简化了循环结构的编写,但for range也不是“免费的午餐”,初学者在享用这道美味时,经常会遇到一些问题,下面我们就来看看这些常见的问题。
### 问题一:循环变量的重用
我们前面说过,for range形式的循环语句,使用短变量声明的方式来声明循环变量,循环体将使用这些循环变量实现特定的逻辑,但你在刚开始学习使用的时候,可能会发现循环变量的值与你之前的“预期”不符,比如下面这个例子:
```plain
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中的轻量级协程),输出这次迭代的元素的下标值与元素值。关于Goroutine创建和使用的知识我们在后面课程中会有详细的讲解。
现在我们继续看这个例子,作为一个初学者,我们预期的输出结果可能是这样的:
```plain
0 1
1 2
2 3
3 4
4 5
```
那实际输出真的是这样吗?我们实际运行输出一下:
```plain
4 5
4 5
4 5
4 5
4 5
```
我们看到,Goroutine中输出的循环变量,也就是i和v的值都是for range循环结束后的最终值,而不是各个Goroutine启动时变量i和v的值,与我们最初的“预期”不符,这是为什么呢?
这是因为我们最初的“预期”本身就是错的。这里,初学者很可能会被for range语句中的短声明变量形式“迷惑”,简单地认为每次迭代都会重新声明两个新的变量i和v。但事实上,这些循环变量在for range语句中仅会被声明一次,且在每次迭代中都会被重用。
你还能想起[第11讲](https://time.geekbang.org/column/article/436915)中关于控制语句的隐式代码块的知识点吗?基于隐式代码块的规则,我们可以将上面的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的当时值进行绑定,看下面的修正代码:
```plain
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)
}
```
运行修改后的例子代码,输出结果是这样的:
```plain
0 1
1 2
2 3
3 4
4 5
```
这回的输出结果与我们的预期就是一致的了。不过这里你要注意:你执行这个程序的输出结果的行序,可能与我的不同,这是由Goroutine的调度所决定的,我们在后面课程中会详细讲解。
### 问题二:参与循环的是range表达式的副本
前面我们学过了,在for range语句中,range后面接受的表达式的类型可以是数组、指向数组的指针、切片、字符串,还有map和channel(需具有读权限)。我们以数组为例来看一个简单的例子:
```plain
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。我们期望这个程序会输出如下结果:
```plain
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]
```
但实际运行该程序的输出结果却是:
```plain
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循环,用一个等价的伪代码形式重写一下:
```plain
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中,大多数应用数组的场景我们都可以用切片替代,这里我们也用切片来试试看:
```plain
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表达式的循环对象。运行这个修改后的例子,结果是这样的:
```plain
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]
```
我们看到输出的结果与最初的预期终于一致了,显然用切片能实现我们的要求。
那切片是如何做到的呢?在之前的[第15讲](https://time.geekbang.org/column/article/444348)中,我们学习过,切片在Go内部表示为一个结构体,由(array, len, cap)组成,其中array是指向切片对应的底层数组的指针,len是切片当前长度,cap为切片的最大容量。
所以,当进行range表达式复制时,我们实际上复制的是一个切片,也就是表示切片的结构体。表示切片副本的结构体中的array,依旧指向原切片对应的底层数组,所以我们对切片副本的修改也都会反映到底层数组a上去。而v再从切片副本结构体中array指向的底层数组中,获取数组元素,也就得到了被修改后的元素值。
### 问题三:遍历map中元素的随机性
根据上面的讲解,当map类型变量作为range表达式时,我们得到的map变量的副本与原变量指向同一个map(具体原因你可以看第16讲)。**如果我们在循环的过程中,对map进行了修改,那么这样修改的结果是否会影响后续迭代呢?这个结果和我们遍历map一样,具有随机性。**
比如我们来看下面这个例子,在map循环过程中,当counter值为0时,我们删除了变量m中的一个元素:
```plain
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"作为第一个迭代的元素时,我们将得到如下结果:
```plain
tony 21
tom 22
jim 23
counter is 3
```
否则,我们得到的结果是这样的:
```plain
tom 22
jim 23
counter is 2
```
如果我们在针对map类型的循环体中,新创建了一个map元素项,那这项元素可能出现在后续循环中,也可能不出现:
```plain
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)
```
这个例子的执行结果也会有两个,
```plain
tony 21
tom 22
jim 23
lucy 24
counter is 4
```
或:
```plain
tony 21
tom 22
jim 23
counter is 3
```
考虑到上述这种随机性,我们日常编码遇到遍历map的同时,还需要对map进行修改的场景的时候,要格外小心。
## 小结
好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。
在这一讲中,我们讲解了程序控制结构中最复杂的一种:循环控制结构。和其他主流编程语言不同,Go语言仅提供了一种循环结构语句:**for语句**。for语句的这种践行“做一件事仅有一种方法”理念的作法恰是Go语言崇尚“简单”的设计哲学的具体体现。
我们首先学习了for语句的经典形式:for preStmt; condition; postStmt { … },你要注意for语句经典形式的四个组成部分,分别是循环前置语句、循环判断表达式、循环体与循环后置语句,也要注意这四个部分的执行顺序。而且,这四部分中,除了循环体,其它三个组成部分都是可选的。我们可以根据实际情况选择省略某个部分。
如果我们只保留循环判断条件表达式,我们就得到了for循环语句经常使用的第二种形式:`for condition {...}`。如果循环判断条件表达式求值结果始终为true,我们就可以将for循环语句写成`for {...}`的形式,这种形式也被称为“无限循环”。
而且,针对string类型以及一些复合数据类型,比如数组/切片、map以及channel等,Go提供了使用更为便捷的“语法糖”for range形式。for range形式与for语句经典形式差异较大,除了循环体保留了下来,其它几部分融合到for range的语义中了。for range语句形式也有几个“变种”,你要注意的是,如果仅需要代表元素值的循环变量,不需要代表下标值或key的循环变量,我们也需要**使用空标识符占位**。
此外,Go语言提供了continue语句与break语句用于显式中断当前循环体的执行,两个语句不同之处在于continue会继续后续迭代的执行,而break将终结整个for语句的执行。Go语言还支持在continue与break关键字后面加label的方式,这种方式常用于有嵌套循环的场景中,它们可以帮助程序中断内层循环的执行,返回外层循环继续执行下一个外层循环迭代,或彻底结束整个嵌套循环的执行。
最后,for语句在日常使用中有一些常见的问题需要你格外注意,包括循环变量重用、range表达式副本参与循环、map类型遍历的随机性,等等,你一样要深刻理解,才能在日常编码时少走弯路。
## 思考题
在“参与循环的是range表达式的副本”这一部分中,我们用切片替换了数组,实现了我们预期的输出,我想让你思考一下,除了换成切片这个方案之外,还有什么方案也能实现我们预期的输出呢?
欢迎你把这节课分享给更多对Go语言循环结构感兴趣的朋友。我是Tony Bai,我们下节课见。