Skip to content

更多流程控制

defer语句

select语句一样,Go语言中的defer语句也非常独特,而且比前者有过之而无不及。defer语句仅能被放置在函数或方法中。它由关键字defer和一个调用表达式组成。注意,这里的调用表达式所表示的既不能是对Go语言内建函数的调用也不能是对Go语言标准库代码包unsafe中的那些函数的调用。实际上,满足上述条件的调用表达式被称为表达式语句。请看下面的示例:

1
2
3
4
5
6
7
8
func readFile(path string) ([]byte, error) {
    file, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer file.Close()
    return ioutil.ReadAll(file)
}

函数readFile的功能是读出指定文件或目录(以下统称为文件)本身的内容并将其返回,同时当有错误发生时立即向调用方报告。其中,osioutil(导入路径是io/ioutil)代表的都是Go语言标准库中的代码包。请注意这个函数中的倒数第二条语句。我们在打开指定文件且未发现有错误发生之后,紧跟了一条defer语句。其中携带的表达式语句表示的是对被打开文件的关闭操作。注意,当这条defer语句被执行的时候,其中的这条表达式语句并不会被立即执行。它的确切的执行时机是在其所属的函数(这里是readFile)的执行即将结束的那个时刻。也就是说,在readFile函数真正结束执行的前一刻,file.Close()才会被执行。这也是defer语句被如此命名的原因。我们在结合上下文之后就可以看出,语句defer file.Close()的含义是在打开文件并读取其内容后及时地关闭它。该语句可以保证在readFile函数将结果返回给调用方之前,那个文件或目录一定会被关闭。这实际上是一种非常便捷和有效的保险措施。

更为关键的是,无论readFile函数正常地返回了结果还是由于在其执行期间有运行时恐慌发生而被剥夺了流程控制权,其中的file.Close()都会在该函数即将退出那一刻被执行。这就更进一步地保证了资源的及时释放。

注意,当一个函数中存在多个defer语句时,它们携带的表达式语句的执行顺序一定是它们的出现顺序的倒序。下面的示例可以很好的证明这一点:

func deferIt() {
    defer func() {
        fmt.Print(1)
    }()
    defer func() {
        fmt.Print(2)
    }()
    defer func() {
        fmt.Print(3)
    }()
    fmt.Print(4)
}

deferIt函数的执行会使标准输出上打印出4321。请大家猜测下面这个函数被执行时向标准输出打印的内容,并真正执行它以验证自己的猜测。最后论证一下自己的猜测为什么是对或者错的。

1
2
3
4
5
func deferIt2() {
    for i := 1; i < 5; i++ {
        defer fmt.Print(i)
    }
}

最后,对于defer语句,我还有两个特别提示:

  1. defer携带的表达式语句代表的是对某个函数或方法的调用。这个调用可能会有参数传入,比如:fmt.Print(i + 1)。如果代表传入参数的是一个表达式,那么在defer语句被执行的时候该表达式就会被求值了。注意,这与被携带的表达式语句的执行时机是不同的。请揣测下面这段代码的执行:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    func deferIt3() {
        f := func(i int) int {
            fmt.Printf("%d ",i)
            return i * 10
        }
        for i := 1; i < 5; i++ {
            defer fmt.Printf("%d ", f(i))
        }
    }
    

它在被执行之后,标准输出上打印出1 2 3 4 40 30 20 10

  1. 如果defer携带的表达式语句代表的是对匿名函数的调用,那么我们就一定要非常警惕。请看下面的示例:
    1
    2
    3
    4
    5
    6
    7
    func deferIt4() {
        for i := 1; i < 5; i++ {
            defer func() {
                fmt.Print(i)
            }()
        }
    }
    

deferIt4函数在被执行之后标出输出上会出现5555,而不是4321。原因是defer语句携带的表达式语句中的那个匿名函数包含了对外部(确切地说,是该defer语句之外)的变量的使用。注意,等到这个匿名函数要被执行(且会被执行4次)的时候,包含该defer语句的那条for语句已经执行完毕了。此时的变量i的值已经变为了5。因此该匿名函数中的打印函数只会打印出5。正确的用法是:把要使用的外部变量作为参数传入到匿名函数中。修正后的deferIt4函数如下:

1
2
3
4
5
6
7
func deferIt4() {
    for i := 1; i < 5; i++ {
        defer func(n int) {
            fmt.Print(n)
        }(i)
    }
}

范例:输出 0 1 1 2 3 5 8 13 21 34 34 21 13 8 5 3 2 1 1 0

package main

import (
    "fmt"
)

func main() {
    for i := 0; i < 10; i++ {
        defer func(n int) {
            fmt.Printf("%d ", n)
        }(func() int {
            n := fibonacci(i)
            fmt.Printf("%d ", n)
            return n
        }())
    }
}

func fibonacci(num int) int {
    if num == 0 {
        return 0
    }
    if num < 2 {
        return 1
    }
    return fibonacci(num-1) + fibonacci(num-2)
}

异常处理 ---- error

Go语言的函数可以一次返回多个结果。这就为我们温和地报告错误提供了语言级别的支持。实际上,这也是Go语言中处理错误的惯用法之一。我们先来回顾前一小节的例子:

1
2
3
4
5
6
7
8
func readFile(path string) ([]byte, error) {
    file, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer file.Close()
    return ioutil.ReadAll(file)
}

函数readFile有两个结果声明。第二个结果声明的类型是errorerror是Go语言内置的一个接口类型。它的声明是这样的:

1
2
3
type error interface { 
    Error() string
}

显然,只要一个类型的方法集合包含了名为Error、无参数声明且仅声明了一个string类型的结果的方法,就相当于实现了error接口。os.Open函数的第二个结果值就的类型就是这样的。我们把它赋给了变量err。也许你已经意识到,在Go语言中,函数与其调用方之间温和地传递错误的方法即是如此。

在调用了os.Open函数并取得其结果之后,我们判断err是否为nil。如果答案是肯定的,那么就直接把该错误(这里由err代表)返回给调用方。这条if语句实际上是一条卫述语句。这样的语句会检查流程中的某个步骤是否存在异常,并在必要时中止流程并报告给上层的程序(这里是调用方)。在Go语言的标准库以及很多第三方库中,我们经常可以看到这样的代码。我们也建议大家在自己的程序中善用这样的卫述语句。

现在我们把目光聚焦到readFile函数中的最后一条语句上。这是一条return语句。它把对ioutil.ReadAll函数的调用的结果直接作为readFile函数的结果返回了。实际上,ioutil.ReadAll函数的结果声明列表与readFile的结果声明列表是一致的。也就是说,它们声明的结果的数量、类型和顺序都是相同的。因此,我们才能够做这种返回结果上的“嫁接”。这又是一个Go语言编码中的惯用法。

好了,在知晓怎样在传递错误之后,让我们来看看怎样创造错误。没错,在很多时候,我们需要创造出错误(即error类型的值)并把它传递给上层程序。这很简单。只需调用标准库代码包errors的New函数即可。例如,我们只要在readFile函数的开始处加入下面这段代码就可以更快的在参数值无效时告知调用方:

1
2
3
if path == "" {
    return nil, errors.New("The parameter is invalid!")
}

errors.New是一个很常用的函数。在Go语言标准库的代码包中有很多由此函数创建出来的错误值,比如os.ErrPermissionio.EOF等变量的值。我们可以很方便地用操作符==来判断一个error类型的值与这些变量的值是否相等,从而来确定错误的具体类别。就拿io.EOF来说,它代表了一个信号。该信号用于通知数据读取方已无更多数据可读。我们在得到这样一个错误的时候不应该把它看成一个真正的错误,而应该只去结束相应的读取操作。请看下面的示例:

br := bufio.NewReader(file)
var buf bytes.Buffer
for {
    ba, isPrefix, err := br.ReadLine()
    if err != nil {
        if err == io.EOF {
            break
        }
        fmt.Printf("Error: %s\n", err)
        break
    }
    buf.Write(ba)
    if !isPrefix {
        buf.WriteByte('\n')
    }
}

可以看到,这段代码使用到了前面示例中的变量file。它的功能是把file代表的文件中的所有内容都读取到一个缓冲器(由变量buf代表)中。请注意,该示例中的第6~8行代码。如果判定err代表的错误值等于io.EOF的值(即它们是同一个值),那么我们只需退出当前的循环以使读取操作结束即可。

总之,只要能够善用error接口、errors.New函数和比较操作符==,我们就可以玩儿转Go语言中的一般错误处理。

异常处理 ---- panic

我们已经了解到了足够多的error接口及其周边的知识。现在,是学习另外一种异常处理方式的时候了。先来展示一个名词——panicpanic可被意译为运行时恐慌。因为它只有在程序运行的时候才会被“抛出来”。并且,恐慌是会被扩散的。当有运行时恐慌发生时,它会被迅速地向调用栈的上层传递。如果我们不显式地处理它的话,程序的运行瞬间就会被终止。这里有一个专有名词——程序崩溃。内建函数panic可以让我们人为地产生一个运行时恐慌。不过,这种致命错误是可以被恢复的。在Go语言中,内建函数recover就可以做到这一点。

实际上,内建函数panicrecover是天生的一对。前者用于产生运行时恐慌,而后者用于“恢复”它。不过要注意,recover函数必须要在defer语句中调用才有效。因为一旦有运行时恐慌发生,当前函数以及在调用栈上的所有代码都是失去对流程的控制权。只有defer语句携带的函数中的代码才可能在运行时恐慌迅速向调用栈上层蔓延时“拦截到”它。这里有一个可以起到此作用的defer语句的示例:

1
2
3
4
5
defer func() {
    if p := recover(); p != nil {
        fmt.Printf("Fatal error: %s\n", p)
    }
}()

在这条defer语句中,我们调用了recover函数。该函数会返回一个interface{}类型的值。还记得吗?interface{}代表空接口。Go语言中的任何类型都是它的实现类型。我们把这个值赋给了变量p。如果p不为nil,那么就说明当前确有运行时恐慌发生。这时我们需根据情况做相应处理。注意,一旦defer语句中的recover函数调用被执行了,运行时恐慌就会被恢复,不论我们是否进行了后续处理。所以,我们一定不要只“拦截”不处理。

我们下面来反观panic函数。该函数可接受一个interface{}类型的值作为其参数。也就是说,我们可以在调用panic函数的时候可以传入任何类型的值。不过,我建议大家在这里只传入error类型的值。这样它表达的语义才是精确的。更重要的是,当我们调用recover函数来“恢复”由于调用panic函数而引发的运行时恐慌的时候,得到的值正是调用后者时传给它的那个参数。因此,有这样一个约定是很有必要的。

总之,运行时恐慌代表程序运行过程中的致命错误。我们只应该在必要的时候引发它。人为引发运行时恐慌的方式是调用panic函数。recover函数是我们常会用到的。因为在通常情况下,我们肯定不想因为运行时恐慌的意外发生而使程序崩溃。最后,在“恢复”运行时恐慌的时候,大家一定要注意处理措施的得当。

范例:打印 Enter main Enter outerFunc Enter innerFunc Fatal error: Occur a panic!

package main

defer func() {
    if p := recover(); p != nil {
        fmt.Printf("Fatal error: %s\n", p)
    }
}()

import (
    "errors"
    "fmt"
)

func innerFunc() {
    fmt.Println("Enter innerFunc")
    panic(errors.New("Occur a panic!"))
    fmt.Println("Quit innerFunc")
}

func outerFunc() {
    fmt.Println("Enter outerFunc")
    innerFunc()
    fmt.Println("Quit outerFunc")
}

func main() {
    fmt.Println("Enter main")
    outerFunc()
    fmt.Println("Quit main")
}

语句初探

go语句和通道类型是Go语言的并发编程理念的最终体现。在第五章,我已经详细介绍过了通道类型。相比之下,go语句在用法上要比通道简单很多。与defer语句相同,go语句也可以携带一条表达式语句。注意,go语句的执行会很快结束,并不会对当前流程的进行造成阻塞或明显的延迟。一个简单的示例如下:

go fmt.Println("Go!")

可以看到,go语句仅由一个关键字go和一条表达式语句构成。同样的,go语句的执行与其携带的表达式语句的执行在时间上没有必然联系。这里能够确定的仅仅是后者会在前者完成之后发生。在go语句被执行时,其携带的函数(也被称为go函数)以及要传给它的若干参数(如果有的话)会被封装成一个实体(即Goroutine),并被放入到相应的待运行队列中。Go语言的运行时系统会适时的从队列中取出待运行的Goroutine并执行相应的函数调用操作。注意,对传递给这里的函数的那些参数的求值会在go语句被执行时进行。这一点也是与defer语句类似的。

正是由于go函数的执行时间的不确定性,所以Go语言提供了很多方法来帮助我们协调它们的执行。其中最简单粗暴的方法就是调用time.Sleep函数。请看下面的示例:

1
2
3
4
5
6
7
8
9
package main

import (
    "fmt"
)

func main() {
    go fmt.Println("Go!")
}

这样一个命令源码文件被运行时,标准输出上不会有任何内容出现。因为还没等Go语言运行时系统调度那个go函数执行,主函数main就已经执行完毕了。函数main的执行完毕意味着整个程序的执行的结束。因此,这个go函数根本就没有执行的机会。

但是,当我们在上述go语句的后面添加一条对time.Sleep函数的调用语句之后情况就会不同了:

package main

import (
    "fmt"
    "time"
)

func main() {
    go fmt.Println("Go!")
    time.Sleep(100 * time.Millisecond)
}

语句time.Sleep(100 * time.Millisecond)会把main函数的执行结束时间向后延迟100毫秒。100毫秒虽短暂,但足够go函数被调度执行的了。上述命令源码文件在被运行时会如我们所愿地在标准输出上打印出Go!。

另一个比较绅士的做法是在main函数的最后调用runtime.Gosched函数。相应的程序版本如下:

package main

import (
    "fmt"
    "runtime"
)

func main() {
    go fmt.Println("Go!")
    runtime.Gosched()
}

runtime.Gosched函数的作用是让当前正在运行的Goroutine(这里是运行main函数的那个Goroutine)暂时“休息”一下,而让Go运行时系统转去运行其它的Goroutine(这里是与go fmt.Println("Go!")对应并会封装fmt.Println("Go!")的那个Goroutine)。如此一来,我们就更加精细地控制了对几个Goroutine的运行的调度。

当然,我们还有其它方法可以满足上述需求。并且,如果我们需要去左右更多的Goroutine的运行时机的话,下面这种方法也许更合适一些。请看代码:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    wg.Add(3)
    go func() {
        fmt.Println("Go!")
        wg.Done()
    }()
    go func() {
        fmt.Println("Go!")
        wg.Done()
    }()
    go func() {
        fmt.Println("Go!")
        wg.Done()
    }()
    wg.Wait()
}

sync.WaitGroup类型有三个方法可用——AddDoneWaitAdd会使其所属值的一个内置整数得到相应增加,Done会使那个整数减1,而Wait方法会使当前Goroutine(这里是运行main函数的那个Goroutine)阻塞直到那个整数为0。这下你应该明白上面这个示例所采用的方法了。我们在main函数中启用了三个Goroutine来封装三个go函数。每个匿名函数的最后都调用了wg.Done方法,并以此表达当前的go函数会立即执行结束的情况。当这三个go函数都调用过wg.Done函数之后,处于main函数最后的那条wg.Wait()语句的阻塞作用将会消失,main函数的执行将立即结束。

与go语句、go函数以及承载其运行的Goroutine相关的话题其实还有很多。