Go语言流程控制语句新解

2021-08-21

最近准备总结一下如何在 Go 语言中处理错误。错误处理的本质还是流程控制。所以今天先介绍一下 Go 语言的流程控制语句。有人可能会说流程控制不就是判断、循环,都很简单,有什么可说的。我写这篇文章的主要观点是主讲 if 和 goto,其他像 switch 和 for 不过是前两者的变种或者结合,是语法糖,而panic/recover 则可以看成是跨越函数的 goto。通过这种方式,我们可以系统地理解 Go 语言的流程控制机制,为后面的错误处理打下基础。

流程控制,顾名思义,这是控制程序的执行流程。如果没有流程控制语句,程序就会按顺序依次执行。下面的语句最终会输出数字9。

a := 1
a += 3
a += 5
fmt.Println(a)

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 本质上是 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。

除了if语法,Go 语言还支持goto语法来改变程序的执行顺序。比如:

a := 1
goto foo
a += 1
foo:
a += 2

程序在执行的时候会跳过a += 1语句,执行foo:后面的a += 2。

如果只用goto语句,那能做的事情比较少。但如果把if跟goto结合起来,效果就不一样了。比如:

a := 0
foo:
a += 1
if a < 10 {
  goto foo
}

我们在 a += 1 前面加上foo标签,然后根据 a 的值决定是否跳回 foo 标签重复执行 a += 1,这就实现了条件循环的效果。如果去掉if判断,就会形成无限循环效果。

如果我们想控制循环次数,则可以引入一个计数变量:

a,i := 0,0
foo:
a += 2
if i < 10 {
	i++
	goto foo
}

上面的代码会重复执行a += 2 10 次。

因为循环功能很常用,所以 Go 语言将其简化为 for 语句。刚才的例子可以简化为:

a := 0
for i := 0; i < 10; i++ {
  a += 2
}

for 跟 if 语句有点像,但条件部分i := 0; i < 10; i++又分成三部分,用分号分隔。第一部分i := 0 是用来初始化变量,只在循环开始之前执行一次;第二部分i < 10是判断条件,每次循环的时候都会判断一下,只有在条件成立的时候才会执行大括号里的语句;第三部分i++是在每次执行完大括号里的语句之后才会执行。

for语句说白了是if跟goto的结合,只是一种语法糖,并没有引入新功能。

好了,到现在我们已经介绍了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 {
    	fmt.Println(p)
  	}
  }()
  
  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 语言最核心的流控机制是 if 和 goto,像 switch 和 for 都是它们的变种或者结合,像 panic/recover 则可以看成一种跨函数的 goto。

我会在下一篇介绍Go语言的错误处理机制。

「taoshu」微信公众号