Go語言核心36講(Go語言進階技術十三)--學習筆記

MingsonZheng發表於2021-11-03

19 | 錯誤處理(上)

提到 Go 語言中的錯誤處理,我們其實已經在前面接觸過幾次了。

比如,我們宣告過error型別的變數err,也呼叫過errors包中的New函式。

我們說過error型別其實是一個介面型別,也是一個 Go 語言的內建型別。在這個介面型別的宣告中只包含了一個方法Error。Error方法不接受任何引數,但是會返回一個string型別的結果。它的作用是返回錯誤資訊的字串表示形式。

我們使用error型別的方式通常是,在函式宣告的結果列表的最後,宣告一個該型別的結果,同時在呼叫這個函式之後,先判斷它返回的最後一個結果值是否“不為nil”。

如果這個值“不為nil”,那麼就進入錯誤處理流程,否則就繼續進行正常的流程。下面是一個例子,程式碼在 demo44.go 檔案中。

package main

import (
  "errors"
  "fmt"
)

func echo(request string) (response string, err error) {
  if request == "" {
    err = errors.New("empty request")
    return
  }
  response = fmt.Sprintf("echo: %s", request)
  return
}

func main() {
  for _, req := range []string{"", "hello!"} {
    fmt.Printf("request: %s\n", req)
    resp, err := echo(req)
    if err != nil {
      fmt.Printf("error: %s\n", err)
      continue
    }
    fmt.Printf("response: %s\n", resp)
  }
}

我們先看echo函式的宣告。echo函式接受一個string型別的引數request,並會返回兩個結果。

這兩個結果都是有名稱的,第一個結果response也是string型別的,它代表了這個函式正常執行後的結果值。

第二個結果err就是error型別的,它代表了函式執行出錯時的結果值,同時也包含了具體的錯誤資訊。

當echo函式被呼叫時,它會先檢查引數request的值。如果該值為空字串,那麼它就會通過呼叫errors.New函式,為結果err賦值,然後忽略掉後邊的操作並直接返回。

此時,結果response的值也會是一個空字串。如果request的值並不是空字串,那麼它就為結果response賦一個適當的值,然後返回,此時結果err的值會是nil。

再來看main函式中的程式碼。我在每次呼叫echo函式之後,都會把它返回的結果值賦給變數resp和err,並且總是先檢查err的值是否“不為nil”,如果是,就列印錯誤資訊,否則就列印常規的響應資訊。

這裡值得注意的地方有兩個。第一,在echo函式和main函式中,我都使用到了衛述語句。我在前面講函式用法的時候也提到過衛述語句。簡單地講,它就是被用來檢查後續操作的前置條件並進行相應處理的語句。

對於echo函式來說,它進行常規操作的前提是:傳入的引數值一定要符合要求。而對於呼叫echo函式的程式來說,進行後續操作的前提就是echo函式的執行不能出錯。

我們在進行錯誤處理的時候經常會用到衛述語句,以至於有些人會吐槽說:“我的程式滿屏都是衛述語句,簡直是太難看了!”不過,我倒認為這有可能是程式設計上的問題。每個程式語言的理念和風格幾乎都會有明顯的不同,我們常常需要順應它們的紋理去做設計,而不是用其他語言的程式設計思想來編寫當下語言的程式。

再來說第二個值得注意的地方。我在生成error型別值的時候,用到了errors.New函式。

這是一種最基本的生成錯誤值的方式。我們呼叫它的時候傳入一個由字串代表的錯誤資訊,它會給返回給我們一個包含了這個錯誤資訊的error型別值。該值的靜態型別當然是error,而動態型別則是一個在errors包中的,包級私有的型別*errorString。

顯然,errorString型別擁有的一個指標方法實現了error介面中的Error方法。這個方法在被呼叫後,會原封不動地返回我們之前傳入的錯誤資訊。實際上,error型別值的Error方法就相當於其他型別值的String方法。

我們已經知道,通過呼叫fmt.Printf函式,並給定佔位符%s就可以列印出某個值的字串表示形式。

對於其他型別的值來說,只要我們能為這個型別編寫一個String方法,就可以自定義它的字串表示形式。而對於error型別值,它的字串表示形式則取決於它的Error方法。

在上述情況下,fmt.Printf函式如果發現被列印的值是一個error型別的值,那麼就會去呼叫它的Error方法。fmt包中的這類列印函式其實都是這麼做的。

順便提一句,當我們想通過模板化的方式生成錯誤資訊,並得到錯誤值時,可以使用fmt.Errorf函式。該函式所做的其實就是先呼叫fmt.Sprintf函式,得到確切的錯誤資訊;再呼叫errors.New函式,得到包含該錯誤資訊的error型別值,最後返回該值。

好了,我現在問一個關於對錯誤值做判斷的問題。我們今天的問題是:對於具體錯誤的判斷,Go 語言中都有哪些慣用法?

由於error是一個介面型別,所以即使同為error型別的錯誤值,它們的實際型別也可能不同。這個問題還可以換一種問法,即:怎樣判斷一個錯誤值具體代表的是哪一類錯誤?

這道題的典型回答是這樣的:

1、對於型別在已知範圍內的一系列錯誤值,一般使用型別斷言表示式或型別switch語句來判斷;

2、對於已有相應變數且型別相同的一系列錯誤值,一般直接使用判等操作來判斷;

3、對於沒有相應變數且型別未知的一系列錯誤值,只能使用其錯誤資訊的字串表示形式來做判斷。

問題解析

如果你看過一些 Go 語言標準庫的原始碼,那麼對這幾種情況應該都不陌生。我下面分別對它們做個說明。

型別在已知範圍內的錯誤值其實是最容易分辨的。就拿os包中的幾個代表錯誤的型別os.PathError、os.LinkError、os.SyscallError和os/exec.Error來說,它們的指標型別都是error介面的實現型別,同時它們也都包含了一個名叫Err,型別為error介面型別的代表潛在錯誤的欄位。

如果我們得到一個error型別值,並且知道該值的實際型別肯定是它們中的某一個,那麼就可以用型別switch語句去做判斷。例如:

func underlyingError(err error) error {
  switch err := err.(type) {
  case *os.PathError:
    return err.Err
  case *os.LinkError:
    return err.Err
  case *os.SyscallError:
    return err.Err
  case *exec.Error:
    return err.Err
  }
  return err
}

函式underlyingError的作用是:獲取和返回已知的作業系統相關錯誤的潛在錯誤值。其中的型別switch語句中有若干個case子句,分別對應了上述幾個錯誤型別。當它們被選中時,都會把函式引數err的Err欄位作為結果值返回。如果它們都未被選中,那麼該函式就會直接把引數值作為結果返回,即放棄獲取潛在錯誤值。

只要型別不同,我們就可以如此分辨。但是在錯誤值型別相同的情況下,這些手段就無能為力了。在 Go 語言的標準庫中也有不少以相同方式建立的同型別的錯誤值。

我們還拿os包來說,其中不少的錯誤值都是通過呼叫errors.New函式來初始化的,比如:os.ErrClosed、os.ErrInvalid以及os.ErrPermission,等等。

注意,與前面講到的那些錯誤型別不同,這幾個都是已經定義好的、確切的錯誤值。os包中的程式碼有時候會把它們當做潛在錯誤值,封裝進前面那些錯誤型別的值中。

如果我們在操作檔案系統的時候得到了一個錯誤值,並且知道該值的潛在錯誤值肯定是上述值中的某一個,那麼就可以用普通的switch語句去做判斷,當然了,用if語句和判等操作符也是可以的。例如:

printError := func(i int, err error) {
  if err == nil {
    fmt.Println("nil error")
    return
  }
  err = underlyingError(err)
  switch err {
  case os.ErrClosed:
    fmt.Printf("error(closed)[%d]: %s\n", i, err)
  case os.ErrInvalid:
    fmt.Printf("error(invalid)[%d]: %s\n", i, err)
  case os.ErrPermission:
    fmt.Printf("error(permission)[%d]: %s\n", i, err)
  }
}

這個由printError變數代表的函式會接受一個error型別的引數值。該值總會代表某個檔案操作相關的錯誤,這是我故意地以不正確的方式操作檔案後得到的。

雖然我不知道這些錯誤值的型別的範圍,但卻知道它們或它們的潛在錯誤值一定是某個已經在os包中定義的值。

所以,我先用underlyingError函式得到它們的潛在錯誤值,當然也可能只得到原錯誤值而已。然後,我用switch語句對錯誤值進行判等操作,三個case子句分別對應我剛剛提到的那三個已存在於os包中的錯誤值。如此一來,我就能分辨出具體錯誤了。

對於上面這兩種情況,我們都有明確的方式去解決。但是,如果我們對一個錯誤值可能代表的含義知之甚少,那麼就只能通過它擁有的錯誤資訊去做判斷了。

好在我們總是能通過錯誤值的Error方法,拿到它的錯誤資訊。其實os包中就有做這種判斷的函式,比如:os.IsExist、os.IsNotExist和os.IsPermission。命令原始碼檔案 demo45.go 中包含了對它們的應用,這大致跟前面展示的程式碼差不太多,我就不在這裡贅述了。

package main

import (
	"fmt"
	"os"
	"os/exec"
	"runtime"
)

// underlyingError 會返回已知的作業系統相關錯誤的潛在錯誤值。
func underlyingError(err error) error {
	switch err := err.(type) {
	case *os.PathError:
		return err.Err
	case *os.LinkError:
		return err.Err
	case *os.SyscallError:
		return err.Err
	case *exec.Error:
		return err.Err
	}
	return err
}

func main() {
	// 示例1。
	r, w, err := os.Pipe()
	if err != nil {
		fmt.Printf("unexpected error: %s\n", err)
		return
	}
	// 人為製造 *os.PathError 型別的錯誤。
	r.Close()
	_, err = w.Write([]byte("hi"))
	uError := underlyingError(err)
	fmt.Printf("underlying error: %s (type: %T)\n",
		uError, uError)
	fmt.Println()

	// 示例2。
	paths := []string{
		os.Args[0],           // 當前的原始碼檔案或可執行檔案。
		"/it/must/not/exist", // 肯定不存在的目錄。
		os.DevNull,           // 肯定存在的目錄。
	}
	printError := func(i int, err error) {
		if err == nil {
			fmt.Println("nil error")
			return
		}
		err = underlyingError(err)
		switch err {
		case os.ErrClosed:
			fmt.Printf("error(closed)[%d]: %s\n", i, err)
		case os.ErrInvalid:
			fmt.Printf("error(invalid)[%d]: %s\n", i, err)
		case os.ErrPermission:
			fmt.Printf("error(permission)[%d]: %s\n", i, err)
		}
	}
	var f *os.File
	var index int
	{
		index = 0
		f, err = os.Open(paths[index])
		if err != nil {
			fmt.Printf("unexpected error: %s\n", err)
			return
		}
		// 人為製造潛在錯誤為 os.ErrClosed 的錯誤。
		f.Close()
		_, err = f.Read([]byte{})
		printError(index, err)
	}
	{
		index = 1
		// 人為製造 os.ErrInvalid 錯誤。
		f, _ = os.Open(paths[index])
		_, err = f.Stat()
		printError(index, err)
	}
	{
		index = 2
		// 人為製造潛在錯誤為 os.ErrPermission 的錯誤。
		_, err = exec.LookPath(paths[index])
		printError(index, err)
	}
	if f != nil {
		f.Close()
	}
	fmt.Println()

	// 示例3。
	paths2 := []string{
		runtime.GOROOT(),     // 當前環境下的Go語言根目錄。
		"/it/must/not/exist", // 肯定不存在的目錄。
		os.DevNull,           // 肯定存在的目錄。
	}
	printError2 := func(i int, err error) {
		if err == nil {
			fmt.Println("nil error")
			return
		}
		err = underlyingError(err)
		if os.IsExist(err) {
			fmt.Printf("error(exist)[%d]: %s\n", i, err)
		} else if os.IsNotExist(err) {
			fmt.Printf("error(not exist)[%d]: %s\n", i, err)
		} else if os.IsPermission(err) {
			fmt.Printf("error(permission)[%d]: %s\n", i, err)
		} else {
			fmt.Printf("error(other)[%d]: %s\n", i, err)
		}
	}
	{
		index = 0
		err = os.Mkdir(paths2[index], 0700)
		printError2(index, err)
	}
	{
		index = 1
		f, err = os.Open(paths[index])
		printError2(index, err)
	}
	{
		index = 2
		_, err = exec.LookPath(paths[index])
		printError2(index, err)
	}
	if f != nil {
		f.Close()
	}
}

總結

今天我們一起初步學習了錯誤處理的內容。我們總結了錯誤型別、錯誤值的處理技巧和設計方式,並一起分享了 Go 語言中處理錯誤的最基本方式

思考題

請列舉出你經常用到或者看到的 3 個錯誤型別,它們所在的錯誤型別體系都是怎樣的?你能畫出一棵樹來描述它們嗎?

筆記原始碼

https://github.com/MingsonZheng/go-core-demo

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

相關文章