Go并发编程实战4.3 异常处理_Go并发编程实战4.3 异常处理试读-查字典图书网
查字典图书网
当前位置: 查字典 > 图书网 > 编程 > Go并发编程实战 > 4.3 异常处理

Go并发编程实战——4.3 异常处理

我们在本书前面的内容中已经涉及了一些Go语言的异常处理方面的内容,比如接口类型error、内建函数panic和标准库代码包errors。在本节,我们会对Go语言的各种异常处理方法进行系统的讲解,并试图一窥这些方法背后的内涵和哲学。 4.3.1 error 在编写Go语言代码的时候,我们应该习惯使用error类型值来表明非正常的状态。作为惯用法,在Go语言标准库代码包中的很多函数和方法也会以返回error类型值来表明错误状态及其详细信息。 我们之前说过,error是一个预定义标识符,它代表了一个Go语言内建的接口类型。这个接口类型的声明如下: type error interface { Error() string } 它非常地简单。其中的Error方法声明的意义就在于为方法调用方提供当前错误状态的详细信息。任何数据类型只要实现了这个可以返回string类型值的Error方法就可以成为一个error接口类型的实现。不过在通常情况下,我们并不需要自己去编写一个error的实现类型。Go语言的标准库代码包errors为我们提供了一个用于创建error类型值的函数New。该方法的声明如下: func New(text string) error { return &errorString{text} } 可以看到,errors.New函数接受一个string类型的参数值并可以返回一个error类型值。这个error类型值的动态类型就是errors.errorString类型。New函数的唯一参数被用于初始化那个errors.errorString类型的值。从代表这个实现类型的名称上可以看出,该类型是一个包级私有的类型。它只是errors包的内部实现的一部分,而非公开的API。errors.errorString类型及其方法的声明如下: type errorString struct { s string } func (e *errorString) Error() string { return e.s } 把errors.New函数、errors.errorString及其方法的声明联系起来看,我们就可以知道:传递给errors.New函数的参数值就是当我们调用它的Error方法的时候返回的那个结果值。 我们可以使用代码包fmt中的打印函数打印出error类型值所代表的错误的详细信息,就像这样: var err error = errors.New("A normal error.") fmt.Println(err) // 也可以是 fmt.Printf("%sn", err) 等等。 这些打印函数在发现欲打印的内容是一个error类型值的时候都会调用该值的Error方法并将结果值作为该值的字符串表示形式。因此,我们传递给errors.New的参数值即是其返回的error类型值的字符串表示形式。 另一个可以生成error类型值的方法是调用fmt包中的Errorf函数。调用它的代码类似于: err2 := fmt.Errorf("%sn", "A normal error.") 与fmt.Printf函数相同,fmt.Errorf函数可以根据格式说明符和后续参数生成一个字符串类型值。但与fmt.Printf函数不同的是,fmt.Errorf函数并不会在标准输出上打印这个生成的字符串类型值,而是用它来初始化一个error类型值并作为该函数的结果值返回给调用方。在fmt.Errorf函数的内部,创建和初始化error类型值的操作正是通过调用errors.New函数来完成的。 在大多数情况下,errors.New函数和fmt.Errorf函数足以满足我们创建error类型值的要求。但是,接口类型error使得我们拥有了很大的扩展空间。我们可以根据需要定义自己的error类型。例如,我们可以使用额外的字段和方法让程序使用方能够获取更多的错误信息。例如,结构体类型os.PathError是一个error接口类型的实现类型。它的声明中包含了3个字段,这使得我们能够从它的Error方法的结果值当中获取到更多的信息。os.PathError类型及其方法的声明如下: // PathError records an error and the operation and // file path that caused it. type PathError struct { Op string // "open", "unlink", etc. Path string // The associated file. Err error // Returned by the system call. } func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() } 从os.PathError类型的声明上我们可以获知,它的这3个字段都是公开的。因此,在任何位置上我们都可以直接通过选择符访问到它们。但是,在通常情况下,函数或方法中的相关结果声明的类型应该是error类型,而不应该是某一个error类型的实现类型。这也是为了遵循面向接口编程的原则。在这种情况下,我们常常需要先判定获取到的error类型值的动态类型,再依此来进行必要的类型转换和后续操作。例如: file, err3 := os.Open("/etc/profile") if err3 != nil { if pe, ok := err3.(*os.PathError); ok { fmt.Printf("Path Error: %s (op=%s, path=%s)n", pe.Err, pe.Op, pe.Path) } else { fmt.Printf("Uknown Error: %sn", err3) } } 在这个示例中,我们通过类型断言表达式和if语句来对os.Open函数返回的error类型值进行处理。这与把error类型值作为结果值(之一)来表达函数执行的错误状态的做法一样,也属于Go语言中的异常处理的惯用法之一。 如果上面示例中的os.Open函数在执行过程中没有发生任何错误,那么我们就可以对变量file所代表的文件的内容进行读取了。相关代码如下: r := bufio.NewReader(file) var buf bytes.Buffer for { byteArray, _, err4 := r.ReadLine() if err4 != nil { if err4 == io.EOF { break } else { fmt.Printf("Read Error: %sn", err4) break } } else { buf.Write(byteArray) } } 在这段代码中,我们使用到了几个之前没有遇到过的标准库代码包,它们是bufio、bytes和io。我们利用bufio.NewReader函数来创建一个可以读取文件内容的读取器,并利用bytes.Buffer类型的值来缓存从文件读取出来的内容。请读者注意示例中使用的error类型的变量io.EOF。在标准库代码包io中,它的声明如下: var EOF = errors.New("EOF") 可以看到,io.EOF变量正是由errors.New函数的结果值来初始化的。EOF是文件结束符(End Of File)的缩写。对于文件读取操作来说,它意味着读取器已经读到了文件的末尾。因此,严格来说,EOF并不应该算作一个真正的错误,而仅仅属于一种“错误信号”。 变量r代表了一个读取器。它的ReadLine方法返回3个结果值。第三个结果值的类型就是error类型的。当读取器读到file所代表的文件的末尾时,ReadLine方法会直接将变量io.EOF的值作为它的第三个结果值返回。因此,我们可以很方便地通过比较操作符==来判断第三个结果值是否就是io.EOF变量的值。如果判断的结果为true,那么我们就可以直接终止那个被用于连续读取文件内容的for语句的执行。否则,我们就应该意识到在读取文件内容的过程中有真正的错误发生了,并采取相应的措施。 注意,只有当两个error类型的变量的值确实为同一个值的时候,使用比较操作符==进行判断时才会得到true。从另一个角度看,我们可以预先声明一些error类型的变量,并把它们作为特殊的“错误信号”来使用。任何需要返回同一类“错误信号”的函数或方法都可以直接把这类预先声明的变量的值拿来使用。这样我们就可以很便捷地使用==来识别这些“错误信号”并进行相应的操作了。 不过,需要注意的是,这类变量的值必须都是不可变的。也就是说,它们的实际类型的声明中不应该包含任何公开的字段,并且附属于这些类型的方法也不应该包含对其字段进行赋值的语句。例如,我们前面提到的os.PathError类型就不适合作为这类变量的值的动态类型,否则很可能会造成不可预知的后果。 这种通过预先声明error类型的变量为程序使用方提供便利的做法在Go语言标准库代码包中非常常见。除了我们刚刚讲的io.EOF,在诸如compress/gzip、crypto/dsa、bufio、bytes、database/sql、encoding/binary、fmt/scan等代码包中都包括这样的变量。 关于实现error接口类型的另一个技巧是,我们还可以通过把error接口类型嵌入到新的接口类型中来对它进行扩展。例如,标准库代码包net中的Error接口类型,其声明如下: // An Error represents a network error. type Error interface { error Timeout() bool // Is the error a timeout? Temporary() bool // Is the error temporary? } 一些在net包中声明的函数会返回动态类型为net.Error的error类型值。在使用方,对这种error类型值的动态类型的判定方法与前面提及的基本一致。 如果变量err的动态类型是net.Error,那么就我们可以根据它的Temporary方法的结果值来判断当前的错误状态是否临时的: if netErr, ok := err.(net.Error); ok && netErr.Temporary() { // 省略若干条语句 } 如果是临时的,那么就可以间隔一段时间之后再进行对之前的操作进行重试,否则就记录错误状态的信息并退出。假如我们没有对这个error类型值进行类型断言,也就无法获取到当前错误状态的那个额外属性,更无法决定是否应该进行重试操作了。这种对error类型的无缝扩展方式所带来的益处是显而易见的。 在Go语言中,对错误的正确处理是非常重要的。语言本身的设计和标准库代码中展示的惯用法鼓励我们对发生的错误进行显式地检查。虽然这会使Go语言代码看起来稍显冗长,但是我们可以使用一些技巧来简化它们。这些技巧大都与通用的编程最佳实践大同小异,或者已经或将要包含在我们所讲的内容(自定义错误类型、使用卫述语句、单一职责函数等)中,所以这并不是问题。况且,这一点点代价比传统的try-catch方式带来的弊端要小得多。 4.3.2 panic和recover 在通常情况下,向程序使用方报告错误状态的方式可以是返回一个额外的error类型值。但是,当遇到不可恢复的错误状态的时候,很可能会导致程序无法继续运行。这时,上述错误处理方式显然就不适合了。反过来讲,在一般情况下,我们不应通过调用panic函数来报告普通的错误,而应该只把它作为报告致命错误的一种方式。 1. panic 为了使编程人员能够在自己的程序中报告运行期间的、不可恢复的错误状态,Go语言内建了一个专用函数——panic。我们在讲内建函数的时候已经提到过它。panic函数被用于停止当前的控制流程的执行并报告一个运行时恐慌。它可以接受一个任意类型的参数值。然而这个参数常常是一个string类型值或者error类型值,因为这样可以更容易地描述运行时恐慌的详细信息。请看下面的例子: func main() { outerFunc() } func outerFunc() { innerFunc() } func innerFunc() { panic(errors.New("A intended fatal error!")) } 当在函数innerFunc中调用了panic函数之后,函数innerFunc的执行会被停止。然后,流程控制权会被交回给函数innerFunc的调用方outerFunc函数。然而,outerFunc函数的执行也将被停止,就像在其中调用innerFunc函数的位置上调用了panic函数一样。运行时恐慌就这样沿着调用栈反方向进行传达,直至到达当前Goroutine(也被称为Go程,可以看作是一个能够独占一个系统线程并在其中运行程序的独立环境)调用栈的最顶层。这时,当前Goroutine的调用栈中的所有函数的执行都已经被停止了。这也意味着程序已经崩溃。 当然,运行时恐慌并不都是通过调用panic函数的方式引发的。它也可以由Go语言的运行时系统来引发。例如: myIndex := 4 ia := [3]int{1, 2, 3} _ = ia[myIndex] 这个示例中的第三行代码会引发一个运行时恐慌,因为它造成了一个数组访问越界的运行时错误。这个运行时恐慌就是由运行时系统报告的。它相当于我们显式地调用panic函数并传入一个runtime.Error类型的参数值。runtime.Error类型的声明如下: type Error interface { error // RuntimeError is a no-op function but // serves to distinguish types that are runtime // errors from ordinary errors: a type is a // runtime error if it has a RuntimeError method. RuntimeError() } 可以看到,接口类型runtime.Error将error接口类型嵌入其中,并添加了RuntimeError方法的声明。显然,runtime.Error类型是error接口类型的一个扩展,而RuntimeError方法声明则只是作为runtime.Error类型一个标志存在的。我们可以通过在上一小节讲到的惯用法来判定运行时恐慌中携带的error类型值的动态类型是否是RuntimeError类型。不过,这里有一个问题:我们怎样“拦截”一个运行时恐慌并取出其中携带的值呢? 2. recover 运行时恐慌一旦被引发就会向调用方传递直至程序崩溃。这当然不是我们愿意看到的,因为谁也不能保证程序不会发生任何运行时错误。不过,不要担心,Go语言为我们提供了专用于“拦截”运行时恐慌的内建函数——recover。它可以使当前的程序从运行时恐慌的状态中恢复并重新获得流程控制权。recover函数有一个interface{}类型的结果值。如果当前的程序正处于运行时恐慌的状态下,那么调用recover函数将会让我们得到一个非nil的interface{}类型值。如果当时的运行时恐慌是由Go语言的运行时程序引发的,那么我们就会获得一个runtime.Error类型的值。 不过,单靠这个内建函数并不足以“拦截”运行时恐慌。因为运行时恐慌让当前程序失去了流程控制权,我们无法让一段代码在运行时恐慌被引发之后执行。但是这有一个例外,defer语句中的延迟函数总会被执行,不论它的外围函数是以怎样的方式被终止执行的。所以,我们还需要将recover函数与defer语句配合起来使用。更确切地说,只有在defer语句的延迟函数中调用recover函数才能够真正起到“拦截”运行时恐慌的作用。按照惯例,我们在函数或方法中使用它们的方式应该形如: defer func() { if r := recover(); r != nil { fmt.Printf("Recovered panic: %sn", r) } }() 我们现在编写一个更复杂一些的示例,以使大家能够更加深刻地理解与panic函数、recover函数和defer语句有关的运行机制。我们把这些示例代码组织成了一个命令源码文件。它的完整代码如下: package main // 省略导入语句 func main() fetchDemo() fmt.Println("The main function is executed.") } func fetchDemo() { defer func() { if v := recover(); v != nil { fmt.Printf("Recovered a panic. [index=%d]n", v) } }() ss := []string{"A", "B", "C"} fmt.Printf("Fetch the elements in %v one by one...n", ss) fetchElement(ss, 0) fmt.Println("The elements fetching is done.") } func fetchElement(ss []string, index int) (element string) { if index >= len(ss) { fmt.Printf("Occur a panic! [index=%d]n", index) panic(index) } fmt.Printf("Fetching the element... [index=%d]n", index) element = ss[index] defer fmt.Printf("The element is "%s". [index=%d]n", element, index) fetchElement(ss, index+1) return } 在这个示例中,我们通过向标准输出打印不同内容的方式来体现程序在运行过程中的执行流程。在运行这个命令源码文件之后,标准输出上会出现如下内容: 1: Fetch the elements in [A B C] one by one... 2: Fetching the element... [index=0] 3: Fetching the element... [index=1] 4: Fetching the element... [index=2] 5: Occur a panic! [index=3] 6: The element is "C". [index=2] 7: The element is "B". [index=1] 8: The element is "A". [index=0] 9: Recovered a panic. [index=3] 10: The main function is executed. 为了查看方便,我为每行打印内容都加入了行号。表示行号的数字均在每行的最左边,且与真正的打印内容之间用冒号“:”和若干空格分隔。现在,我们来解释一下上面的输出内容。main函数中的代码调用了fetchDemo函数。在fetchDemo函数中的代码调用fetchElement函数之前,第1行内容被打印出来了。由于在fetchElement函数中存在递归调用(fetchElement函数在其代码块的最后调用了自身),所以接下来的第2、3、4行的内容都是由于函数调用语句 fmt.Printf("Fetching the element... [index=%d]n", index) 的执行而被打印出来的。 函数fetchElement中的递归调用使得延迟函数一直没有被执行的机会。还记得吗?defer语句中的延迟函数仅会在其外围函数的执行将要结束的时候才会被执行。这种情况直到在fetchElement函数被第四次调用的时候才有所转变。在fetchElement函数被第四次调用的时候,传递给它的第二个参数值大于了第一个参数的最大索引值,这时我们通过调用panic函数并传递给它当前的索引值引发了一个运行时恐慌。这时,调用语句 fmt.Printf("Occur a panic! [index=%d]n", index) 已经使第5行的内容被打印到了标准输出上。在运行时恐慌发生后,它被沿着调用栈逐一地向顶层传达。这使fetchElement函数中的延迟函数调用语句得以执行,以至于第6、7、8行内容被陆续打印出来。当运行时恐慌已经被传递到fetchDemo函数中且正要向它的调用方继续传递的时候,被fetchDemo函数中的那个延迟函数中的代码“拦截”了。我们再来看一下这个延迟函数的代码: defer func() { if v := recover(); v != nil { fmt.Printf("Recovered a panic. [index=%d]n", v) } }() 显然,运行时恐慌能够被“拦截”的原因是在该延迟函数中的那个针对recover函数的调用表达式。如果调用recover函数后得到的结果值为nil(参见3.3.5节中对此种情况的说明)就什么都不做。但是在这里,这个结果值就是在fetchElement函数中调用panic函数时传入的那个参数值,即触发运行时恐慌的那个越界的索引值3。因此,也就是有了第9行打印内容。注意,在fetchDemo函数中的最后那条打印语句 fmt.Println("The elements fetching is done.") 永远没有机会被执行,因为在它上一行的针对fetchElement函数的调用语句在被执行的过程中总是会发生运行时恐慌。 最后,由于运行时恐慌在将要被继续传递给fetchDemo函数的调用方的时候被“拦截”(或者说被“平息”)了,因此fetchDemo函数的调用方(也就是main函数)得以重获流程控制权。所以,在main函数中的调用fetchDemo函数的语句下面的打印语句 fmt.Println("The main function is executed.") 是会被执行的。这也就是会有第10行打印内容的原因。 好了,通过上面的这个较大的示例和对它的详细讲解,相信读者已经对运行时恐慌的报告和处理机制有了更进一步的认识。 值得一提的是,在Go语言标准库中可以经常看到的一类惯用法值得我们在编写程序时参考,那就是,即使在我们使用的某个程序实体的内部发生了运行时恐慌,这个运行时恐慌也会在被传递给我们编写的程序使用方之前被“平息”并以error类型值的形式返回给使用方。 另外,在这些标准库代码包中,往往都会有自己的error接口类型的实现。只有当调用recover函数得到的结果值的类型是它们自定义的error类型的实现类型的时候,才会去处理这个运行时恐慌。否则就会重新引发(官方使用的词汇是re-panic)一个运行时恐慌并携带相同的值。 例如,在标准库代码包fmt中的Token函数就是这样处理运行时恐慌的。它的声明如下: func (s *ss) Token(skipSpace bool, f func(rune) bool) (tok []byte, err error) { defer func() { if e := recover(); e != nil { if se, ok := e.(scanError); ok { err = se.err } else { panic(e) } } }() // 省略若干条语句 } 在Token函数包含的延迟函数中,当运行时恐慌携带的值的类型是fmt.scanError类型的时候,这个值就会被赋值给代表结果值的变量err,否则运行时恐慌就会被重新引发。如果这个被重新引发的运行时恐慌被传递到了调用栈的最顶层,那么标准输出上就会打印出类似这样的内容: panic: <运行时恐慌被首次引发时携带的值的字符串形式> [recovered] panic: <运行时恐慌被重新引发时携带的值的字符串形式> goroutine 1 [running]: main.func•001() <调用栈信息> goroutine 2 [runnable]: exit status 2 由于篇幅有限,我们省略了绝大部分调用栈信息。此外,我们还使用被尖括号“<”和“>”括起来的辅助描述来说明一些会根据实际情况变化的内容。我们可以看到,在上面这段打印内容的第一行的最右边包含了内容“[recovered]”。这意味着在运行时恐慌被首次引发之后又被“平息”了。但是,第二行内容表示此运行时恐慌又被重新引发了。因此,在下面的调用栈信息中,不但会包含与该运行时恐慌被首次引发时的调用轨迹和引发位置,还会包含该运行时恐慌被重新引发时的具体位置。 无论我们对一个运行时恐慌重新引发几次,它所有的引发信息都依然会被提供在最终的程序崩溃报告中,就像前面描述的那样。更明确地讲,该运行时恐慌被引发的根本原因永远不会丢失。所以,我们在重新引发一个运行时恐慌的时候使用最简单的方式就足够了,像这样: panic(e) 也就是说,我们一般并不需要再为调用panic函数而另外创建一个新的参数值。 我们在编写自己的程序的时候可以使用上面介绍的这些惯用法。但是,我们应该在使用这种方案之前明确和统一可以被立即处理和需要被重新引发的运行时恐慌的种类。一般情况下,如果携带的值是动态类型为runtime.Error的error类型值的话,这个运行时恐慌就应该被重新引发。另外,从运行时恐慌的分类和处理决策角度看,我们在必要时自行定义一些error类型的实现类型是很有好处的。 综上所述,对于运行时恐慌的引发,我们应该持谨慎态度。更确切地说,我们应该仅在遇到致命的、不可恢复的错误状态时才去引发一个运行时恐慌。否则,我们完全可以利用函数或方法的结果值来向程序使用方传达错误状态。另一方面,我们应该仅在处于程序模块的边界位置上的函数或方法中对运行时恐慌进行“拦截”和“平息”。在运行时恐慌的处理方式上,我们可以大致遵循这样的流程:“拦截”、判定运行时恐慌的种类、根据相关决策处理(“平息”、记录日志或重新引发,等等)。 总之,对运行时恐慌的合理运用是优秀代码和程序的必备条件之一。这涉及panic函数、defer语句和recover函数。希望本小节的内容能够让读者更好地使用它们。

展开全文


推荐文章

猜你喜欢

附近的人在看

推荐阅读

拓展阅读

《Go并发编程实战》其他试读目录

• 1.1 Go语言特性一瞥
• 1.2 Go语言的优劣
• 1.3 怎样学习Go语言
• 1.4 本章小结
• 4.1 基本流程控制
• 4.2 defer语句
• 4.3 异常处理 [当前]
• 4.4 实战演练——Set
• 4.5 实战演练——Ordered Map
• 4.6 本章小结
• 8.1 锁的使用
• 8.2 条件变量
• 8.3 原子操作
• 8.4 只会执行一次
• 8.5 WaitGroup
• 8.6 临时对象池
• 8.7 实战演练——Concurrent Map
• 8.8 本章小结
  • 大家都在看
  • 小编推荐
  • 猜你喜欢
  •