Go 语言实战(5):流程控制

这一期介绍 程序的流程控制,下一期开始引入实战项目:一开始不能太贪心,会先尝试做一个 CLI 工具。

控制结构

程序有三种基本的控制结构,分别是 顺序、分支(选择) 和 循环 。其中顺序结构不需要任何特殊的语句,程序语句按顺序执行即可。需要控制语句的是剩下两种结构。

注意,Go 语言的代码块不能省略大括号({},又叫花括号),哪怕只有一行。这是为了保持语言的可伸缩性,使得语言在不同规模和不同的上下文都不会出现歧义。与之相反,条件表达式处于关键字和左大括号 { 中间,而且总是只有一行,所以不需要加括号 ()

无论是分支结构还是循环结构,都需要引入条件表达式,来控制执行路径。由于 Go 取消掉了一些操作的值,使得它们从表达式变成了语句,导致相应的操作无法直接加入条件表达式。估计是因为这样,Go 允许在条件表达式前增加一个简单语句,中间用分号 ; 隔开,也就是这样 简单语句;条件表达式。注意,只能是一句,如果是更复杂的效果,还是应该在进入控制结构之前,通过多个语句先完成操作。

其中简单语句可以是:

  • 空白。
  • 表达式。
  • 通道(chan)的发送语句。
  • 自增自减。
  • 赋值。
  • 变量的短声明。

变量短声明很可能是用得最多的语句,因为在这里声明变量可以限制变量的作用域,仅限于控制结构内部,但需要注意变量遮盖(shadow)的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
a := 1
if a < 10 {
a = 10
}
// 外层 a 被修改,是 10
fmt.Println(a)
b := 1
if b := 2; b < 10 { // 声明了一个新的变量 b,作用域为整个 if-else 控制结构
b = 10
} else {
// 如果条件满足执行到这(例如 b:= 11),这里访问的也是内层 b
}
// 外层 b 仍然是 1
fmt.Println(b)

1. 分支结构

分支结构会指定一个到多个条件,通过测试哪些条件得到满足来决定执行路径。Go 里面可以实现分支结构的语句有:

if 语句

Go 的 if 语句跟其它语言差别不大,直接上例子。

细节差别有:表达式不用括号,代码块不能省略大括号,else if 不像一些语言可以写成 elif (Go 很省关键字)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// if 的条件表达式只能为 布尔 bool 类型
// 1. 只有 if 子句
if 布尔表达式 { // 不像 C 语言,布尔表达式不用加括号
// 条件为真才执行
}
// 2. 加上 else 子句
if 布尔表达式 {
// 表达式为真执行这里
} else {
// 否则执行这里
}
// 3. else 后面可以嵌套更多 if 语句
if 条件1 {
// 条件1 为真
} else if 条件2 {
// 条件2 为真
} else {
// 都不满足
}

switch 语句

只有一两个判断条件时, if 语句是很好的选择;但当分支多了起来,大量的 else if 会导致代码冗长又难以维护。这时 switch 语句是更好的选择。

可能是为了省关键字,switch 实际上有两个用法,分别是表达式 switch,和类型 switch。无论哪一种,switch 语句都会从上到下逐个测试 case,执行第一个满足的分支。

表达式 switch:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 表达式 switch 也有两种用法
// 1. 经典 switch-case 用法,表达式可以为任意类型,不限定布尔型
// case 后面的值类型必须跟表达式的类型是可比较的
switch 表达式 {
case1:
// 表达式 == 值1 时执行
// 可以是多行语句
case2:
// 表达式 == 值2 时执行
default:
// default 子句可选,前面所有 case 都不满足时执行
}
// 2. 接近某些语言的 switch-cond 的用法
// 大量 if-else 的分支结构可以改写成这种形式
switch { // 没有表达式,但如果有需要,仍然可以保留一个简单语句,后面加分号表明省略了表达式
case 布尔表达式1: // 这里的表达式只能为 布尔型
// 表达式1 为 true 时执行
case 布尔表达式2:
// 表达式2 为 true 时执行
default:
// 同样是所有 case 都不满足时执行
}
类型 switch:

类型 switch 涉及类型断言。

Go 的接口变量允许储存任何满足接口的类型的值,举例说类型 T 满足接口 I ,那么 T 的值 t 就可以赋值给 I 的变量 ivar i I = t

而将接口变量 i 转换为具体的类型 T 时,除了显式的类型转换 t = T(i) ,还可以用 类型断言 t = i.(T) 。两者之间一个明显的差别是,类型转换需要开发者自行确保可以转换,否则就会产生一个运行时 panic;而类型断言有安全形式 t, ok = i.(T) ,如果转换失败,ok 的值为 false ,并不会产生 panic。不过这里不是讨论类型断言,不详细展开。

类型 switch 借用了类型断言的形式,但是括号里的不是某个具体的类型,而是关键字 type ,也就是 i.(type) ;然后 case 后面接的,就不再是值,而是具体的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
switch i.(type) {
case 类型1:
// 如果 i 的值是 类型1 时执行
case 类型2:
// i 为类型2 时执行
case nil:
// 除了类型,还能判断 i 是否为 nil
default:
// 上述 case 均不满足时执行
}
// 类型 switch 还可以包含一个短声明,提供一个转换后的变量供 switch 结构体内部调用
switch x := i.(type) {
case nil:
// i 为 nil,没有动态类型信息,x 的类型仍然是 i 的接口类型
fmt.Println("i 为 nil")
case int:
// x 的类型是 int
fmt.Println(x)
case func(int) float64:
// x 的类型是签名为 func(int) float64 的函数
fmt.Println(x(1))
case bool, string:
// 条件类型不止一个,无法确定命中的类型,x 的类型没有做转换,仍是 i 的接口类型
fmt.Println(x)
default:
// i 虽然不为 nil,但是没有命中任何类型,x 的类型仍然是 i 的接口类型
fmt.Println("意料以外的类型")
}
与 C 风格 switch 的差别

所以总结下来,switch 语句实际上包含了

  • C 风格的 switch-case 经典用法。
  • scheme 风格的 switch-cond 用法(但是子句还是用 case 关键字)。
  • 类型断言用法。

不熟悉的话,还挺容易混淆的。

除此之外,虽然关键字和结构整体都是沿袭 C 风格的 switch 语句,细节上还是有几个比较重要的差异:

  • 默认 break:

    C 风格的 switch,case 之间的默认行为是 fallthrough。换言之,命中的 case 只是一个入口,如果没有遇到 break 语句,接下去的每个 case 都会执行,直到结束。

    但实际上,需要 fallthrough 的情况非常少,大多数情况下,我们都只是希望只执行命中的 case,这就导致 case 和 break 总是成对出现,非常啰嗦。

    Go 将 switch 的默认行为改为 break,需要 fallthrough 时,再在 case 的结尾显示写一个 fallthrough。注意类型 switch 不允许 fallthrough。

  • case 不限定为常量值:

    C 风格的 case 后面,只能接一个 常量或字面量的值。Go 无此限制。在经典用法中,值可以是常量,也可以是变量或者表达式。

  • case 后面允许接多个条件:

    因为 C 风格 switch 默认 fallthrough,当多个条件共享相同的代码时,只要将多个 case 顺序写在一起,代码放在最后一个 case,就可以共享代码逻辑。

下面看一下例子:

1
2
3
4
5
6
7
8
// C 代码
switch (x) {
case 1:
case 2:
case 3:
// 这里的代码被 3 个case 共享
break;
}

Go 的默认行为变成了 break,不能这样写了;但是多个条件共享代码反而更容易了,因为 case 后面可以接多个条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Go 代码
// 经典 C 风格
switch x {
case 1, 2, 3:
// x 等于任意一个数都执行
}
// cond 风格
switch {
case x < 0, x == 0, x > 0:
// 任意一个条件为 true 都执行
}
switch x := i.(type) {
case bool, int, string:
// i 的类型是其中任意一个类型都执行
// 但是类型不止一个时,x 不会转换类型
}

例子中的任意一种写法,都可以;但是三种写法不能混用。

select 语句

select 语句可以说是通信版的 switch,也可以说是借鉴了 C 语言 select 函数的含义(虽然读写的对象不同),并且升格成了关键字:

1
2
3
4
5
6
7
8
select {
case a = <- ch1: // 从通道中接收(读)
// 读到消息之后的操作
case ch2 <- b: // 发送消息(写)
// 发送成功之后的操作
default:
// 以上 case 都阻塞时,执行这里
}

select 语句里的每个 case 后面都接一个通信操作,要么接收要么发送。

执行时,会在可以执行的 case 中 随机 执行一个(注意是随机,switch 则是从上到下测试第一个满足的),其它 case 忽略。如果没有可以执行的 case,则会执行 default 子句;没有可执行的 case 且没有 default 子句,则会阻塞直到有可以执行的 case 为止。所以如果不想阻塞,就一定要增加 default 子句。

select 语句常常嵌套在循环结构里,实现对通道轮询的效果。详细用法在介绍并发和通道时再展开。

2. 循环结构

在 Go 里面实现循环结构,只有一个 for 语句。看起来这又是省关键字的结果。Go 的不同写法,可以实现 C 风格里的 for、while 和 do-while ,甚至还有 Java 的 for-each 或 Python 的 for-range 效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 1. 经典写法
// 初始化语句和步进语句都是简单语句,循环条件必须是布尔表达式
// 3 个部分可以任意省略,但是分号要保留(这才看得出来没省略的是哪部分)
// 循环条件如果省略,等同于 true,变成死循环,需要内部有 break 语句 或 return 语句 结束循环
for 初始化语句; 循环条件(布尔表达式); 步进语句 {
// 循环体
}
// 2. while 写法
// 如果只保留循环条件,用法等同于 while
// 同样的,如果省略循环条件,就会变成死循环
for 循环条件 {
// ...
}
// 3. do-while 写法
// do{...}while(循环条件) 和 while 相比,是把判断后置,无论如何至少执行一次
// 可以通过死循环再 break/return 跳出来模拟
for {
// 循环体
// 不满足继续循环的条件时退出
if !循环条件 {
break
}
}
// 4. for-range 写法
for i, x = range 表达式 {
// 循环体
}

range 表达式的右边,可以是 数组、数组的指针、切片(slice)、字符串(string)、映射(map)或者可接收(可读)的通道(chan)。换句话说,它们的成员元素可以枚举。

range 表达式本质上是一个语法糖,实际上底层实现还是展开成普通的 for 循环,帮你枚举对象里的成员元素。对于通道以外的类型而言,展开后的三个部分可以近似看作:取第一个元素(初始化);没遍历到最后一个元素(循环条件);取下一个元素(步进)。而对于通道而言,循环条件变成了通道还没关闭。

详细用法,具体在每个类型里介绍。这里只提醒两点:

  • range 表达式获得的变量都是拷贝,对变量的修改不会影响集合中原来的值。(这个的原理会专门找一期介绍值和引用)如果想修改集合里的值,还是要老老实实地遍历下标(或者 map 的 key),然后直接修改索引值 s[i] = newVal

  • 多数的 range 表达式可以返回不止一个值。你既可两个值都赋值给变量使用,也可以只要其中一个;丢弃第二个值可以直接忽略 for i := range s {...} ,而丢弃第一个值则必须显式赋值给空白标识符 for _, v := range s {...}

    | 类型 | 类型定义 | 第一个值 | 第二个值 |
    | ————————– | ——————- | ——————– | ———————————- |
    | 数组、数组指针 或 切片 a | [n]E, *[n]E, []E | 下标 i ,类型int | 下标对应的元素 a[i],类型 E |
    | 字符串 s | string | 下标 i ,类型int | 下标对应的字节 s[i],类型 byte |
    | 映射 m | map[K]V | 键值k,类型K | 键值对应的元素 m[k],类型V |
    | 通道 c | chan E, <- chan E | 元素 e,类型 E | - |

无论是哪一种写法,都可以看作是经典写法的变体(省略了某些部分),所以执行流程都是相同的:

  1. 执行初始化(init)语句;
  2. 判断循环条件(condition),满足则进入循环,否则退出(循环条件省略等同于 true);
  3. 执行循环体;
  4. 执行步进(post)语句,并回到 2。

3. 控制语句

控制语句主要针对循环结构,除了按照 for 语句的规则执行,还可以加入一些控制语句,改变执行的方向。

分别有 breakcontinuegoto 三种语句。这三种语句的用法跟 C 家族语言基本一致:

  • break 跳出当前的循环,并继续执行循环之后的语句。break 也可以用于跳出 switch 代码块,但由于 Go 的 switch 默认会在 case 结尾退出,所以 switch 里 break 用得比较少。

    注意有多层循环嵌套时,break 只会跳出当前所在的内层循环。

    如果想跳出在外层循环,需要在跳出的循环前面加标签(label),break 后面加上标签作为目标,就会跳出对应的循环。

  • continue 中止当前循环的执行,改为执行步进语句,(在同一个循环结构内)开始执行下一次循环。

    break 类似,有多层循环嵌套时,continue 只会影响当前所在的内层循环。

    如果想开始外层的下一次循环,同样可以用循环前的标签作为 continue 的目标。

  • goto 必须配合标签 (label) 使用。goto 语句会无条件跳转到(同一个函数内部) 标签 声明的位置继续执行。goto 的使用不限于某种控制结构。实际上,靠 if-else + goto 可以实现任意的控制结构。

    goto 用好了可以用很少的代码实现复杂的逻辑控制;但是用不好的话,会导致执行流程混乱,造成理解和调试困难。结构化程序设计一般不鼓励使用 goto ,多数后来的高级语言也都不提供 goto 语句。

标签

标签(label)本质上就是一个 标识符,只是它指向的不是一个常量或变量,而是一个程序的位置(指向声明位置下一行代码);有效作用域总是为同一个函数体,不受嵌套的代码块限制(标签是 Go 里面唯一一种永远都是函数级作用域的标识符)。标签不占用常量和变量的命名,允许标签跟其它标识符重名,但强烈建议不要重名。

标签声明时单独一行,后面接一个冒号 LABEL:;使用时作为 breakcontinuegoto 的目标。为了区别于其它标识符,标签一般全大写。

goto 的目标标签可以声明在函数内的任意地方,跳转的限制只有两条:

  • 不能跨函数跳转,包括函数内嵌套的函数和闭包。
  • 不能(向后)跳过变量声明(可以跳过常量声明)。因为跳过的变量后续引用到会报错。修改的方法是把变量声明提前到跳转区间以外。

相对应地,作为 breakcontinue 目标的标签多一个限制:标签必须声明在外层某个循环前面(相当于指向该循环),用来表明要跳出或者继续的是哪个循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
LABEL1:
a := 1
LOOP_I:
for i := 0; i < I; i++ {
LOOP_J:
for j := 0; j < J; j++ {
for k := 0; k < K; k++ {
// ...
if err != nil {
// LOOP_J 也是合法的
// 但使用 LABEL1 和 LABEL2 以及 LOOP2 都无法编译通过
break LOOP_I
}
if nextj {
// 跟 break 类似,选择 LOOP_I 和 LOOP_J 以外的标签都无法编译通过
continue LOOP_J
}
}
}
}
LABEL2:
// ...
LOOP2:
for {
// ...
}

写在后面

写教程可以深入细节,一直到内部的原理。也可以不多解释直接模仿着跑起来,多见几个例子再回头解释。

前者似乎所有人都可以看,新手打基础,老手查漏补缺。但新手可能还没建立起直观的认识,一上来就太多细节,也许就懵了。这个系列既然起了『实战』之名,按理说应该接近后者。直接来几个实例,先跑起来,再介绍里面分别是什么。有 C 家族语言经验的朋友,甚至都不需要解释,从例子里大概就能体会差异,可以用来干些简单的活。

写到第四期,似乎越来越理论和细节了。这并不是我的本意。

这样固然是满足了一部分我自己深入了解 Go 的需要;另一方面,也是希望可以迁就一部分缺少 C 家族语言经验的读者。(是否有没有编程经验的读者呢?因为缺乏来自你们的反馈,读者的面目在我这其实是模糊的。)这就需要在第一个『实战项目』之前先铺垫一些『基础知识』。虽说是基础,凡是涉及的主题,我都希望写透一些,不太愿意把一个主题拆得零碎,先笼统过一遍,下次展开一点,后面再来深入。

但这样下去,与其看我啰嗦,还不如直接看 Go 官方的语言规范来得简洁清晰。虽然我不断强调大家可以当参考资料跳着看;但是从详尽的资料中筛选关键信息不正是我该做的事吗。幸好写到这一期,开发一个最基本的程序所需要的知识勉强是够了,所以下一期赶紧开始进入一个实战程序吧。

开发中肯定还会遇到没介绍过的内容,等到毫无遗漏介绍完再开始是不现实的,这时提问答疑和群内互助就足以解决问题了。开发中遇到的问题,后面正好着重介绍。

这里给出 Go 语言官方的规范文档,大家也可以自行查阅。

官网:https://golang.org/ref/spec

官方提供给国内访问的镜像:https://golang.google.cn/ref/spec


知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。