4.1 基本流程控制
本节主要介绍大多数现代编程语言都会囊括的流程控制语句。当然,Go语言在流程控制和各种语句的编写方面有它自己的规则和特点。 下面,我们就逐一对它们进行介绍。
4.1.1 代码块和作用域
在介绍各种流程控制语句之前,我们先来了解一下什么是代码块。我们在前面的章节中多次提到过代码块,那么到底什么是代码块呢?
代码块就是一个由花括号“{”和“}”括起来的若干表达式和语句的序列。当然,代码块中也可以不包含任何内容,即为空代码块。
在Go语言的源代码中,除了显式的代码块之外,还有一些隐式的代码块,说明如下。
所有Go语言源代码形成了一个最大的代码块。这个最大的代码块也被称为全域代码块。
每一个代码包都是一个代码块,即代码包代码块。它们分别包含了当前代码包内的所有Go语言源代码。
每一个源码文件都是一个代码块,即源码文件代码块。它们分别包含了当前文件内的所有Go语言源码。
每一个
if
语句、for
语句、switch
语句和select
语句都是一个代码块。每一个在
switch
或select
语句中的子句都是一个代码块。
我们之前说过,每一个标识符都有它的作用域。在Go语言中,使用代码块表示词法上的作用域范围,具体规则如下。
一个预定义标识符的作用域是全域代码块。
代表了一个常量、类型、变量或函数(不包括方法)的、被声明在顶层的(即在任何函数之外被声明的)标识符的作用域是代码包代码块。
一个被导入的代码包的名称的作用域是包含该代码包导入语句的源码文件代码块。
一个代表了方法接收者、方法参数或方法结果的标识符的作用域是方法代码块。
对于一个代表了常量或变量的标识符,如果它被声明在函数内部,那么它的作用域总是包含它的声明的那个最内层的代码块。
对于一个代表了类型的标识符,如果它被声明在函数内部,那么它的作用域就是包含它的声明的那个最内层的代码块。
此外,我们可以在某个代码块中对一个已经在包含它的外层代码块中声明过的标识符进行重声明。并且,当我们在内层代码块中使用这个标识符的时候,它代表的总是它在内层代码块中被重声明时与它绑定在一起的那个程序实体。也就是说,在这种情况下,在外层代码块中声明的那个同名标识符被屏蔽了。例如,有这样一个命令源码文件:
package main
import (
"fmt"
)
var v string = "1, 2, 3"
func main() {
v := []int{1, 2, 3}
if v != nil {
var v int = 123
fmt.Printf("%v\n", v)
}
}
当我们运行这个命令源码文件后,标准输出上会打印出什么内容呢?又或者,对它的编译是否会成功呢?读者可以根据上面的规则先自己思考一会儿。
答案揭晓:打印的内容是123
。在这个命令文件中,我们首先在顶层代码块中声明了一个变量v
,然后在main
函数的代码块中的第一个行也声明了一个名为v
的变量。此时,在main
函数内部的变量v
屏蔽了顶层的变量v
。
我们在3.3.3节讲过,基本数据类型的值都无法与空值nil
进行进行判等。之所以main
函数中的第二行代码没有造成编译错误,就是因为这里的v
代表的是一个切片类型值而不是一个string
类型值。
我们再来看if
代码块中的代码。其中的第一行代码用于声明一个名为v
的变量(又是一个)。这个变量v
屏蔽了在main
函数中的第一个行声明那个变量v
。现在,在if
代码块内部,v
代表的已经是一个int
类型值了,而不是一个切片类型值,也不是一个string
类型值。因此,if
代码块中的第二行代码会向标准输出上打印的内容是123
。
我们现在了解了代码块的含义和分类,以及标识符的作用域的推导方法。这对于我们编写稍具规模的Go语言程序非常重要。这些知识和规则会指导我们编写正确的代码。
4.1.2 if
语句
Go语言中的if
语句会根据一个布尔类型的表达式的结果来执行两个分支中的一个。如果那个表达式的结果值是true
,那么if
分支会被执行,否则else
分支会被执行。
1. 组成和编写方法
Go语言的if
语句总是以关键字if
开始。在这之后,可以后跟一条简单语句(当然也可以没有),然后是一个作为条件判断的布尔类型的表达式以及一个用花括号“{”和“}”括起来的代码块。
常用的简单语句包括短变量声明、赋值语句和表达式语句。除了特殊的内建函数和代码包unsafe
中的函数,针对其他函数和方法的调用表达式和针对通道类型值的接收表达式都可以出现在语句上下文中。换句话说,它们都可以称为表达式语句。在必要时,我们还可以使用圆括号“(”和“)”将它们括起来。其他的简单语句还包括发送语句、自增/自减语句和空语句。我们在后面的章节中会陆续介绍它们。
回归正题。根据上面描述的if
语句的组成结构,我们可以很轻松地写出最简单的if
语句,例如:
if 100 < number {
number++
}
当然,if
语句也可以有else
分支,它由else
关键字和一个用花括号“{”和“}”括起来的代码块。例如:
if 100 < number {
number++
} else {
number--
}
可能读者已经注意到了,其中的条件表达式100 < number
并没有被圆括号括起来。实际上,这也是Go语言的流程控制语句的特点之一。另外,跟在条件表达式和else
关键之后的两个代码块必须由花括号“{”和“}”括起来。这一点是强制的,不论代码块包含几条语句以及是否包含语句都是如此。
Go语言建议我们不要把括有代码块的花括号写在同一个代码行上。也就是说,左花括号“{”、其中的语句和右花括号“}”应该存在于不同的代码行上,即使我们编写的是最简单的那种if
语句。这是一种良好的编码风格,我们理应这样去做,特别是当代码块中包含像return
语句这样的终止语句的时候。
因为if
语句可以接受一条初始化子语句,所以我们常常会使用它来初始化一个变量:
if diff := 100 - number; 100 < diff {
number++
} else {
number--
}
可以看到,初始化子句和条件表达式之间是需要用分号“;”分隔的。
与其他高级编程语句相同,Go语言的if
语句也支持串联。例如:
if diff := 100 - number; 100 < diff {
number++
} else if 200 < diff {
number--
} else {
number -= 2
}
正如上面的示例所展示的那样,我们需要把第二条if
语句追加到第一条if
语句中的else
关键字的后面。同样地,在它们后面还可以追加一个else
分支。如果我们要再串联第三条if
语句,方法也是如此。原则上,我们可以串联任意多个if
语句。不过要注意,我们把被串联在一起的if
语句看作一个整体。所有的if
语句中的条件表达式会共同实现条件筛选的功能。例如,在上面的示例中,当变量diff
的值小于100
时,第一个分支会被执行。而当变量diff
的值不小于100
但小于200
时,第二个分支会被执行。若以上条件都不满足,则第三个分支会被执行。
在上面的示例中,我们看到了两个特殊符号:++
和—
。它们分别代表了自增语句和自减语句。注意,它们并不是操作符。++
的作用是把它左边的标识符代表的值与无类型常量1
相加并将结果再赋给左边的标识符,而—
的作用则是把它左边的标识符代表的值与无类型常量1
相减并将再结果赋给左边的标识符。也就是说,自增语句number++
与赋值语句number = number + 1
具有相同的语义,而自减语句number—
则与赋值语句number = number - 1
具有相同的语义。另外,在++
和—
左边的并不仅限于标识符,还可以是任何可被赋值的表达式,比如应用在切片类型值或字典类型值之上的索引表达式。
2. 更多惯用法
由于在Go语言中一个函数可以返回多个结果,因此我们常常会把在函数执行期间出现的常规错误也作为结果之一。这已经成为了编写Go语言程序的一个惯例。
例如,标准库代码包os
中的函数Open
就是这样的一个函数。它的声明如下:
func Open(name string) (file *File, err error)
函数os.Open
返回的第一个结果是与已经被“打开”的文件相对应的*File
类型的值,而第二个结果则是代表了常规错误的error
类型的值。我们在之前说过,error
是一个预定义的接口类型。所有实现它的类型都应该被用于描述一个常规错误。
在导入代码包os
之后,我们可以像这样调用其中的Open
函数:
f, err := os.Open(name)
在通常情况下,我们应该先去检查变量err
的值是否为nil
。如果变量err
的值不为nil
,那么就说明os.Open
函数在被执行过程中发生了错误。这时的f
变量的值肯定是不可用的。这已经是一个约定俗成的规则了。因此,调用os.Open
函数的前4行代码一般都会是这样的:
f, err := os.Open(name)
if err != nil {
return err
}
总之,if
语句常被用来检查常规错误。
另外,if
语句常被作为卫述语句。卫述语句是指被用来检查关键的先决条件的合法性并在检查未通过的情况下立即终止当前代码块的执行的语句。其实,在上一个示例中的if
语句就是卫述语句中的一种。它在有错误发生的时候立即终止了当前代码块的执行并将错误返回给外层代码块。另一个例子是这样的:
func update(id int, deptment string) bool {
if id <= 0 {
return false
}
// 省略若干条语句
return true
}
在函数update
开始处的那条if
语句就属于卫述语句。我们还可以对这个函数稍加改造一下,像这样:
func update(id int, deptment string) error {
if id <= 0 {
return errors.New("The id is INVALID!")
}
// 省略若干条语句
return nil
}
如此一来,update
函数返回的结果不但可以表示在函数执行期间是否发生了错误,而且还可以体现出错误的具体描述。不过,这需要我们事先导入标准库的代码包errors
。
我们在介绍if
语句的典型应用场景的同时,还透露了一部分常规错误生成和处理的方法。了解与程序异常处理有关的更多细节,请参见4.3节。
4.1.3 switch
语句
与if
语句类似,switch
语句也提供了一种多分支执行的方法。它会用一个表达式或一个类型说明符与每一个case
进行比较并决定执行哪一个分支。
1. 组成和编写方法
语句switch
可以使用表达式或者类型说明符作为case
判定方法。因此,switch
语句也就可以被分成两类:表达式switch
语句和类型switch
语句。在表达式switch
语句中,每一个case
携带的表达式都会与switch
语句要判定的那个表达式(也被称为switch
表达式)相比较。而在类型switch
语句中,每一个case
所携带的不是表达式而是类型字面量,并且switch
语句要判定的目标也变成了一个特殊的表达式。这个特殊表达式的结果是一个类型而不是一个类型值。下面我们分别对这两种switch
语句进行说明。
2. 表达式switch
语句
在表达式switch
语句中,switch
表达式和case
携带的表达式(也被称为case
表达式)都会被求值。对这些表达式的求值是自左向右、自上而下进行的。第一个与switch
表达式的求值结果相等的case
表达式所关联的那个分支会被执行,而其他分支会被忽略。如果没有找到匹配的case
表达式并且存在default case
,那么default case
所关联的那个分支会被执行。default case
最多只能有一个,并且它并不是必须作为switch
语句的最后一个case
出现。此外,如果在switch
语句中没有显式的switch
表达式,那么true
将会被作为switch
表达式。
我们先来看switch
语句的一个简单形式:
switch content {
default:
fmt.Println("Unknown language")
case "Python":
fmt.Println("A interpreted Language")
case "Go":
fmt.Println("A compiled language")
}
一般情况下,switch
关键字之后会紧跟一个switch
表达式。这种情况下,switch
表达式中涉及的标识符都必须是已经被声明过的。作为更复杂一点的形式,我们还可以在这两者之间插入一条简单语句。像这样:
switch content := getContent(); content {
default:
fmt.Println("Unknown language")
case "Python":
fmt.Println("A interpreted Language")
case "Go":
fmt.Println("A compiled language")
}
在这个示例中,我们在switch
语句中先调用了getContent
函数,并且把它的结果值赋给了新声明的变量content
,后面紧接着的就是对content
的值的判定过程。注意,简单语句content := getContent()
会在switch
表达式content
被求值之前被执行。
现在来看case
语句。一条case
语句由一个case
表达式和一个语句列表组成,并且这两者之间需要用冒号“:”分隔。在上例的switch
语句中,一共有3个case
语句。注意,default case
是一种特殊的case
语句。
一个case
表达式由一个case
关键字和一个表达式列表组成。注意,这里说的是一个表达式列表,而不是一个表达式。这意味着,一个case
表达式中可以包含多个表达式。现在,我们利用这一特性来改造一下上面的switch
语句,改造结果如下:
switch content := getContent(); content {
default:
fmt.Println("Unknown language")
case "Ruby", "Python":
fmt.Println("A interpreted Language")
case "C", "Java", "Go":
fmt.Println("A compiled language")
}
大家知道,解释型编程语言和编译型编程语言都不止一个。所以,我们把几个解释型编程语言的名称放在同一个case
表达式中,而把几个编译型编程语言都放到另一个case
表达式中。每一个代表了编程语言名称的string
类型值都作为了一个独立的表达式。在同一条case
表达式中,多个表达式之间需要用逗号“,”分隔。当然,我们也可以把每一个string
类型值都单独放在一个case
表达式中。
在一条case
语句中的语句列表的最后一条语句可以是fallthrough
语句。在一条表达式switch
语句中,一条fallthrough
语句会将流程控制权转移到下一条case
语句上。fallthrough
语句极其直接和简单,仅由英文单词fallthrough
组成。请看下面的示例:
switch content := getContent(); content {
default:
fmt.Println("Unknown language")
case "Ruby":
fallthrough
case "Python":
fmt.Println("A interpreted Language")
case "C", "Java", "Go":
fmt.Println("A compiled language")
}
这个示例是上一个示例的重构版本。其中的代码的功能与上一个示例中代码的功能是完全一致的。原来的表达式列表"Ruby", "Python"已经被拆分到了两个case语句当中。并且,包含了表达式"Ruby"的case语句的语句列表只包含了一条fallthrough语句,而在包含表达式"Python"的case语句的语句列表中包含了原先与case表达式case "Ruby", "Python"对应的那条语句。虽然有了这些更改,但是当变量content的值与"Ruby"相等的时候,在标准输出上打印出的内容依然会是A interpreted Language。也就是说,虽然content
的值与"Ruby"相等,但是与case "Python"对应的语句列表也会被执行。这是因为在case "Ruby"的语句列表的最后,fallthrough语句使流程控制权得以流转到了case "Python"上。不过,要注意的是,这种控制权流转并不存在传递性。在上面的示例中,当content的值为"Ruby"时,case "C", "Java", "Go"的语句列表一定不会被执行。另外,fallthrough语句只能够作为case语句中的语句列表的最后一条语句。更重要的是,fallthrough语句不能出现在最后一条case语句的语句列表中。
此外,break
语句也可以出现在case
语句中的语句列表中。一条break
语句由一个break
关键字和一个可选的标记组成。如果这两者都存在,那么它们之间应该有空格“ ”分隔。例如:
switch content := getContent(); content {
default:
fmt.Println("Unknown language")
case "Ruby":
break
case "Python":
fmt.Println("A interpreted Language")
case "C", "Java", "Go":
fmt.Println("A compiled language")
}
在break
语句被执行后,包含它的switch
语句、for
语句或select
语句的执行会被立即终止。流程控制权将会被转移到这些语句后面的语句上。请读者修改一下上面示例中的代码,使当content
的值为"Ruby"
或"Python"
的时候,不输出任何内容而直接结束当前的switch
语句的执行。这要求使用break
语句来做,当然也可以用上fallthrough
语句。
包含标记的break
语句是与标记(Label
)语句一起配合使用的。这个我们后面再讲。
3. 类型switch
语句
类型switch
语句将对类型进行判定,而不是值。在其他方面,它都与表达式switch
语句如出一辙。类型switch
语句中的switch
表达式很特殊。这个switch
表达式的表现形式与类型断言表达式有几分相似。但是与类型断言表达式不同的是,它使用关键字type
来充当欲判定的类型,而不是使用一个具体的类型字面量。下面是一个简单的例子:
switch v.(type) {
case string:
fmt.Printf("The string is '%s'.\n", v.(string))
case int, uint, int8, uint8, int16, uint16, int32, uint32, int64, uint64:
fmt.Printf("The integer is %d.\n", v)
default:
fmt.Printf("Unsupported value. (type=%T)\n", v)
}
在阅读这段示例之前,如果读者不知道或者忘记了类型断言表达式是怎么一回事,请先去3.1.6节寻找和学习一下相关知识。因为,类型断言对于正确理解这段示例代码很重要。
我们先从形式上介绍一下类型switch
语句的特点,请结合上面的示例来理解下面的内容。首先,它的switch
表达式会包含一个特殊的类型断言表达式,例如v.(type)
。这个我们刚刚已经说过。其次,每个case
表达式中包含的都是类型字面量而不是表达式,处于同一个case
表达式中的多个类型字面量之间同样也需要用逗号“,”分隔。请看上面示例中的前两个case
表达式。
现在我们来具体分析这段示例代码。这条类型switch
语句共包含了3条case
语句。第一条case
语句中的case
表达式包含了类型字面量string
。这就意味着,如果v
的类型是string
类型,那么该分支就会被执行。在这个分支中,我们使用类型断言表达式v.(string)
把v
的值转换成了string
类型的值,并以特定格式打印出来。第二条case
语句中的类型字面量有多个,包括了所有的整数类型。这就意味着只要v
的类型属于整数类型,该分支就会被执行。注意,byte
类型是uint8
类型的别名类型,而rune
类型则是int32
类型的别名类型。因此,如果v
是byte
类型或rune
类型的,第二个分支也会被执行。由于任何整数类型都不能表示Go所支持的全部范围的整数(例如,int64
类型和uint64
类型的数值范围都很大且双方有很大重叠,但是其中一方的数值范围依然不能完全覆盖全部的数值范围),因此在这个分支中,我们并没有使用类型断言表达式把v
的值转换成任何一个整数类型的值,而是利用fmt.Printf
函数直接打印出了v
所表示的整数值(注意格式化字符串中的%d
)。如果v
的类型既不是string
类型也不是整数类型,那么default case
的分支将会被执行。此分支中的那行语句会在标准输出上打印出提示内容并附上v
的动态类型(注意格式化字符串中的%T
)。顺便提一下,我们在这个示例中展现了fmt.Printf
函数的一部分使用方法。关于传递给它的第一个参数中的%s
、%d
和%T
的含义以及关于它的使用说明,请读者参看Go语言官方文档网站中的代码包fmt
的文档页面。在那里,读者还可以看到该代码包中的其他打印函数。我们在后面的章节中会陆续用到这些打印函数。
我们在编写类型switch
语句的时候,需要遵守两个特殊规则。首先,变量v
的类型必须是某个接口类型。这也是理所当然的。如果v
的具体类型已经确定了,那么我们也就没必要用类型switch
语句来判定它了。其次,case
表达式中的类型字面量必须是v
的类型的实现类型。一个通用的方案是,把变量v
的类型设置为interface{}
(空接口)类型。由于任何Go语言数据类型都是interface{}
的实现类,因此这样就等于支持最广义的类型判定了。尤其是当我们判定v
的类型是否为某个或某些基础数据类型的时候,应该也必须这样做。
与表达式switch
语句相同,我们在类型switch
语句中的switch
关键字和switch
表达式之间也可以插入一条简单语句。另外,在类型switch
语句中,case
表达式中的类型字面量可以是nil
。在前面的那个示例中,如果v
的值是nil
,那么表达式v.(type)
的结果值也会是nil
。因此,当这种情况发生时,如果存在包含了nil
的case
表达式,那么与它相对应的那个分支就会被执行。
与表达式switch
语句不同的是,fallthrough
语句不允许出现在类型switch
语句中的任何case
语句的语句列表中。这一点需要特别注意。
最后,值得特别提出的是,类型switch
语句的switch
表达式还有一种变形写法。我们使用这个变形写法对前面示例中的类型switch
语句进行了重构,如下:
switch i := v.(type) {
case string:
fmt.Printf("The string is '%s'.\n", i)
case int, uint, int8, uint8, int16, uint16, int32, uint32, int64, uint64:
fmt.Printf("The integer is %d.\n", i)
default:
fmt.Printf("Unsupported value. (type=%T)\n", i)
}
我们看到,现在处在switch
表达式的位置上的是i := v.(type)
。这实际上是一个短变量声明。当存在这种形式的switch
表达式的时候,就相当于这个变量(这里是i
)被声明在了每个case
语句的语句列表的开始处。在每个case
语句中,变量i
的类型都是不同的。它的类型会和与它处于同一个case
语句的case
表达式包含的类型字面量所代表的那个类型相等。例如,在上面的示例中,第一个case
语句相当于:
case string:
i := v.(string)
fmt.Printf("The string is '%s'.\n", i)
如果相应的case
表达式包含多个类型字面量,那么它的类型会与表达式v.(type)
的求值结果所代表的类型一致。例如,如果v
的动态类型是uint16
类型,那么第二个case
语句相当于:
case int, uint, int8, uint8, int16, uint16, int32, uint32, int64, uint64:
i := v.(uint16)
fmt.Printf("The integer is %d.\n", i)
综上所述,这种形式的switch
表达式为我们提供了便利。我们不再需要在每个case
语句中分别对那个欲判定类型的值进行显式地类型转换了。
4. 更多惯用法
除了前面讲到的一些常规用法之外,我们还可以把switch
语句作为串联的if
语句的一种替代品。这需要使用switch
语句的另一种变形来实现。这种变形去掉了switch
语句在通常情况下会包含的switch
表达式。在switch
表达式缺失的情况下,该switch
语句的判定目标会被视为布尔类型值true
。也就是说,其中的所有case
表达式的结果值都应该是布尔类型的。并且,自上而下,第一个结果值为true
的case
表达式所对应的分支会被执行。例如:
switch {
case number < 100:
number++
case number < 200:
number--
default:
number -= 2
}
假设number
是整数类型的。当number
小于100
时第一个分支会被执行,而当number
不小于100
但小于200
时第二个分支会被执行,否则第三个分支会被执行。请读者仔细阅读这条switch
语句,它的功能与我们在上一小节讲串联if
语句时给出的那个if
语句的功能完全一致。由此可知,这种switch
语句的变形完全可以替代串联if
语句,并且还能够提供更好的代码可读性。
作为switch
语句的一种变形,在它的switch
关键字和switch
表达式之间也可以有一条简单语句。虽然这种switch
语句并没有switch
表达式, 但是为了不让Go语言和代码阅读者把这条简单语句误认为switch
表达式,我们还是需要在该条简单语句的后面加上分号“;”,尽管这样看起来会有些奇怪。例如:
switch number := 123; {
case number < 100:
number++
case number < 200:
number--
default:
number -= 2
}
我们在前面介绍的各种switch
语句都有各不相同的应用场景。只要我们真正地理解了它们所代表的含义,就可以根据实际需要正确地选用它们。
4.1.4 for
语句
一条for
语句会根据既定的条件重复执行一个代码块。这种重复执行一个代码块的行为也称为循环或迭代。迭代的开始和结束是受到既定条件的控制的。这个条件或由for
子句直接给出,或从range
子句中获得。
1. 组成和编写方法
一个最简单的for
语句形式是,for
语句一直重复执行一个代码块直到作为条件的表达式的求值结果为false
。这个条件会在每次执行该代码块之前被求值。如果没有显式地指定该条件,那么true
将会被作为缺省的条件。在这种情况下,如果在被重复执行的代码块中不存在break
语句或者break
语句总是没有被执行的机会,那么就产生了一个无限循环(或称死循环)。请看如下示例:
// number是一个int类型的变量
for number < 200 { // 当number大于等于200时for循环会退出。
number += 2
}
for { // 很不幸,这是一个死循环,该代码块永远会被重复的执行下去……
number++
}
2. for
子句
一条for
语句可以携带一个for
子句,并可以使用这个for
子句提供的条件来对迭代进行控制。除了条件,for
子句还可以包含一条用来初始化上下文的简单语句(以下简称初始化子句)和一条用来为代码块的执行做后置处理的简单语句(以下简称后置子句)。for
子句的这3个部分是有固定排列顺序的,即初始化子句在左、条件在中、后置子句在右。并且,它们之间需要用分号“;”来分隔。我们可以在编写for
子句的时候省略掉其中的任何部分。但是,为了避免歧义,即使其中的一个部分被省略掉了,与它相邻的分隔符“;”也必须被保留。
在一般情况下,初始化子句为赋值语句或短变量声明,而后置子句则为自增语句或自减语句。当然,它们也可以是别的简单语句。但是要注意,后置语句一定不能是短变量声明。另外,初始化子句总会在充当条件的表达式被第一次求值之前执行,且只会执行一次,而后置子句的执行总会在每次代码块执行完成之后紧接着进行。
下面我们来看一组示例:
for i := 0; i < 100; i++ {
number++
}
var j uint = 1
for ; j%5 != 0; j *= 3 { // 省略初始化子句
number++
}
for k := 1; k%5 != 0; { // 省略后置子句
k *= 3
number++
}
在for
子句的初始化子句和后置子句同时被省略或者其中的所有部分都被省略的情况下,分隔符“;
”可以被省略。这时,for
语句的形式就和我们在本小节开始处描述的相同了。
3. range
子句
一条for
语句可以携带一个range
子句,从而可以迭代出一个数组或切片值中的每个元素、一个字符串值中的每个字符或者一个字典值中的每个键值对。甚至,它还可以被用于持续接收一个通道类型值中的元素。随着迭代的进行,每一次被获取出的迭代值(元素、字符或键值对)都会被赋给相应的迭代变量,然后这些迭代变量将会被带入马上要被执行的for
语句的代码块中。例如:
ints := []int{1, 2, 3, 4, 5}
for i, d := range ints {
fmt.Printf("%d: %d\n", i, d)
}
又例如:
var i, d int
for i, d = range ints {
fmt.Printf("%d: %d\n", i, d)
}
可以看到,range
子句由3部分组成。其中,range
关键字总是会处于中间的位置上。在range
关键字右边的应该是一个表达式。这个表达式常被称为range
表达式。其结果值可以是一个数组值、一个指向数组值的指针值、一个切片值、一个字符串值或者一个字典值,也可以是一个允许接收操作的通道类型值。注意,一般情况下,range
表达式只会在迭代开始前被求值一次。当然,例外情况是存在的,不过我们一会儿再对此进行说明。
在range
关键字左边的是相应的表达式列表或是标识符列表。如果是表达式列表或不包含未被声明过的标识符的标识符列表,那么在该列表与range
关键字之间就必须由赋值操作符=
分隔。如果是包含了未被声明过的标识符的标识符列表,那么在该列表与range
关键字之间就必须由赋值操作符:=
分隔。显然,前者代表普通赋值,后者代表声明并赋值。不论怎样,左边列表中的每一个元素都代表了一个迭代变量。它们会在每一次迭代的时候被重用(重新赋值或重新声明并赋值)。对于未被声明过的标识符,它所代表的变量的类型会与相应的迭代值的类型相等,并且它的作用域是包含其声明的for
语句。对于已被声明过的标识符和表达式,它们的类型与相应的迭代值之间必须满足赋值规则。并且,在for
语句中对它们的更改不会因for
语句的执行结束而失效。例如,在下面的for
语句中,在range
关键字和赋值操作符左边的就是一个表达式列表:
ints := []int{1, 2, 3, 4, 5}
length := len(ints)
indexesMirror := make([]int, length)
elementsMirror := make([]int, length)
var i int
for indexesMirror[length-i-1], elementsMirror[length-i-1] = range ints {
i++
}
与range
表达式不同,在range
关键字左边的表达式列表中的表达式在每一次迭代的时候都会被求值一次。也就是说,它们被求值的次数与for
语句的代码块被执行的次数相同。并且,对它们的求值总是会在代码块被执行之前进行。此外,由于每一次迭代的产出值(也就是迭代值)都与迭代变量共同组成了赋值语句,所以它们也具备赋值语句所拥有的一切特性,比如赋值的执行阶段和赋值顺序。
到这里,读者可能会有一个疑问,为什么我们在刚刚的示例中每次可以从切片值中迭代出两个值?
实际上,对于切片值来说,携带range
子句的for
语句每次迭代出的那两个值并不都是该切片值中的元素。并且,随着range
表达式的结果值的不同,range
子句会有不同的表现,具体如下。
对于一个数组值、一个指向数组值的指针值或一个切片值
a
来说,range
循环的迭代产出值可以是一个也可以是两个。并且,迭代的顺序是与索引值(也就是第一个迭代值)的递增顺序一致的。如果只产出一个迭代值,那么range
循环产生的迭代值会是从0
到len(a)-1
的多个int
类型的索引值,并且不会发生根据索引值定位元素值的动作。另外,如果切片值为nil
,那么迭代次数将会是0。对于一个字符串值,
range
子句将会遍历其中的所有Unicode代码点。我们知道,在底层,字符串值其实是由其中的每个字符的UTF-8编码值组成并存储的。一个UTF-8编码值既可以由一个rune
类型值代表,也可以由一个[]byte类型值代表。因此,我们可以把一个字符串值看成一个[]rune类型值或一个[]byte类型值。图4-1展示了这三者之间的对应关系。
图 4-1 range迭代与字符串
由图4-1可知,对于一个字符串值来说,在一个连续的迭代之上产出的索引值(第一个迭代值)即是其中某一个Unicode代码点(与rune
类型值一一对应)的UTF-8编码值中的第一个字节在与其所属的[]byte类型值上的索引值。对照图4-1,当range
表达式的结果值是字符串值"Golang爱好者"
时,range
子句的第一次迭代的第一个迭代值为int
类型值0
,第二个迭代值(若需要)为rune
类型值'G'
。而它的第八次迭代的第一个迭代值为int
类型值9
,第二个迭代值(若需要)为rune
类型值'好'
。注意,当迭代遭遇非法的UTF-8编码值时,第二个迭代值就会是'
'
(对应的Unicode代码点为U+FFFD),且下一次迭代将会从在该非法UTF-8编码值之后的第一个字节开始。
对于一个字典值来说,它的迭代顺序是不固定的。如果字典值中的键值对在还没有被迭代到的时候就被删除了,那么相应的迭代值将不会被产出。另一方面,如果我们在字典值被迭代过程中向其添加了新的键值对,那么相应的迭代值是否会被产出是不确定的。对字典值迭代的产出值的数量可以是一个也可以是两个。如果字典值为
nil
,那么迭代次数将会是0。对于通道类型值,这种迭代的效果类似于连续不断的从该通道中接收元素值,直到该通道被关闭。并且,对通道类型值的迭代每次都只会产出一个值。注意,如果通道类型值为
nil
,那么range
表达式将会被永远地阻塞!
为了方便快速查询,我们在对上面的描述进行了简化并绘制了表4-1。
表4-1 range
子句的迭代产出
range 表达式的类型 | 第一个产出值 | 第二个产出值(若显式获取) | 备注 |
---|---|---|---|
a :[n]E 、*[n]E 或[]E | i :int 类型的元素索引值 | 与索引对应的元素的值a[i] ,类型为E | a 为range 表达式的结果值。n 为数组类型的长度。E 为数组类型或切片类型的元素类型 |
s :string 类型 | i :int 类型的元素索引值 | 与索引对应的元素的值s[i] ,类型为rune | s 为range 表达式的结果值 |
m/ :map[K]V | k :键值对中的键的值,类型为K | 与键对应的元素值m[k] ,类型为V | m 为range 表达式的结果值。K 为字典类型的键的类型。V 为字典类型的元素类型 |
c :chan E 或 | e :元素的值,类型为E | c 为range 表达式的结果值。E 为通道类型的元素的类型 |
综上所述,如果range
表达式的求值结果是一个通道类型值,那么仅会产出一个迭代值。也就是说,这时在range
关键字和赋值操作符左边的表达式或标识符就只能有一个。否则,产出的迭代值可以是一个也可以是两个,这取决于在range
关键字和赋值操作符左边的表达式或标识符的数量。例如,下面的这个for
语句与我们在讲range
子句时的第一个示例中的那个for
语句在语义上是等价的:
ints := []int{1, 2, 3, 4, 5}
for i := range ints {
d := ints[i]
fmt.Printf("%d: %d\n", i, d)
}
如果range
表达式的结果类型是某个数组类型或某个指向数组值的指针类型,同时它只被要求产出第一个迭代值,那么这个range
表达式就只会被部分求值。这是什么意思呢?我们都知道数组值的长度是其类型的一部分。因此,对于上面这类情况,我们只需要求得到range
表达式的结果的类型就可为后续迭代提供足够的支持了。当这个长度是常量的时候,该range
表达式将不会被求值。此处的“长度是常量”的意思是,可以推断在该range
表达式的求值结果上应用内建函数len
所得到的结果一定是一个常量。这个推断的方法我们在上一章讲内建函数len
的时候已经介绍过,这里就不再赘述了。
4. 更多惯用法
我们在前面说过,对于所有可迭代的数据类型的值来说,我们都可以要求每次迭代只产出第一个迭代值。例如:
m := map[uint]string{1: "A", 6: "C", 7: "B"}
var maxKey uint
for k := range m {
if k > maxKey {
maxKey = k
}
}
但是,我们并没有介绍怎样忽略掉第一个迭代值而只使用第二个迭代值的方法。有些遗憾,与for
语句相关的语法中并没有针对此问题的解决方法。并且,将第一个迭代值赋给迭代变量但不使用它也不是一个可行的办法。这会造成一个编译错误。因为Go语言编译器不允许程序中有未被使用的变量出现。不过,如果我们稍稍转变一下思考角度的话,这个问题就相当好解决了,也许读者早已经想到了这个方法。既然说迭代值和迭代变量之间是赋值和被赋值的关系,那么我们当然可以把迭代值赋给一个空标识符。这一点在我们先前讲的赋值规则的时候已有说明。这样也可以避免编译错误的发生。我们同样以前一个示例中的变量m
为例,如下:
var values []string
for _, v := range m {
values = append(values, v)
}
这种做法在for
语句的编写过程中是很常用的。
在for
语句中,我们还可以使用break
语句来终止for
语句的执行。若有一个变量namesCount
,它的声明如下:
var namesCount map[string]int
这个变量的值包含了某个网站的所有用户昵称及其重复次数(用户昵称可以重复)。也就是说,这个字典值的键表示用户昵称,而值则代表了使用该昵称的用户的数量。现在我们想从中查找到所有的只包含中文的用户昵称的计数信息。这一需求的简单实现如下:
targetsCount := make(map[string]int)
for k, v := range namesCount {
matched := true
for _, r := range k {
if r < '\u4e00' || r > '\u9fbf' {
matched = false
break
}
}
if matched {
targetsCount[k] = v
}
}
在上面这段代码中,我们使用了嵌套的for
语句。外层的for
语句对变量namesCount
的值进行迭代,也就是说它会遍历其中的每一个键值对。而内层的for
语句则对每个用户昵称中的每个字符进行遍历。如果用户昵称中包含了非中文字符,那么我们会设置一个标志(由变量matched
代表)并且终止内层的for
循环。只有在标志的值为true
时,我们才会把相应的键值对添加到变量targetsCount
中。break
语句只会终止直接包含它的那条for
语句的执行。因此,当碰到一个非全中文的用户昵称时,我们虽然使用break
语句终止了内层for
语句的执行,但是当外层for
语句的代码块被执行完毕后,它的下一次迭代仍然会进行。
现在我们稍微改动一下上面的需求,加上一个限制条件:发现第一个非全中文的用户昵称的时候就停止查找。刚才提到,break
语句只能终止直接包含它的那条for
语句的执行。那么我们怎样在发现第一个非全中文的用户昵称之后就直接终止外层for
语句的执行呢?最简单的解决方法是使用一个作为辅助标志的变量和两个break
语句,代码如下:
targetsCount := make(map[string]int)
for k, v := range namesCount {
matched := true
for _, r := range k {
if r < '\u4e00' || r > '\u9fbf' {
matched = false
break
}
}
if !matched {
break
} else {
targetsCount[k] = v
}
}
这段代码与上一段代码非常类似,我们只不过对外层for
语句的代码块中的最后几行代码做了一些修改。当作为辅助标志的变量的值为false
的时候直接退出外层循环。这种做法很简单也很直观。不过,我们一定要使用那个辅助标志吗?
我们之前说过,break
语句可以与标记(Label
)语句一起配合使用。在我们改进上面的代码之前,先来介绍一下标记语句。
一条标记语句可以成为goto
语句、break
语句或continue
语句的目标。标记语句中的标记只是一个标识符,它可以被放置在任何语句的左边以作为这个语句的标签。标记和被标记的语句之间需要用冒号“:”来分隔。一个标记、一个冒号“:”和那个被标记的语句就组成了一条标记语句,就像这样:
L:
for k, v := range namesCount {
// 省略若干条语句
}
需要注意的是,既然标记也是一个标识符,那么当它在未被使用的时候也同样会造成一个编译错误。那么怎样使用标记呢?其中一种方法就是让它成为break
的目标:
L:
for k, v := range namesCount {
if v > 100 {
fmt.Printf("The matched name: %v\n", k)
break L
}
}
如上所示,我们在break
语句的后面追加了一个空格“ ”和一个标记。这就意味着,终止执行的对象就是标记代表的那条语句。因此,执行break L
语句就会终止L
标记的那条for
语句的执行,从而退出那个for
循环转而执行其后面的语句(如果有的话)。
好了,我们现在来看怎样使用break
语句和标记语句来完成我们刚刚提出的第二个需求。代码如下:
targetsCount := make(map[string]int)
L:
for k, v := range namesCount {
for _, r := range k {
if r < '\u4e00' || r > '\u9fbf' {
break L
}
}
targetsCount[k] = v
}
可以看到,与之前的那个简单的解决方法相比,for
语句中的matched
变量被删除掉了,同时还省略掉了一条if
语句。取而代之的是标记L
和携带它的break
语句。这确实减少了一些代码量,不是吗?这样的语句组合可以让我们非常方便地跳出嵌套的for
语句,而且比使用辅助标志更加清晰。
现在,让我们回到原始需求上来。还记得吗?只实现了第一个需求的代码中依然用到了作为辅助标志的matched
变量。这里的辅助标志可以去掉吗?答案是肯定的,使用continue
语句可以达到这一目的。
实际上,Go语言中的continue
语句只能在for
语句中被使用。continue
语句会使直接包含它的那个for
循环直接进入下一次迭代。也就是说,当次迭代不会执行在该continue
语句后面那些语句(它们被跳过了)而直接结束。例如,实现原始需求那段代码可以被修改成这样:
targetsCount := make(map[string]int)
for k, v := range namesCount {
matched := true
for _, r := range k {
if r < '\u4e00' || r > '\u9fbf' {
matched = false
break
}
}
if !matched {
continue
}
targetsCount[k] = v
}
在外层for
语句的代码块中的最后那几行代码被修改了。其逻辑由如果matched
的值是true
就把当前键值对添加到targetsCount
的值中改为了如果matched
的值是false
就不把当前键值对添加到targetsCount
的值中。没错,这种修改实在没什么意义。
与break
语句相同,continue
语句也可以与标记语句组合起来使用。这样一来,continue
语句的功能就会得到放大。下面我们就来看看真正的改进版本的代码:
targetsCount := make(map[string]int)
L:
for k, v := range namesCount {
for _, r := range k {
if r < '\u4e00' || r > '\u9fbf' {
continue L
}
}
targetsCount[k] = v
}
在这段代码中,我们已经不需要辅助标志了。如果continue
语句携带了标记,那么它就会使该标记代表的那个for
循环直接进入下一次迭代。在该示例中,语句continue L
使得外层的for
循环直接进入到了下一次迭代。也就是说,当它被执行的时候,外层的for
语句的当次迭代出的那个键值对不会被添加到targetsCount
的值中。这使得这段代码与实现原始需求的前两个版本的代码拥有相同的语义。通过continue
语句和标记语句的组合使用,我们用了更少的代码且更加清晰地实现了那个原始需求。不过,需要特别注意的是,在continue
语句右边的标记必须代表一条闭合的for
语句。也就是说,在这里的标记既不能代表在for
语句之外的其他语句,也不能代表在for
语句的代码块中的某条语句。
最后一个与for
语句有关的编写技巧是关于for
子句的。读者可以先想一想怎样使用Go语言的for
语句写出反转一个切片类型值中的所有元素值的代码。一个附加的限制条件是,不允许使用在for
语句之外声明的任何变量作为辅助。请读者思考一分钟。
好了,其实现代码如下:
// numbers 是一个[]int类型的变量,且其中已包含了若干元素
for i, j := 0, len(numbers)-1; i < j; i, j = i+1, j-1 {
numbers[i], numbers[j] = numbers[j], numbers[i]
}
我们已经知道,在for
子句中可以有初始化子句和后置子句。绝大多数的简单语句都可以充当初始化子句和后置子句。不过要注意,充当初始化子句和后置子句的只能是单一语句而不能是多个语句。但是,我们能够使用平行赋值的语句来丰富这两个子句的语义,就像上面展示的那样。想象一下,如果在初始化子句和后置子句中不允许出现平行赋值语句,那么我们又能怎样写出满足上述要求的实现代码呢?
至此,我们用了相当的篇幅介绍了Go语言的for
语句的编写方法和技巧。for
语句是Go语言中编写方法最多、最灵活的语句。它其中包含了很多个部分,也可以和很多其他语句组合使用。读者应该在阅读本小节中的示例的同时尝试使用for
语句去解决各种各样的问题,并体会它的不同编写方法和组合用法之间的异同,这样才能真正地理解它。
4.1.5 goto
语句
一条goto
语句会把流程控制权无条件地转移到它右边的标记所代表的语句上。
1. 组成和编写方法
实际上,goto
语句只能与标记语句连用,并且在它的右边必须要出现一个标记。
在我们理解了标记语句之后再来看goto
语句,就会发现理解和使用它是非常简单的。但是,在goto
的使用过程中有两个需要注意的地方。
第一,不允许因使用goto
语句而使任何本不在当前作用域中的变量进入该作用域。这句话可能不太好理解。我们用下面的示例来说明。
goto L
v := "B"
L:
fmt.Printf("V: %v\n", v)
在这个示例中,变量v
实际上并不能够在标记L
所指代的那条打印语句中被使用。因为语句goto L
恰恰使变量v
的声明语句被跳过了。因此,这段代码会造成一个编译错误。不过我们只需要稍加修改就可以使上面这段代码顺利通过编译。修改后的代码如下:
v := "B"
goto L
L:
fmt.Printf("V: %v\n", v)
可以看到,我们只是将原本在语句goto L
下面的那条语句移动到了goto L
语句的上面。其根本思想是,让变量v
的声明语句和使用它的代码处在相同的作用域中。当然,把变量v
的声明语句移动到包含当前作用域的外层作用域中也是可以的。总之,当goto
语句的执行致使某个或某些声明语句被跳过的时候,我们就要小心了。
第二,我们把某条goto
语句的直属代码块叫作代码块A,而把该条goto
语句右边的标记所指代的那条标记语句的直属代码块叫作代码块B。那么,只要代码块B不是代码块A的外层代码块,这条goto
语句就是不合法的。示例如下:
// n是一个int类型的变量
if n%3 != 0 {
goto L1
}
switch {
case n%7 == 0:
fmt.Printf("%v is a common multiple of 7 and 3.\n", n)
default:
L1:
fmt.Printf("%v isn't a multiple of 3.\n", n)
}
如上所示,标记L1
所指代的标记语句的直属代码块是由switch
语句代表的,而goto L1
语句的直属代码块是由if
语句代表的,并且前者并不是后者的直属代码块。因此,goto L1
是非法的。我们编译这段代码的时候会得到一个编译错误。
要修正这个错误也并不难。代码如下:
if n%3 != 0 {
goto L1
}
switch {
case n%7 == 0:
n = 200
fmt.Printf("%v is a common multiple of 7 and 3.\n", n)
default:
}
L1:
fmt.Printf("%v isn't a multiple of 3.\n", n)
可以看到,我们只是把标记L1
和它指代的那条语句移动到了switch
语句的外边而已。但是,这样的一段代码是可以顺利通过编译的。原因就在于,这时的代码块B已经是代码块A的外层代码块了。
2. 更多惯用法
我们最常见到的一个使用场景是,利用goto
语句跳出嵌套的流程控制语句的执行。这不仅限于我们在上一节涉及的嵌套for
语句。因为goto
语句几乎可以出现在任何Go语言代码块中,在这方面它与break
语句和continue
语句有很大不同。例如:
// 查找name中的第一个非法字符并返回。
// 如果返回的是空字符串就说明name中不包含任何非法字符。
func findEvildoer(name string) string {
var evildoer string
for _, r := range name {
switch {
case r >= '\u0041' && r <= '\u005a': // a-z
case r >= '\u0061' && r <= '\u007a': // A-z
case r >= '\u4e00' && r <= '\u9fbf': // 中文字符
default:
evildoer = string(r)
goto L2
}
}
goto L3
L2:
fmt.Printf("The first evildoer of name '%s' is '%s'!\n", name, evildoer)
L3:
return evildoer
}
如上所示,我们只允许变量name
的值中出现大写或小写字母以及中文字符。如果碰到不符合要求的字符就立即停止对name
的遍历,并在返回findEvildoer
函数的结果值之前先打印出一条警告信息。当然,我们也可以换一种写法,使用break
语句和if
语句替换掉那两条goto
语句,再调整一下标记的对象。修改后的代码如下:
func findEvildoer(name string) string {
var evildoer string
L2:
for _, r := range name {
switch {
case r >= '\u0041' && r <= '\u005a': // a-z
case r >= '\u0061' && r <= '\u007a': // A-z
case r >= '\u4e00' && r <= '\u9fbf': // 中文字符
default:
evildoer = string(r)
break L2
}
}
if evildoer != "" {
fmt.Printf("The first evildoer of name '%s' is '%s'!\n", name, evildoer)
}
return evildoer
}
需要注意的是,上面示例中的break
语句必须携带标记,否则它只会终止直接包含它的switch
语句的执行,而外层的for
语句的迭代依然会继续。这两个版本的findEvildoer
函数所实现的语义是完全相同的。至于哪种方法更好就是仁者见仁智者见智了。
另一个比较适合使用goto
语句的场景是集中式的错误处理,示例如下:
func checkValidity(name string) error {
var errDetail string
for i, r := range name {
switch {
case r >= '\u0041' && r <= '\u005a': // a-z
case r >= '\u0061' && r <= '\u007a': // A-z
case r >= '\u0030' && r <= '\u0039': // 0-9
case r == '_' || r == '-' || r == '.': // 其他允许的符号
default:
errDetail = "The name contains some illegal characters。"
goto L3
}
if i == 0 {
switch r {
case '_':
errDetail = "The name can not begin with a '_'."
goto L3
case '-':
errDetail = "The name can not begin with a '-'."
goto L3
case '.':
errDetail = "The name can not begin with a '.'."
goto L3
}
}
}
return nil
L3:
return errors.New("Validity check failure: " + errDetail)
}
我们可以看到,只要发现了问题,流程控制权就会被跳转到checkValidity
函数的最后一条语句上,无论检查出问题的代码处在for
语句中的哪一行上。在这里,goto
语句的优势同样在于可以非常方便地从错综复杂的流程控制语句中干脆地跳出。但是,它也存在一个劣势。这一劣势与标记语句有关。当存在多个相邻的标记语句时,除非使用额外的goto
语句,否则我们就不能阻止这些标记语句被顺序地执行。请看下面的代码:
fmt.Println("It always happens.")
Error1:
fmt.Println("Error1 occurred!")
Error2:
fmt.Println("Error2 occurred!")
在我们通过goto
语句把流程控制权跳转到标记Error1
所指代的语句之后,由于在默认情况下语句列表是会被顺序地执行的,所以标记Error2
所指代的语句也会被执行。甚至,在不发生任何流程控制权跳转的情况下,标记Error1
和Error2
所指代的语句也会在第一条语句被执行后被相继地执行。除非我们加入一些额外的goto
语句和标记作为辅助,像这样:
fmt.Println("It always happens.")
goto Post
Error1:
fmt.Println("Error1 occurred!")
goto Post
Error2:
fmt.Println("Error2 occurred!")
Post:
这显然严重影响了代码的清晰度,既增加了代码量,又对原有的代码造成了污染。
总之,虽然goto
语句在某些场景下会为我们提供更多的便利,但是它却不像其他流程控制语句那样灵活。并且,充斥着goto
语句的代码块的可读性会大大下降。所以,在很多时候,我们需要在便捷和简洁之间进行权衡,而后者往往会更占上风。我们需要有节制地使用goto
语句,这样才能够在提高开发效率的同时降低开发维护的成本。