天天看點

Go語言學習15-特殊流程控制特殊流程控制

特殊流程控制

1. defer語句

defer 語句被用于預定對一個函數的調用。被 defer 語句調用的函數稱為延遲函數。defer 語句隻能出現在函數或方法的内部。

一條 defer 語句總是以關鍵字 defer 開始。在 defer 的右邊還會有一條表達式語句,且它們之間要以空格分隔。例如:

defer fmt.Println("The finishing touches.")           

如上的表達式語句必須代表一個函數或方法的調用。但是像針對各種内建函數的那些調用表達式,因為它們并不能稱為表達式語句,是以不允許出現在這裡。同時這個位置出現的表達式語句是不能被圓括号括起來的。

defer 語句的執行時機總是在直接包含它的那個函數(簡稱外圍函數)把流程控制權交還給它的調用方的前一刻,無論 defer 語句出現在外圍函數的函數體中的哪一個位置上。具體分為:

  • 當外圍函數的函數體中的相應語句全部被正常執行完畢的時候,隻有在該函數中的所有 defer 語句都被執行完畢之後該函數才會真正地結束執行。
  • 當外圍函數的函數體中的 return 語句被執行的時候,隻有在該函數中的所有 defer 語句都被執行完畢之後該函數才會真正地傳回。
  • 當在外圍函數中有運作時恐慌發生的時候,隻有在該函數中的所有 defer 語句都被執行完畢之後該運作時恐慌才會真正地被擴散至該函數的調用方。

也就是說,外圍函數的執行的結束會由于其中的 defer 語句的執行而被推遲。例如:

func isPositiveEnenNumber(number int) (result bool){
    defer fmt.Println("done.");
    if number < 0 {
        panic(errors.New("The number is a negative number!"))
    }
    if number % 2 ==0 {
        return true
    }
    return
}           

上述示例中,無論參數 number 是怎樣的值,以及該函數的執行會以怎樣的方式結束,在該函數的調用方重獲流程控制權之前标準輸出上都一定會列印

done.

綜上總結,使用 defer 語句的優勢有兩個:

  • 收尾任務總會被執行,這樣就不會因粗心大意而造成資源的浪費。
  • 可以把它們放到外圍函數的函數體中的任何地方(一般是函數體開始處或緊跟在申請資源的語句的後面),而不是隻能放在函數體的最後。

在 defer 語句中,調用的函數不但可以是已聲明的命名函數,還可以是臨時編寫的匿名函數。例如:

defer func(){
    fmt.Println("The finishing touches.")
}()           
注意: 一個針對匿名函數的調用表達式是由一個函數字面量和一個代表了調用操作的 一對圓括号 組成的。

無論在 defer 關鍵字右邊的是命名函數還是匿名函數,都可以稱為 延遲函數。因為它總是會被延遲到外圍函數執行結束前一刻才被真正地調用。每當 defer 語句被執行的時候,傳遞給延遲函數的參數都會以通常的方式被求值。如下:

func begin(funcName string) string {
    fmt.Printf("Enter function %s.\n", funcName)
    return funcName
}

func end(funcName string) string {
    fmt.Printf("Exit function %s.\n", funcName)
    return funcName
}

func record(){
    defer end(begin("record"))
    fmt.Println("In function record")
}           

對函數 record 進行調用之後,運作截圖如下:

Go語言學習15-特殊流程控制特殊流程控制

出于同一條 defer 語句可能會被多次執行的考慮,如下:

func printNumbers(){
    for i := 0; i < 5; i++ {
        defer fmt.Printf("%d ", i)
    }
}           

對函數 printNumbers 進行調用之後,運作截圖如下:

Go語言學習15-特殊流程控制特殊流程控制

如上的函數 printNumbers 有兩點需要關注:

  • 在for語句的每次疊代的過程中都會執行一次其中的defer語句。Go語言會把代入參數值之後的調用表達式另行存儲,以此類推,後面幾次疊代所産生的延遲函數調用表達式依次為:
    fmt.Printf("%d ", 0)
    fmt.Printf("%d ", 1)
    fmt.Printf("%d ", 2)
    fmt.Printf("%d ", 3)
    fmt.Printf("%d ", 4)           
  • 對延遲函數調用表達式的求值順序是與它們所在的defer語句被執行的順序完全相反的。每當Go語言把已帶入參數值的延遲函數調用表達式另行存儲之後,還會把它追加到一個專門為目前外圍函數存儲延遲函數調用表達式的清單(也就是棧)當中,而該清單總是先進後出。是以這些延遲函數調用表達式的求值順序會是:
    fmt.Printf("%d ", 4)
    fmt.Printf("%d ", 3)
    fmt.Printf("%d ", 2)
    fmt.Printf("%d ", 1)
    fmt.Printf("%d ", 0)           

我們再看看下面的例子,如下:

func appendNumber(ints []int) (result []int) {
    result = append(ints, 1)
    defer func(){
        result = append(result, 2)
    }()
    result = append(result, 3)
    defer func(){
        result = append(result, 4)
    }()
    result = append(result, 5)
    defer func(){
        result = append(result, 6)
    }()
    return result
}
func main(){
    res := appendNumber([]int{0})
    fmt.Printf("result: %v\n", res)
}           

運作結果截圖如下【大家可以試着按上面的兩點去分析下】:

Go語言學習15-特殊流程控制特殊流程控制

現在考慮一個問題,把 printNumbers 函數的聲明修改為如下:

func printNumbers(){
    for i := 0; i < 5; i++ {
        defer func() {
            fmt.Printf("%d ", i)
        }()
    }
}           

運作結果截圖如下:

Go語言學習15-特殊流程控制特殊流程控制

現在我們對運作結果進行分析可知:

在 for 語句被執行完畢的時候,共有 5 個相同的延遲函數調用表達式被存儲在專屬清單(棧)中,例如:

func() {
    fmt.Printf("%d ", i)
}()           

這時的變量 i 已經被修改為了 5,對 5 個相同的調用表達式的求值都會使标準輸出列印出 5 。

針對上面的情況,可以修改如下:

defer func(i int) {
    fmt.Printf("%d ", i)
}(i) // 在defer語句被執行的時候,傳遞給延遲函數的這個參數i就會被求值。           

運作結果截圖如下(這個和第一個版本的 printNumbers 函數執行效果是相同的):

Go語言學習15-特殊流程控制特殊流程控制

如果 延遲函數 是一個匿名函數,并且在 外圍函數 的聲明中存在命名的結果聲明,那麼在延遲函數中的代碼使可以對命名結果的值進行通路和修改的。例如:

func modify(n int) (number int) {
    defer func(){
        number += n
    }()
    number++
    return
}           

在 延遲函數 的聲明中可以包含結果聲明,但是其傳回的結果值會在它被執行完畢時被丢棄。是以在編寫延遲函數的聲明的時候不會為其添加結果聲明。另外,推薦以傳參的方式提供延遲函數所需的外部值。例如:

// 傳入參數為1時,modify函數的結果值是5
func modify(n int) (number int) {
    defer func(plus int) (result int){
        result = n + plus
        number += result
        return // 此處雖然傳回了結果,但是卻并不會産生任何效果。
    }(3) // 延遲函數調用時直接傳外部參數
    number++
    return
}           

2. 異常處理

在前面的博文中已經涉及了Go語言的異常處理的知識,比如 接口類型error、内建函數panic 和 标準庫代碼包errors。本小節将對Go語言的各種異常處理方法進行系統的講解。

2.1 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) // 傳回一個error類型值,它的動态類型就是errors.errorString類型
}           

有 errorString 的首字母可知,errors.errorString 類型是一個包級私有的類型。它隻是errors 包的内部實作的一部分,而非公開的 API 。errors.errorString 類型及其方法的聲明如下:

type errorString struct {
    s string
}

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

列印error類型值所代表的錯誤的詳細資訊。

var err error = errors.New("A normal error example")
fmt.Println(err)
fmt.Printf("%s\n", err)           

另一個可以生成 error 類型值的方法是調用 fmt 包中的 Errorf 函數,調用類似如下代碼:

// 初始化一個error類型值并作為該函數的結果值傳回給調用方。
err2 := fmt.Errorf("%s\n","A normal error")           

在 fmt.Errorf 函數的内部,建立和初始化 error 類型值的操作正是通過調用 errors.New 函數來完成的。

結構體類型 os.PathError 是一個 error 接口類型的實作類型。聲明及其方法如下:

// 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() 
}           

先判定擷取到的 error 類型值的動态類型,再依次來進行必要的類型轉換和後續操作。例如:

file , err := os.Open("E:\\Software\\lgh.txt")
if err != nil {
    if pe, ok := err.(*os.PathError); ok {// 判斷err是否為*os.PathError類型
        fmt.Printf("Path Error: %s \n(op=%s,path=%s)\n", pe.Err, pe.Op, pe.Path)    
    } else {
        fmt.Printf("Unknown Error: %s\n",err)
    }
}           

如上Open 的參數的檔案路徑不存在,運作截圖如下:

Go語言學習15-特殊流程控制特殊流程控制

在上面示例中的 os.Open 函數在執行過程中沒有發生任何錯誤,就可以對變量 file 的内容進行讀取了。例如:

reader := bufio.NewReader(file) // 建立一個可以讀取檔案内容的讀取器
var buf bytes.Buffer // 緩存從檔案讀取出來的内容
for {
    // reader讀取器,傳回3個結果值。reader類型所屬的方法如下:
    // func (b *Reader) ReadLine() (line []byte, isPrefix bool, err error)
    // 當讀取器讀到file所代表的檔案的末尾時,ReadLine方法會直接将變量io.EOF的值作為它的第三個結果值err傳回。
    byteArray, _, err1 := reader.ReadLine()
    if err1 != nil {
        // io.EOF是error類型的變量,在标準庫代碼包io中,它的聲明如下:
        // var EOF = errors.New("EOF") ,EOF是檔案結束符(End Of File)的縮寫。
        // 嚴格來說,EOF并不應該算作一個真正的錯誤,而僅僅屬于一種"錯誤信号"
        if err1 == io.EOF { // 判斷讀取器是否已經讀到了檔案的末尾
            break
        } else {
            fmt.Printf("Read Error: %s\n", err1)
            break
        }
    } else {
        buf.Write(byteArray)
    }
    fmt.Printf("%s\n", byteArray)
}           

實作 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 類型值。如果變量 err 的動态類型是 net.Error,可以根據它的 Temporary 方法的結果值來判斷目前的錯誤狀态是否臨時的:

if netErr, ok := err.(net.Error); ok && netErr.Temporary(){
    // 省略若幹語句
}           

如果是臨時的,那麼就可以間隔一段時間之後再對之前的操作進行重試,否則就記錄錯誤狀态的資訊并退出。

2.2 panic

Go語言内建的一個專用函數,目的使程式設計人員能夠在自己的程式中報告運作期間的,不可恢複的錯誤狀态。panic 函數被用于停止目前的控制流程的執行并報告一個運作時恐慌。它可以接受一個任意類型的參數值,這個參數常常是一個 string 類型值或者 error 類型值。例如:

package main

import (
    "errors"
)

func main(){
    outerFunc()
}

func outerFunc(){
    innerFunc()
}

func innerFunc(){
    panic(errors.New("A intended fatal error!"))
}           

當在函數 innerFunc 中調用 panic 函數之後,函數 innerFunc 的執行會被停止。然後,流程控制權會被交回給函數 innerFunc 的調用方 outerFunc 函數,此時,outerFunc 函數的執行也将被停止。運作時恐慌就這樣沿着調用棧反方向進行傳達,直至到達目前 Goroutine(也被稱為Go程,可以看作是一個能夠獨占一個系統線程并在其中運作程式的獨立環境)調用棧的最頂層。這時,目前 Goroutine 的調用棧中的所有函數的執行都已經被停止了,意味着程式已經崩潰了。

運作時恐慌并不都是通過調用 panic 函數的方式引發的。它也可以由Go語言的運作時系統來引發。例如:

myIndex := 4
ia := [3]int{1, 2, 3}
_ = ia[myIndex] // 産生了一個數組通路越界的運作時錯誤,會引發一個運作時恐慌。           

如上這個運作時恐慌由運作時系統報告的,它相當于顯示地調用 panic 函數并傳入一個 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()
}           

2.3 recover

運作時恐慌一旦被引發就會向調用方傳遞直至程式崩潰。Go語言提供了專用于“攔截”運作時恐慌的内建函數--- recover。它可以使目前的程式從運作時恐慌的狀态中恢複并重新獲得流程控制權。recover 函數有一個 interface{} 類型的結果值,如果目前的程式正處于運作時恐慌的狀态下,那麼調用 recover 函數将會得到一個 非nil 的 interface{} 類型值。如果當時的運作時恐慌是由Go語言的運作時程式引發的,就會獲得一個 runtime.Error 類型的值。

隻有在 defer 語句的延遲函數中調用 recover 函數才能夠起到“攔截”運作時恐慌的作用。例如:

defer func(){
    if r := recover(); r != nil {
        fmt.Printf("Recovered panic: %s\n", r)
    }
}()           

再看如下一個示例,有助于了解 panic 函數、recover 函數和 defer 語句有關的運作機制。例如:

package main

import (
    "fmt"
)

func main(){
    fetchDemo()
    // 由于運作時恐慌在将要被繼續傳遞給fetchDemo函數的調用方的時候被“攔截”。
    // 是以fetchDemo函數的調用方(也就是main函數)得以重獲流程控制權,下一條語句可以列印
    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 elements is \"%s\". [index=%d]\n", element, index)
    fetchElement(ss, index + 1)
    return
}           

如上指令源碼檔案運作結果截圖:

Go語言學習15-特殊流程控制特殊流程控制

在Go語言标準庫中,即使使用的某個程式實體的内部發生了運作時恐慌,這個運作時恐慌也會在被傳遞給我們編寫的程式使用方之前被“平息”并以 error 類型值的形式傳回給使用方。在這些标準庫代碼包中,往往都會有自己的 error 接口類型的實作。隻有當調用 recover 函數得到的結果值的類型是它們自定義的 error 類型的實作類型的時候,才會去處理這個運作時恐慌,否則就會重新引發一個運作時恐慌(re-panic)并攜帶相同的值。

在标準庫代碼包 fmt 中 scan.go 的 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(e)           

在使用Go語言編寫程式時,在使用上面類似 Token 函數的慣用法之前應該明确和統一可以被立即處理和需要被重新引發的運作時恐慌的種類。一般情況下,如果攜帶的值是動态類型為 runtime.Error 的 error 類型值的話,這個運作時恐慌就應該被重新引發。從運作時恐慌的分類和處理決策角度看,在必要時自行定義一些 error 類型的實作類型是有好處的。

建議: 對于運作時恐慌的引發,應該在遇到緻命的、不可恢複的錯誤狀态時才去引發一個運作時恐慌,否則,可以完全利用函數或方法的結果值來向程式使用方傳達錯誤狀态。另外,應該僅在程式子產品的邊界位置上的函數或方法中對運作時恐慌進行“攔截”和“平息”。

結語

繼續閱讀