Go语言学习指南:惯例模式与编程实践
上QQ阅读APP看书,第一时间看更新

4.3 for语句

Go作为C语言家族中的一员,同时使用for循环语句。不同之处在于,在Go中for是唯一的循环语句关键字。Go使用4种不同的for形式实现不同的循环场景:

  • C风格的for语句
  • 有条件的for语句
  • 无限循环的for语句
  • for-range语句

4.3.1 使用标准形式

首先我们将看到最为熟悉的for声明,这样的声明形式在C、Java和JavaScript中都是十分相似的,见示例4-8。

示例4-8:for语句的标准形式

以上代码依次打印数字0~9。

if语句一样,for语句不需要在条件表达式中使用小括号。for语句由三部分组成,每个部分由分号分隔。

第一部分是初始化语句,设置在for循环中使用的一个或多个变量。在初始化语句中有两点值得牢记:第一,必须使用:=来初始化变量(不能使用var);第二,就像if语句一样,这里的变量会被覆盖。

第二部分是条件比较语句。这里必须是一个表达式,表达式的结果必须是一个bool类型。每次循环体运行之前、初始化之后以及每次循环体执行结束之后都会执行比较的判定。如果表达式的结果是true,那么循环就执行,反之就退出循环体。

最后一部分是标准for语句的增量语句。这个增量通常使用i++。每次循环体执行结束之后,判定比较条件之前都会执行增量语句。

4.3.2 使用条件比较

Go允许在for语句中省去初始化语句和增量语句。这样的for语句在功能上类似于C、Java、JavaScript、Python、Ruby等语言中的while语句。参见示例4-9。

示例4-9:for语句的条件比较

4.3.3 使用无限循环

第三种for语句移除了条件比较语句。这样的for循环会无限循环。如果你在1980年就开始学习编程,那么极有可能了解在BASIC下无限循环打印“HELLO”的经历:

我们用Go实现了同样的版本,在本地或者在The Go Playground(https://oreil.ly/whOi-)中执行示例4-10的代码。

示例4-10:无限循环

运行代码会在屏幕上打印出成千上万的Hello

打印会一直持续,直到按下快捷键Ctrl-C强行退出程序。

 如果在The Go Playground上运行,可能几秒后就被会强行终止,这是因为在The Go Playground上有一些运行代码的限制。

4.3.4 break和continue

如何不使用键盘或者关闭计算机而退出无限for循环呢?这就是break的作用,与其他语言中的break一样,它可以立即退出循环。break可以在任何形式的for语句中使用,包括无限循环的for语句。

 Go语言中没有与Java、C和JavaScript的do等效的关键字,如果希望至少迭代一次,最简洁的方式就是在无限循环中使用if语句退出循环。在Java代码中,这样使用do/while循环:

Go版本的代码如下所示:

需要注意,这里使用!来否定当前的条件判定。因为Go代码指定如何可以退出循环,而Java代码指定如何可以继续循环。

Go同样有continue关键字,使用它可以跳过整个for循环体中当前循环的剩余部分以直接执行下一个循环。不过continue语句并不是必需的,例如示例4-11中的代码。

示例4-11:混乱的代码

在Go语言中鼓励使用简短的if语句块,将判断条件尽量左对齐。因为嵌套多层的代码可读性很差。使用continue语句(如示例4-12)可以极大地改善代码的可读性。

示例4-12:使用continue增强代码的可读性

正如你看到的,在多个if语句中使用continue替换if-else语句链后,大大改善了条件判断语句的结构,提高了代码的可读性。

4.3.5 使用for-range

第4种for语句用于在Go的某些内置类型中迭代元素,它被称为for-range循环,类似于其他语言中的迭代器(iterator)。在本节中,我们将了解如何使用for-range循环遍历字符串、数组、切片和映射。在第10章介绍channel(通道)时,我们将学习如何使用for-range循环读取channel的数据。

 for-range循环语句只能迭代内置的复合类型和基于复合类型的自定义类型。

首先我们来看看使用for-range循环的切片,可以在The Go Playground(https://oreil.ly/XwuTL)中直接执行示例4-13的代码。

示例4-13:for-range循环

结果如下所示:

for-range循环有趣的地方在于它有两个变量。第一个变量是数据在整个被迭代数据中的位置,第二个变量是该位置的值。这两个循环变量的惯用名取决于被循环的对象。当循环遍历数组、切片或字符串时,通常使用i表示索引。当遍历映射时,则使用k(key的首字母)表示索引。

第二个变量通常称为v(value的首字母),但有时会根据迭代值的类型来命名。当然,你也可以使用任何你喜欢的名称命名这些变量。如果循环体中只有几条语句,那么单字母变量名就可以很好地工作。如果循环体中有较长的(或嵌套的)代码,则需要使用更具描述性的名称[1]

如果在for-range循环中不需要使用键的变量怎么办?要知道,Go语言不允许存在声明但不使用的变量,这条规则也适用于for循环语句中声明的变量。如果不需要访问该键,可以使用下划线作为变量名。这样就可以忽略此变量。让我们重写切片范围代码,不打印出位置。可以在The Go Playground(https://oreil.ly/2fO12)上直接运行示例4-14的代码。

示例4-14:忽略索引变量

结果如下所示:

 任何情况下如果有返回值而你想忽略该返回值时,都可以使用下划线忽略该值。在第5章讨论函数和在第9章讨论包时,我们将再次看到使用下划线(underscore)模式。

如果不需要使用迭代变量的每个值,仅希望使用索引或者键怎么办?此时,Go语言允许我们直接忽略第二个变量,代码如下:

这样的情况经常出现在迭代映射类型的集合时,迭代器只使用键,此时值不重要[2]。不过,在遍历数组或切片时,也可以不使用该值。因为迭代线性数据或者集合数据通常是为了访问数据,而修改数据的情况很少见,如果你发现自己一边循环一边修改数组或切片,那么很有可能是使用了不合适的数据结构,可以考虑重构。

 我们在第10章介绍通道时,也会看到for-range迭代只返回一个值的情况。

迭代映射类型

对于for-range循环如何遍历映射有一些有趣的事情。在The Go Playground(https://oreil.ly/VplnA)上运行示例4-15中的代码。

示例4-15:无序的迭代

编译和运行上面这段代码,你会发现每次的输出都不一样,以下是其中一种情况:

映射类型打印键-值对有时候顺序一样,有时候却不一样。这实际上是为了数据访问的安全性。Go语言在较早的版本里[3],迭代一个映射类型变量的顺序通常和插入数据时的顺序是一样的。这就导致了两个问题:

  • 我们迭代映射类型时,它并不是被顺序访问的,这样编写代码会导致不可预期的错误。
  • 如果映射类型的哈希值每次都是一样的值,那么当我们知道某个服务器存储了一些映射类型的数据时,可以通过发送一些精心设计的数据来进行哈希碰撞的DoS攻击,其结果是所有的键都映射到同一个桶里[4]

为了解决这两个问题,Go官方在映射的实现中做了两个大的改进。第一个是改进映射类型的哈希算法,使其包含一个随机数,该随机数在每次创建映射变量时生成。第二个是每次循环映射时,映射上的for-range迭代的顺序都有一点变化。这两个改进使得哈希碰撞攻击变得十分困难。

 但是也有例外。为了调试程序,在查看映射类型的变量时,使用打印函数(如fmt.Println)会一直以键为升序打印映射变量。

迭代字符串

如前所述,字符串也可以被for-range迭代。接下来我们在The Go Playground(https://oreil.ly/C3LRy)上直接运行示例4-16的代码。

示例4-16:迭代字符串

迭代单词“hello”的输出如下所示:

第一列是索引,第二列是字母的Unicode编码数值,第三列是将字母的Unicode编码数值转换成的字符串。

迭代单词“apple_π!”的输出如下所示:

为什么会是这样的输出呢?有两点值得注意。第一,在第一列中跳过了索引7。第二,索引6的值是960,这远远大于一个字节的长度,但是在第3章中我们介绍过字符串是由字节组成的,这是为什么呢?

我们看到的是使用for-range循环遍历字符串时的特殊行为。它遍历字符,而不是字节。每当for-range循环遇到字符串中的多字节字符时,它将UTF-8表示形式转换为单个32位数字并将其赋值给该值。偏移量按字符中的字节数递增。如果for-range循环遇到不是有效UTF-8值的字节,则会返回Unicode的字符(十六进制值0xfffd)[5]

 注意,使用for-range迭代字符串时,键是从字符串开始的字节位数,但是值却是字符[6]

for-range的值是副本

从以上的for-range示例不难看出,每次for-range循环遍历复合类型时,都会将值从复合类型中复制到值变量中,修改值变量不会导致复合类型中的数据变更。示例4-17展示一个小例子,你可以在The Go Playground(https://oreil.ly/ShwR0)中直接运行代码。

示例4-17:原值不会被修改

输出如下所示:

这种行为是有内在含义的,在第10章介绍goroutine时,我们将会看到在for-range循环中启动多个goroutine,如果没有正确地将索引和值传给goroutine,就会导致程序执行错误。

for循环一样,在for-range循环中也能使用breakcontinue

4.3.6 for语句的标签

默认情况下,breakcontinue关键字都在for循环内使用。如果遇到嵌套的for循环,并且希望退出或跳过嵌套循环最外部时该怎么办?让我们来看一个例子。我们将修改前面的字符串迭代程序示例,使它在字符串碰到字母“l”时停止遍历字符串。你可以在The Go Playground(https://oreil.ly/ToDkq)上运行示例4-18中的代码。

示例4-18:标签

注意,标签outergo-fmt(格式化代码的工具)缩进到与函数相同的位置。标签总是缩进到与代码块的大括号相同的位置,因为这样可以让标签更容易被注意到。运行程序会得到以下输出:

嵌套带有标签的for循环很少见。在实现一些算法时通常会遇到,其伪代码如下:

4.3.7 for语句最佳实践

现在我们已经介绍了for语句的所有形式,如此选择是个需要思考的问题。大多数情况下使用for-range格式,遍历字符串的最佳方法就是for-range循环,因为它正确地返回字符而不是字节。遍历切片和映射时for-range循环也非常实用,我们将在第10章中看到,通道也可以原生地与for-range一起工作。

 当迭代一个内置复合类型实例中的所有内容时,推荐使用for-range循环。它可以有效简化在使用数组、切片或映射时的代码,所以不必使用其他形式的for循环[7]

那么什么时候应该使用完整的for循环呢?最好的场景就是迭代不需要从第一个元素开始直至最后一个元素。虽然也可以在for-range循环中使用ifcontinuebreak的组合达到同样的目的,但标准的for循环更清晰地标记了循环的开始和结束情况。比较下面两个代码片段,它们都从数组中倒数第二个元素进行遍历。首先是for-range循环:

下面是标准for循环的代码:

显而易见,标准的for循环其代码更简短,可读性更强。

 此模式无法跳过字符串的开头。因为标准的for循环不能正确处理多字节字符[8],如果需要跳过字符串中的一些字符,建议使用一个for-range循环。

其他两个for语句格式的使用频率较低。比如,使用条件比较的for循环与它所替代的while循环一样,只有在基于计算值进行循环时才比较有用。

无限for循环在特定场景中比较有用。单循环体的某个地方应该有break的条件,因为很少需要永远循环。真实程序应该在一定范围内限制循环,并在操作不能完成时优雅地退出。如前所述,可以将无限for循环与if语句结合使用,以模拟do语句。无限for循环也被用来实现某些版本的迭代器模式,详见11.1节。


[1]对于简单逻辑,可以使用k和i;对于较长和逻辑复杂的代码,建议使用更有描述性的名称,如for i,item := range items,迭代变量为复数名,值为单数,这样的代码描述性更强。

[2]因为迭代映射类型时,可以通过键来访问值。

[3]Go 1.0后将映射迭代改进为了无序的。

[4]哈希碰撞攻击会导致所有的键都映射到一个桶里,在这种情况下,当有新数据插入时,整个哈希查找非常缓慢,服务器的CPU利用率就会飙升。

[5]简单地说,Unicode是字符集,UTF-8是编码规则,Unicode字符集为每个字符分配一个码位,UTF-8顾名思义是一套以8位为一个编码单位的可变长编码,会将一个码位编码为1~4个字节。最早计算机使用ASCII编码,但是随着计算机的普及,它不足以表示世界上各种语言甚至基本符号的编码,后来为了统一字符的编码,就有了Unicode编码。

[6]这就是为什么打印“apple_π!”时索引跳过了7,因为“π”占用了2个字节,所以在索引为6的数据后的下一排是索引8的数据。

[7]for循环需要使用变量i,并判断i的长度,递增或者递减i。

[8]前面的“apple_π!”中的“π”占用索引6、索引7位置的两个字节,所以在for循环中当索引循环到6、7时,无法知道这两个字节实际是一个字符。