Go语言流程控制语句新解
涛叔最近准备总结一下如何在 Go 语言中处理错误。错误处理的本质还是流程控制。所以今天先介绍一下 Go 语言的流程控制语句。有人可能会说流程控制不就是判断、循环,都很简单,有什么可说的。我写这篇文章的主要观点是主讲 if
和 goto
,其他像 switch
和 for
不过是前两者的变种或者结合,是语法糖,而panic/recover
则可以看成是跨越函数的 goto
。通过这种方式,我们可以系统地理解 Go 语言的流程控制机制,为后面的错误处理打下基础。
条件分支if
流程控制,顾名思义,这是控制程序的执行流程。如果没有流程控制语句,程序就会按顺序依次执行。下面的语句最终会输出数字9
。
:= 1
a += 3
a += 5
a .Println(a) fmt
Go 语言跟 C 等其他语言类似,使用 if
语句控制执行流程,语法如下:
if a > 1 {
// ...
}
if
后面跟着判断条件,条件之后跟大括号。Go 语言的判断条件不需要加括号,如果是从 C 或 Java 等语言转过来的朋友可能会有点不适应。但我个人觉得这种简化还很有价值的。因为我们经常会在判断条件中调用函数:
if err := foo(); err != nil {
// ...
}
如果再加上括号,会显得很凌乱。大家可以对比一下两种效果(功能没有区别):
if (err := foo(); err != nil) {
// ...
}
而且左边的 if
和右边的{
已经明确界定出判断条件的范围了,确实没有必要加括号了。
如果需要根据不同条件执行不同的操作,则可以使用 else if
语句:
if a == 1 {
// ...
} else if a == 2 {
// ...
} else if a == 3 {
// ...
} else {
// ...
}
此类语法跟 C 语言类似,一看就会。
多条件分支switch
如果分支条件很多,则可以使用 switch
语法。switch
本质上是 if
在语法上的小简化。比如上面的例子可以简化成:
switch a {
case 1:
// ...
case 2:
// ...
case 3:
// ...
default:
// ...
}
仔细对比一下,其实也没有简化太多。那为什么还要引入 switch
语法呢?因为在一些特殊场景,switch
确实比较方便,比如:
switch a {
case 1,2,3:
// ...
case 4,5,6:
// ...
default:
// ...
}
如果改成 if
就不那么清晰了:
if a == 1 || a == 2 || a == 3 {
// ...
} else if a == 4 || a == 5 || a == 6 {
// ...
} else {
// ...
}
所以说,switch
还是有他适应的场景。但无论如何,switch
在本质上还是一种特殊形式的 if
。
跳转goto
除了if
语法,Go 语言还支持goto
语法来改变程序的执行顺序。比如:
:= 1
a goto foo
+= 1
a :
foo+= 2 a
程序在执行的时候会跳过a += 1
语句,执行foo:
后面的a += 2
。
如果只用goto
语句,那能做的事情比较少。但如果把if
跟goto
结合起来,效果就不一样了。比如:
:= 0
a :
foo+= 1
a if a < 10 {
goto foo
}
我们在 a += 1
前面加上foo
标签,然后根据 a 的值决定是否跳回 foo
标签重复执行 a += 1
,这就实现了条件循环的效果。如果去掉if
判断,就会形成无限循环效果。
如果我们想控制循环次数,则可以引入一个计数变量:
,i := 0,0
a:
foo+= 2
a if i < 10 {
++
igoto foo
}
上面的代码会重复执行a += 2
10 次。
循环for
因为循环功能很常用,所以 Go 语言将其简化为 for
语句。刚才的例子可以简化为:
:= 0
a for i := 0; i < 10; i++ {
+= 2
a }
for
跟 if
语句有点像,但条件部分i := 0; i < 10; i++
又分成三部分,用分号分隔。第一部分i := 0
是用来初始化变量,只在循环开始之前执行一次;第二部分i < 10
是判断条件,每次循环的时候都会判断一下,只有在条件成立的时候才会执行大括号里的语句;第三部分i++
是在每次执行完大括号里的语句之后才会执行。
for
语句说白了是if
跟goto
的结合,只是一种语法糖,并没有引入新功能。
Go语言还为切片、数组、字典等容器提供了一个特殊的语法糖range
。如果不用它,遍历数组或者切片需要写成:
var a = []int{1,2,3}
for i := 0; i < len(a); i++ {
.Println(a[i])
fmt}
有了range
上述代码可以简化为:
for k, v := range a {
.Println(a[i])
fmt}
这样就不需要自己维护下标变量i
。对于map
类型,因为没办法事先确定 key 的范围,所以只能使用range
遍历:
var b = make(map[int]string)
for k, v = range b {
.Println(k, ":", v)
fmt}
range
会自动处理容器对象为nil
的情况,不需要自己检测。
跨函数跳转panic
好了,到现在我们已经介绍了if
和goto
这两种流程控制语句(switch
和for
是他们的变体)。这两种语句有一个重要的缺点,不能跨越函数。比如下面的写法是不对的:
func bar() {
:
b1()
foo// ...
}
func foo() {
goto b1
}
我们不能通过goto
从函数foo
跳到回函数bar
。但在有些场景下,这种跳转是有必要的。为此,Go 语言提供了 panic
和 recover
语法。先说 panic
。
我们可以在任何需要的地方调用 panic
函数,例如:
func foo() {
()
bar}
func bar() {
()
baz}
func baz() {
panic(123)
// ...
}
()
foo// 后面的代码在 panic 之后无法执行
当我们调用函数foo
的时候,Go 语言会依次向栈压入 bar()
和 baz()
的函数调用帧。但是我们在 baz
是调用了 panic(123)
,函数调用过程会立即停止,Go 语言会依次将栈上的帧弹出,并回收资源。从效果看是当前函数调用结束并退出了。如果弹出过程执行到了 main()
函数的那一帧,整个程序就会退出,并打印调用栈信息。
那如何阻止main()
函数退出呢?这就需要用到recover()
。执行recover()
之后就能拦截panic
导致的栈帧弹出过程。但问题是我们要在哪里执行recover()
调用呢?答案是defer
。
defer
是Go语言提供的另外一种机制,可以在函数退出之前执行一些清理操作(比如关闭文件等)。而我们上面说panic
会导致函数退出,退出一定会执行defer
注册的代码。所以说,如果我们想在 foo()
中拦截panic
,可以把代码改成这样:
func foo() {
defer func() {
if p := recover(); p != nil {
.Println(p)
fmt}
}()
()
bar}
func bar() {
()
baz}
func baz() {
panic(123)
// ...
}
()
foo// 后面的代码在 panic 之后继续执行
panic
导致foo
异常退出,但foo
在退出之前执行了defer
指定的函数,并通过recover
拦截了panic
状态,最终panic
过程停下来了。
通过panic/recover
,我们「实现」了从函数baz
跳转到函数foo
,并打印123
的效果。市面上绝大多数资料都把panic/recover
当成一种类似其他语言的Exception的机制,而我认为它在本质上是一种流程控制机制。如果非要比,panic/recover
更像是 C 语言中的 setjump
和 longjump
,是一种跨函数版的 goto
。
Go语言还有种fatal error,也是在运行时触发,但没法用recover
拦截,具体可以参考我的这篇文章。
流程控制另一个重要使用场景是处理错误。这部分内容我专门整理了一篇文章Go语言的错误处理机制。
总结
最后总结一下。Go 语言最核心的流控机制是 if
和 goto
,像 switch
和 for
都是它们的变种或者结合,像 panic/recover
则可以看成一种跨函数的 goto
。从函数到数据结构再到流程控制,用这些内容可以实现大部分业务逻辑。接下来要学习一些较为高级的内容并发控制。