Go語言學習 二十三 錯誤處理和運行時恐慌(Panic)

本文最初發表在我的個人博客,查看原文,獲得更好的閱讀體驗


一 錯誤

1.1 error類型

按照約定,Go中的錯誤類型爲error,這是一個內建接口,nil值表示沒有錯誤:

type error interface {
        Error() string
}

我們可以很方便的自定義一個錯誤類型:

package main

import (
	"fmt"
)

func main() {
	e := MyError{"This is a custom Error Type."}
	fmt.Println(e.Error())

	v1, err := divide(10, 2)
	if err == nil {
		fmt.Println(v1)
	}

	if v2, err := divide(5, 0); err != nil {
		fmt.Println(err)
	} else {
		fmt.Println(v2)
	}
}

// 自定義錯誤類型
type MyError struct {
	msg string
}

func (e *MyError) Error() string {
	return e.msg
}

// 取整除法
func divide(a1, a2 int) (int, error) {
	if a2 == 0 {
		return 0, &MyError{"整數相除,除數不能爲零"}
	}

	return a1 / a2, nil
}

上述divide函數會返回一個error值,調用方可以根據這個錯誤值來判斷如何處理結果。這種用法在Go中是一種慣用法,尤其在編寫一些函數庫之類的功能時。例如標準庫os中的打開文件的Open函數定義如下:

// Open opens the named file for reading. If successful, methods on
// the returned file can be used for reading; the associated file
// descriptor has mode O_RDONLY.
// If there is an error, it will be of type *PathError.
func Open(name string) (*File, error) {
	return OpenFile(name, O_RDONLY, 0)
}

該函數返回的具體錯誤類型爲PathError

// PathError records an error and the operation and file path that caused it.
type PathError struct {
	Op   string
	Path string
	Err  error
}

func (e *PathError) Error() string { return e.Op + " " + e.Path + ": " + e.Err.Error() }

該錯誤詳細的描述了引發錯誤的操作以及相關文件路徑和錯誤描述信息。

1.2 其他錯誤類型

除此之外,標準庫中還有許多其他預定義的錯誤類型,它們都直接或間接的實現(或內嵌)了error接口。例如:

runtime.Error       // 表示運行時錯誤的Error接口類型
net.Error           // 表示網絡錯誤的Error接口類型
go/types.Error      // 表示類型檢查錯誤的Error結構類型
html/template.Error // 表示html模板轉義期間遇到的問題(結構類型)
os/exec             // 當文件不是一個可執行文件時返回的錯誤(結構類型)

另外,標準庫中的errors包提供了一個函數可方便地返回一個error實例:

// New 函數返回格式爲給定文本的錯誤
func New(text string) error

它的具體實現如下:

// errors 包實現了操作錯誤的函數
package errors

// New 函數返回格式爲給定文本的錯誤
func New(text string) error {
	return &errorString{text}
}

// errorString 是 error 的一個簡單實現(注意是私有的)
type errorString struct {
	s string
}

func (e *errorString) Error() string {
	return e.s
}

示例:

package main

import (
	"errors"
	"fmt"
)

func main() {
	fmt.Println(errors.New("這是一條錯誤信息"))	
}

如果上述錯誤描述過於簡單,還可以使用fmt包中的Errorf函數:

// Errorf根據指定的格式進行格式化參數,並返回滿足error接口的字符串
func Errorf(format string, a ...interface{}) error {
	return errors.New(Sprintf(format, a...))
}

該函數允許我們使用軟件包的格式化功能來創建描述性錯誤消息:

package main

import (
	"fmt"
)

func main() {
	const name, id = "bimmler", 17
	err := fmt.Errorf("user %q (id %d) not found", name, id)
	if err != nil {
		fmt.Print(err)
	}
}

通常,以上兩種方法能滿足絕大多數錯誤場景。如果仍然不夠,正如本文開頭所講,你可以自定義任意的錯誤類型。

二 Panic(恐慌)

內建函數panic可以產生一個運行時錯誤,一旦調用該函數,當前goroutine就會停止正常的執行流程。這種情況一般發生在一些重要參數缺失的檢查時,因爲如果缺失了這些參數,將導致程序不能正常運行,故相比讓程序繼續運行(也可能根本就沒法正常運行),不如讓它及時終止。

func panic(v interface{})

該函數接受一個任意類型的實參(一般爲字符串),並在程序終止時打印。

package main

func main() {
	panic("運行出錯了。")
}

另一類使用場景:

package main

import (
	"fmt"
	"os"
)

func main() {
	fmt.Println("wait for init...")
}

var user = os.Getenv("USER")

func init() {
    // 檢查必要變量等
	if user == "" {
		panic("no value for $USER")
	}
}

一般情況下,我們應避免使用panic,尤其是在庫函數中。

panic被調用後(包括不明確的運行時錯誤,例如數組或切片索引越、類型斷言失敗)等,程序將立刻終止當前函數的執行,並開始回溯goroutine的棧,運行任何被推遲的函數。若回溯到達goroutine棧的頂端,程序就會終止。

假設函數F調用了panic,則F的正常執行將立即停止。F中任何被推遲的函數將依次執行,然後F返回到調用處。對於調用者G,此時好像也在調用panic函數一樣,執行到此立即停止,並開始回溯所有被G推遲的函數。就這樣一直回溯,直到該goroutine中的所有函數都停止,此時,程序終止,並報告錯誤信息,包括傳給panic的參數。

當然,我們還可以使用內建函數recover進行恢復,奪回goroutine的控制權,繼續往下看。

我們在defer語句一文中提到過,defer棧是以LIFO的順序執行的。

三 Recover(恢復)

內建函數recover可以讓發生panickinggoroutine恢復正常運行。在一個被推遲的函數中執行recover可以終止panic的產生的終止回溯調用。注意必須是直接在被推遲的函數中。如果不是在推遲函數中(或間接)調用該函數,則不會發生任何作用,將返回nil。如果程序沒有發生panicpanic的參數爲nil,則recover的返回值也爲nil

func recover() interface{}

以下示例展示了panic和recover的工作機制:

package main

import "fmt"

func main() {
	f()
	fmt.Println("從 f() 中正常返回。")
}

func f() {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("從 f() 中正常恢復。", r)
		}

	}()
	fmt.Println("開始調用函數 g()。。。")
	g(0)
	fmt.Println("從 g() 中正常返回。")
}

func g(i int) {
	if i > 3 {
		fmt.Println("Panicking!")
		panic(fmt.Sprintf("%v", i))
	}
	defer fmt.Println("函數g()中推遲的調用", i)
	fmt.Println("函數g()中的打印", i)
	g(i + 1)
}

func h() {
	fmt.Println("hello")
}

看一個effective_go中的例子:

在服務器中終止失敗的goroutine而無需殺死其它正在執行的goroutine

func server(workChan <-chan *Work) {
    for work := range workChan {
        go safelyDo(work)
    }
}

func safelyDo(work *Work) {
    defer func() {
        if err := recover(); err != nil {
            log.Println("work failed:", err)
        }
    }()
    do(work)
}

通過恰當地使用恢復模式,do函數(及其調用的任何代碼)可通過調用panic來避免更壞的結果。我們可以利用這種思想來簡化複雜軟件中的錯誤處理。

再看一個regexp包的理想化版本,它會以局部的錯誤類型調用panic來報告解析錯誤。以下是一個Error類型,一個error方法和一個Compile函數的定義:

//  Error 是解析錯誤的類型,它滿足 error 接口。
type Error string
func (e Error) Error() string {
    return string(e)
}

// error 是 *Regexp 的方法,它通過用一個 Error 觸發Panic來報告解析錯誤。
func (regexp *Regexp) error(err string) {
    panic(Error(err))
}

// Compile 返回該正則表達式解析後的表示。
func Compile(str string) (regexp *Regexp, err error) {
    regexp = new(Regexp)
    // 如果有解析錯誤,doParse會產生panic
    defer func() {
        if e := recover(); e != nil {
            regexp = nil    // 清理返回值
            err = e.(Error) // 若它不是解析錯誤,將重新觸發Panic。
        }
    }()
    return regexp.doParse(str), nil
}

如果doParse觸發了panic,恢復塊會將返回值設爲nil—被推遲的函數能夠修改已命名的返回值。在err的賦值過程中,我們將通過斷言它是否擁有局部類型Error來檢查它。若它沒有,類型斷言將會失敗,此時會產生運行時錯誤,並繼續棧的回溯,彷彿一切從未中斷過一樣。該檢查意味着若發生了一些像索引越界之類的意外,那麼即便我們使用了panicrecover來處理解析錯誤,代碼仍然會失敗。

通過適當的錯誤處理,error方法(由於它是個綁定到具體類型的方法,因此即便它與內建的error類型名字相同也沒有關係)能讓報告解析錯誤變得更容易,而無需擔心手動處理回溯的解析棧:

if pos == 0 {
    re.error("'*' illegal at start of expression")
}

儘管這種模式很有用,但它應當僅在包內使用。Parse會將其內部的panic調用轉爲error值,它並不會向調用者暴露出panic。這是個值得遵守的良好規則。

另外,這種重新觸發panic的慣用法會在產生實際錯誤時改變panic的值。然而,不管是原始的還是新的錯誤都會在崩潰報告中顯示,因此問題的根源仍然是可見的。這種簡單的重新觸發panic的模型已經夠用了,畢竟它只是一次崩潰。但若你只想顯示原始的值,也可以多寫一點代碼來過濾掉不需要的問題,然後用原始值再次觸發panic

參考:
https://golang.org/doc/effective_go.html#errors
https://golang.org/pkg/builtin/#error
https://blog.golang.org/defer-panic-and-recover

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章