第12章:基本流程控制语法



基本流程控制语法

Go中的流程控制语句和其它很多流行语言很类似,但是也有不少区别。 本篇文章将列出所有这些相似点和不同点。

Go中的流程控制语句简单介绍

Go语言中有三种基本的流程控制代码块:

  • if-else条件分支代码块;
  • for循环代码块;
  • switch-case多条件分支代码块。

Go中另外还有几种和特定种类的类型相关的流程控制代码块:

  • 用来遍历整数、各种容器(第18章)和通道(第21章)的for-range循环代码块。
  • 接口(第23章)相关的type-switch多条件分支代码块。
  • 通道(第21章)相关的select-case多分支代码块。

和很多其它流行语言一样,Go也支持breakcontinuegoto等跳转语句。 另外,Go还支持一个特有的fallthrough跳转语句。

Go所支持的六种流程控制代码块中,除了if-else条件分支代码块,其它五种称为可跳出代码块。 我们可以在一个可跳出代码块中使用break语句以跳出此代码块。

我们可以在forfor-range两种循环代码块中使用continue语句提前结束一个循环步。 除了这两种循环代码块,其它四种代码块称为分支代码块。

请注意,上面所提及的每种流程控制块的一个分支都属于一条语句。这样的语句常常会包含很多子语句。

上面所提及的流程控制语句都属于狭义上的流程控制语句。 下一篇文章中将要介绍的协程、延迟函数调用、以及恐慌和恢复(第13章),以及今后要介绍的并发同步技术(第36章)属于广义上的流程控制语句。

本文余下的部分主要解释三种基本的流程控制语句和各种代码跳转语句。 Go 1.22引入的for range anInteger {...}循环也将被介绍。 其它上面提及的流程控制块将在后面其它文章中逐渐介绍。

if-else条件分支控制代码块

一个if-else条件分支控制代码块的完整形式如下:

  1. if InitSimpleStatement; Condition {
  2. // do something
  3. } else {
  4. // do something
  5. }

ifelse是两个关键字。 和很多其它编程语言一样,else分支是可选的。

在一个if-else条件分支控制代码块中,

  • InitSimpleStatement部分是可选的,如果它没被省略掉,则它必须为一条简单语句(第11章)。 如果它被省略掉,它可以被视为一条空语句(简单语句的一种)。 在实际编程中,InitSimpleStatement常常为一条变量短声明语句。
  • Condition必须为一个结果为布尔值的表达式(第11章)(它被称为条件表达式)。 Condition部分可以用一对小括号括起来,但大多数情况下不需要。

注意,我们不能用一对小括号将InitSimpleStatementCondition两部分括在一起。

在执行一个if-else条件分支控制代码块中,如果InitSimpleStatement这条语句没有被省略,则此条语句将被率先执行。 如果InitSimpleStatement被省略掉,则其后跟随的分号;也可一块儿被省略。

每个if-else流程控制包含一个隐式代码块,一个if分支显式代码块和一个可选的else分支代码块。 这两个分支代码块内嵌在这个隐式代码块中。 在程序运行中,如果Condition条件表达式的估值结果为true,则if分支式代码块将被执行;否则,else分支代码块将被执行。

一个例子:

  1. package main
  2. import (
  3. "fmt"
  4. "math/rand"
  5. "time"
  6. )
  7. func main() {
  8. rand.Seed(time.Now().UnixNano()) // Go 1.20之前需要
  9. if n := rand.Int(); n%2 == 0 {
  10. fmt.Println(n, "是一个偶数。")
  11. } else {
  12. fmt.Println(n, "是一个奇数。")
  13. }
  14. n := rand.Int() % 2 // 此n不是上面声明的n
  15. if n % 2 == 0 {
  16. fmt.Println("一个偶数。")
  17. }
  18. if ; n % 2 != 0 {
  19. fmt.Println("一个奇数。")
  20. }
  21. }

如果InitSimpleStatement语句是一个变量短声明语句,则在此语句中声明的变量被声明在外层的隐式代码块中。

可选的else分支代码块一般情况下必须为显式的,但是如果此分支为另外一个if-else块,则此分支代码块可以是隐式的。

另一个例子:

  1. package main
  2. import (
  3. "fmt"
  4. "time"
  5. )
  6. func main() {
  7. if h := time.Now().Hour(); h < 12 {
  8. fmt.Println("现在为上午。")
  9. } else if h > 19 {
  10. fmt.Println("现在为晚上。")
  11. } else {
  12. fmt.Println("现在为下午。")
  13. // 左h是一个新声明的变量,右h已经在上面声明了。
  14. h := h
  15. // 刚声明的h遮掩了上面声明的h。
  16. _ = h
  17. }
  18. // 上面声明的两个h在此处都不可见。
  19. }

for循环代码块

for循环代码块的完整形式如下:

  1. for InitSimpleStatement; Condition; PostSimpleStatement {
  2. // do something
  3. }

其中for是一个关键字。

在一个for循环代码块中,

  • InitSimpleStatement(初始化语句)和PostSimpleStatement(步尾语句)两个部分必须均为简单语句,并且PostSimpleStatement不能为一个变量短声明语句。
  • Condition必须为一个结果为布尔值的表达式(它被称为条件表达式)。

所有这三个刚提到的部分都是可选的。和很多其它流行语言不同,在Go中上述三部分不能用小括号括在一起。

每个for流程控制包括至少两个子代码块。 其中一个是隐式的,另一个是显式的(花括号起始和终止的部分,又称循环体)。 此显式代码块内嵌在隐式代码块之中。

在一个for循环流程控制中,初始化语句(InitSimpleStatement)将被率先执行,并且只会被执行一次。

在每个循环步的开始,Condition条件表达式将被估值。如果估值结果为false,则循环立即结束;否则循环体(即显式代码块)将被执行。

在每个循环步的结尾,步尾语句(PostSimpleStatement)将被执行。

下面是一个使用for循环流程控制的例子。此程序将逐行打印出09十个数字。

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

在一个for循环流程控制中,如果InitSimpleStatementPostSimpleStatement两部分同时被省略(可将它们视为空语句),则和它们相邻的两个分号也可被省略。 这样的形式被称为只有条件表达式的for循环。只有条件表达式的for循环和很多其它语言中的while循环类似。

  1. var i = 0
  2. for ; i < 10; {
  3. fmt.Println(i)
  4. i++
  5. }
  6. for i < 20 {
  7. fmt.Println(i)
  8. i++
  9. }

在一个for循环流程控制中,如果条件表达式部分被省略,则编译器视其为true

  1. for i := 0; ; i++ { // 等价于:for i := 0; true; i++ {
  2. if i >= 10 {
  3. break
  4. }
  5. fmt.Println(i)
  6. }
  7. // 下面这几个循环是等价的。
  8. for ; true; {
  9. }
  10. for true {
  11. }
  12. for ; ; {
  13. }
  14. for {
  15. }

在一个for循环流程控制中,如果初始化语句InitSimpleStatement是一个变量短声明语句,则在此语句中声明的循环变量被声明在外层的隐式代码块中。 我们可以在内嵌的循环体(显式代码块)中声明同名变量来遮挡在InitSimpleStatement中声明的变量。 比如下面的代码打印出012,而不是0

  1. for i := 0; i < 3; i++ {
  2. fmt.Print(i)
  3. i := i // 这里声明的变量i遮挡了上面声明的i。
  4. // 右边的i为上面声明的循环变量i。
  5. i = 10 // 新声明的i被更改了。
  6. _ = i
  7. }

注意:Go 1.22修改了for循环流程控制的语义:

  • 在Go 1.22之前,每一个声明的循环变量在整个循环的执行过程中只会被实例化一次。此唯一的实例将被所有循环步共享。
  • 从Go 1.22开始,每一个声明的循环变量将会在每个循环步被实例化一次。每个实例只作用于当前循环步。

对于大多数情形,此语义改变不会造成代码的行为改变。 但是,有时候,它会(第13章)。 所以,此语义改变破坏了向后兼容性。 为了将此语义改变造成的破坏减至做小,从Go 1.22开始,每个Go源文件都应该被指定一个Go版本号

一条break语句可以用来提前跳出包含此break语句的最内层for循环。 下面这段代码同样逐行打印出09十个数字。

  1. i := 0
  2. for {
  3. if i >= 10 {
  4. break
  5. }
  6. fmt.Println(i)
  7. i++
  8. }

一条continue语句可以被用来提前结束包含此continue语句的最内层for循环的当前循环步(步尾语句仍将得到执行)。 比如下面这段代码将打印出13579

  1. for i := 0; i < 10; i++ {
  2. if i % 2 == 0 {
  3. continue
  4. }
  5. fmt.Print(i)
  6. }

for-range流程控制代码块用来遍历整数

for-range流程控制代码块可以用来遍历整数、各种容器(第18章)和通道(第21章)等。 本文只介绍如何使用for-range流程控制代码块来遍历整数。

注意:使用for-range流程控制代码块来遍历整数是从Go 1.22才开始支持的。

下面的代码

  1. for i = range anInteger {
  2. ...
  3. }

等价于

  1. for i = 0; i < anInteger; i++ {
  2. ...
  3. }

同样,

  1. for i := range anInteger {
  2. ...
  3. }

等价于

  1. for i := 0; i < anInteger; i++ {
  2. ...
  3. }

比如,上一节的最后一个例子等价于:

  1. for i := range 10 {
  2. if i % 2 == 0 {
  3. continue
  4. }
  5. fmt.Print(i)
  6. }

switch-case流程控制代码块

switch-case流程控制代码块是另外一种多分支代码块。

一个switch-case流程控制代码块的完整形式为:

  1. switch InitSimpleStatement; CompareOperand0 {
  2. case CompareOperandList1:
  3. // do something
  4. case CompareOperandList2:
  5. // do something
  6. ...
  7. case CompareOperandListN:
  8. // do something
  9. default:
  10. // do something
  11. }

其中switchcasedefault是三个关键字。

在一个switch-case流程控制代码块中,

  • InitSimpleStatement部分必须为一条简单语句,它是可选的。
  • CompareOperand0部分必须为一个表达式(如果它没被省略的话,见下)。 此表达式的估值结果总是被视为一个类型确定值。如果它是一个类型不确定值,则它被视为类型为它的默认类型的类型确定值。 因为这个原因,此表达式不能为类型不确定的nil值。 CompareOperand0常被称为switch表达式。
  • 每个CompareOperandListX部分(X表示1N)必须为一个用(英文)逗号分隔开来的表达式列表。 其中每个表达式都必须能和CompareOperand0表达式进行比较。 每个这样的表达式常被称为case表达式。 如果其中case表达式是一个类型不确定值,则它必须能够自动隐式转化为对应的switch表达式的类型,否则编译将失败。

每个case CompareOperandListX:部分和default:之后形成了一个隐式代码块。 每个这样的隐式代码块和它对应的case CompareOperandListX:或者default:形成了一个分支。 每个分支都是可选的。

每个switch-case流程控制代码块中最多只能有一个default分支(默认分支)。

除了刚提到的分支代码块,每个switch-case流程控制至少包括其它两个代码块。 其中一个是隐式的,另一个是显式的。此显式的代码块内嵌在隐式的代码块之中。 所有的分支代码块都内嵌在此显式代码块之中(因此也间接内嵌在刚提及的隐式代码块中)。

switch-case代码块属于可跳出流程控制。 break可以使用在一个switch-case流程控制的任何分支代码块之中以提前跳出此switch-case流程控制。

当一个switch-case流程控制被执行到的时候,其中的简单语句InitSimpleStatement将率先被执行(只执行一次)。 随后switch表达式CompareOperand0将被估值(仅一次)。上面已经提到,此估值结果一定为一个类型确定值。 然后此结果值将从上到下从左到右和各个CompareOperandListX表达式列表中的各个case表达式逐个依次比较(使用==运算符)。 一旦发现某个表达式和CompareOperand0相等,比较过程停止并且此表达式对应的分支代码块将得到执行。 如果没有任何一个表达式和CompareOperand0相等,则default默认分支将得到执行(如果此分支存在的话)。

一个switch-case流程控制的例子:

  1. package main
  2. import (
  3. "fmt"
  4. "math/rand"
  5. "time"
  6. )
  7. func main() {
  8. rand.Seed(time.Now().UnixNano()) // Go 1.20之前需要
  9. switch n := rand.Intn(100); n%9 {
  10. case 0:
  11. fmt.Println(n, "is a multiple of 9.")
  12. // 和很多其它语言不一样,程序不会自动从一个
  13. // 分支代码块跳到下一个分支代码块去执行。
  14. // 所以,这里不需要一个break语句。
  15. case 1, 2, 3:
  16. fmt.Println(n, "mod 9 is 1, 2 or 3.")
  17. break // 这里的break语句可有可无的,效果
  18. // 是一样的。执行不会跳到下一个分支。
  19. case 4, 5, 6:
  20. fmt.Println(n, "mod 9 is 4, 5 or 6.")
  21. // case 6, 7, 8:
  22. // 上一行可能编译不过,因为6和上一个case中的
  23. // 6重复了。是否能编译通过取决于具体编译器实现。
  24. default:
  25. fmt.Println(n, "mod 9 is 7 or 8.")
  26. }
  27. }

在上例中,rand.Intn函数将返回一个从0到所传实参之间类型为int的随机数。

注意,编译器可能会不允许一个switch-case流程控制中有任何两个case表达式可以在编译时刻确定相等。 比如,当前的官方标准编译器(1.22版本)认为上例中的case 6, 7, 8一行是不合法的(如果此行未被注释掉)。但是其它编译器未必这么认为。 事实上,当前的官方标准编译器允许重复的布尔case表达式在同一个switch-case流程控制中出现, 而gccgo(v8.2)允许重复的布尔和字符串类型的case表达式在同一个switch-case流程控制中出现。

上面的例子中的前两个case分支中的注释已经解释了,和很多其它语言不一样,每个分支代码块的结尾不需要一条break语句就可以自动跳出当前的switch-case流程控制。 那么如何让执行从一个case分支代码块的结尾跳入下一个分支代码块?Go提供了一个fallthrough关键字来完成这个任务。 比如,在下面的例子中,所有的分支代码块都将得到执行(从上到下)。

  1. rand.Seed(time.Now().UnixNano()) // Go 1.20之前需要
  2. switch n := rand.Intn(100) % 5; n {
  3. case 0, 1, 2, 3, 4:
  4. fmt.Println("n =", n)
  5. fallthrough // 跳到下个代码块
  6. case 5, 6, 7, 8:
  7. // 一个新声明的n,它只在当前分支代码快内可见。
  8. n := 99
  9. fmt.Println("n =", n) // 99
  10. fallthrough
  11. default:
  12. // 下一行中的n和第一个分支中的n是同一个变量。
  13. // 它们均为switch表达式"n"。
  14. fmt.Println("n =", n)
  15. }

请注意:

  • 一条fallthrough语句必须为一个分支代码块中的最后一条语句。
  • 一条fallthrough语句不能出现在一个switch-case流程控制中的最后一个分支代码块中。

比如,下面代码的几个fallthrough使用是不合法的。

  1. switch n := rand.Intn(100) % 5; n {
  2. case 0, 1, 2, 3, 4:
  3. fmt.Println("n =", n)
  4. // 此整个if代码块为当前分支中的最后一条语句
  5. if true {
  6. fallthrough // error: 不是当前分支中的最后一条语句
  7. }
  8. case 5, 6, 7, 8:
  9. n := 99
  10. fallthrough // error: 不是当前分支中的最后一条语句
  11. _ = n
  12. default:
  13. fmt.Println(n)
  14. fallthrough // error: 不能出现在最后一个分支中
  15. }

一个switch-case流程控制中的InitSimpleStatement语句和CompareOperand0表达式都是可选的。 如果CompareOperand0表达式被省略,则它被认为类型为bool类型的true值。 如果InitSimpleStatement语句被省略,其后的分号也可一并被省略。

上面已经提到了一个switch-case流程控制中的所有分支都可以被省略,所以下面的所有流程控制代码块都是合法的,它们都可以被视为空操作。

  1. switch n := 5; n {
  2. }
  3. switch 5 {
  4. }
  5. switch _ = 5; {
  6. }
  7. switch {
  8. }

上例中的后两个switch-case流程控制中的CompareOperand0表达式都为bool类型的true值。 同理,下例中的代码将打印出hello

  1. switch { // <=> switch true {
  2. case true: fmt.Println("hello")
  3. default: fmt.Println("bye")
  4. }

Go中另外一个和其它语言的显著不同点是default分支不必一定为最后一个分支。 比如,下面的三个switch-case流程控制代码块是相互等价的。

  1. switch n := rand.Intn(3); n {
  2. case 0: fmt.Println("n == 0")
  3. case 1: fmt.Println("n == 1")
  4. default: fmt.Println("n == 2")
  5. }
  6. switch n := rand.Intn(3); n {
  7. default: fmt.Println("n == 2")
  8. case 0: fmt.Println("n == 0")
  9. case 1: fmt.Println("n == 1")
  10. }
  11. switch n := rand.Intn(3); n {
  12. case 0: fmt.Println("n == 0")
  13. default: fmt.Println("n == 2")
  14. case 1: fmt.Println("n == 1")
  15. }

goto跳转语句和跳转标签声明

和很多其它语言一样,Go也支持goto跳转语句。 在一个goto跳转语句中,goto关键字后必须跟随一个表明跳转到何处的跳转标签。 我们使用LabelName:这样的形式来声明一个名为LabelName的跳转标签,其中LabelName必须为一个标识符。 一个不为空标识符的跳转标签声明后必须被使用至少一次。

一条跳转标签声明之后必须立即跟随一条语句。 如果此声明的跳转标签使用在一条goto语句中,则当此条goto语句被执行的时候,执行将跳转到此跳转标签声明后跟随的语句。

一个跳转标签必须声明在一个函数体内,此跳转标签的使用可以在此跳转标签的声明之后或者之前,但是此跳转标签的使用不能出现在此跳转标签声明所处的最内层代码块之外。

下面这个例子使用跳转标签声明和goto跳转语句来实现了一个循环:

  1. package main
  2. import "fmt"
  3. func main() {
  4. i := 0
  5. Next: // 跳转标签声明
  6. fmt.Println(i)
  7. i++
  8. if i < 5 {
  9. goto Next // 跳转
  10. }
  11. }

上面刚提到了一个跳转标签的使用不能出现在此跳转标签声明所处的最内层代码块之外,所以下面的代码片段中的跳转标签使用都是不合法的。

  1. package main
  2. func main() {
  3. goto Label1 // error
  4. {
  5. Label1:
  6. goto Label2 // error
  7. }
  8. {
  9. Label2:
  10. }
  11. }

另外要注意的一点是,如果一个跳转标签声明在某个变量的作用域内,则此跳转标签的使用不能出现在此变量的声明之前。 关于变量的作用域,请阅读后面的文章代码块和作用域(第32章)

下面这个程序编译不通过:

  1. package main
  2. import "fmt"
  3. func main() {
  4. i := 0
  5. Next:
  6. if i >= 5 {
  7. // error: goto Exit jumps over declaration of k
  8. goto Exit
  9. }
  10. k := i + i
  11. fmt.Println(k)
  12. i++
  13. goto Next
  14. Exit: // 此标签声明在k的作用域内,但
  15. // 它的使用在k的作用域之外。
  16. }

刚提到的这条规则可能会在今后放宽。 目前,有两种途径可以对上面的程序略加修改以使之编译通过。

第一种途径是缩小变量k的作用域:

  1. func main() {
  2. i := 0
  3. Next:
  4. if i >= 5 {
  5. goto Exit
  6. }
  7. // 创建一个显式代码块以缩小k的作用域。
  8. {
  9. k := i + i
  10. fmt.Println(k)
  11. }
  12. i++
  13. goto Next
  14. Exit:
  15. }

第二种途径是放大变量k的作用域:

  1. func main() {
  2. var k int // 将变量k的声明移到此处。
  3. i := 0
  4. Next:
  5. if i >= 5 {
  6. goto Exit
  7. }
  8. k = i + i
  9. fmt.Println(k)
  10. i++
  11. goto Next
  12. Exit:
  13. }

包含跳转标签的breakcontinue语句

一个goto语句必须包含一个跳转标签名。 一个break或者continue语句也可以包含一个跳转标签名,但此跳转标签名是可选的。 包含跳转标签名的break语句一般用于跳出外层的嵌套可跳出流程控制代码块。 包含跳转标签名的continue语句一般用于提前结束外层的嵌套循环流程控制代码块的当前循环步。

如果一条break语句中包含一个跳转标签名,则此跳转标签必须刚好声明在一个包含此break语句的可跳出流程控制代码块之前。 我们可以把此跳转标签名看作是其后紧跟随的可跳出流程控制代码块的名称。 此break语句将立即结束此可跳出流程控制代码块的执行。

如果一条continue语句中包含一个跳转标签名,则此跳转标签必须刚好声明在一个包含此continue语句的循环流程控制代码块之前。 我们可以把此跳转标签名看作是其后紧跟随的循环流程控制代码块的名称。 此continue语句将提前结束此循环流程控制代码块的当前步的执行。

下面是一个使用了包含跳转标签名的breakcontinue语句的例子。

  1. package main
  2. import "fmt"
  3. func FindSmallestPrimeLargerThan(n int) int {
  4. Outer:
  5. for n++; ; n++{
  6. for i := 2; ; i++ {
  7. switch {
  8. case i * i > n:
  9. break Outer
  10. case n % i == 0:
  11. continue Outer
  12. }
  13. }
  14. }
  15. return n
  16. }
  17. func main() {
  18. for i := 90; i < 100; i++ {
  19. n := FindSmallestPrimeLargerThan(i)
  20. fmt.Print("最小的比", i, "大的素数为", n)
  21. fmt.Println()
  22. }
  23. }

本书由老貘历时三年写成。目前本书仍在不断改进和增容中。你的赞赏是本书和Go101.org网站不断增容和维护的动力。

(请搜索关注微信公众号“Go 101”或者访问github.com/golang101/golang101获取本书最新版)