Go语言流程控制语句新解

2021-08-21 ⏳5.0分钟(2.0千字) 🕸️

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

条件分支if

流程控制,顾名思义,这是控制程序的执行流程。如果没有流程控制语句,程序就会按顺序依次执行。下面的语句最终会输出数字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 语法。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语法来改变程序的执行顺序。比如:

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

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

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

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 次。

循环for

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

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

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

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

Go语言还为切片、数组、字典等容器提供了一个特殊的语法糖range。如果不用它,遍历数组或者切片需要写成:

var a = []int{1,2,3}
for i := 0; i < len(a); i++ {
  fmt.Println(a[i])
}

有了range上述代码可以简化为:

for k, v := range a {
  fmt.Println(a[i])
}

这样就不需要自己维护下标变量i。对于map类型,因为没办法事先确定 key 的范围,所以只能使用range遍历:

var b = make(map[int]string)
for k, v = range b {
 fmt.Println(k, ":", v)
}

range会自动处理容器对象为nil的情况,不需要自己检测。

跨函数跳转panic

好了,到现在我们已经介绍了ifgoto这两种流程控制语句(switchfor是他们的变体)。这两种语句有一个重要的缺点,不能跨越函数。比如下面的写法是不对的:

func bar() {
  b1:
  foo()
  // ...
}
func foo() {
  goto b1
}

我们不能通过goto从函数foo跳到回函数bar。但在有些场景下,这种跳转是有必要的。为此,Go 语言提供了 panicrecover 语法。先说 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 语言中的 setjumplongjump,是一种跨函数版的 goto

Go语言还有种fatal error,也是在运行时触发,但没法用recover拦截,具体可以参考我的这篇文章

流程控制另一个重要使用场景是处理错误。这部分内容我专门整理了一篇文章Go语言的错误处理机制

总结

最后总结一下。Go 语言最核心的流控机制是 ifgoto,像 switchfor 都是它们的变种或者结合,像 panic/recover 则可以看成一种跨函数的 goto。从函数到数据结构再到流程控制,用这些内容可以实现大部分业务逻辑。接下来要学习一些较为高级的内容并发控制