認識 Go 的 error 與學習判斷 error 類型的方法

Posted on  Apr 16, 2024  in  Go 程式設計 - 初階  by  Amo Chen  ‐ 6 min read

Go 的程式設計中,透過回傳 error 型別的值告知錯誤發生是相當常見的模式,所以很多函式的回傳定義大多如下所示,其中 1 個會是 error 型別,用以告知執行時的錯誤:

func FunctionName() (結果的型別, error)

這也是為什麼我們如果閱讀各種以 Go 開發的開放原始碼專案會很常看到類似以下的程式碼的原因:

r, err := FunctionName()
if err != nil {
	// Error handling here
}

所以學會判斷 error 的類型並處理各種類型的錯誤,變成 Go 程式設計的重要課題。

本文將探討如何判斷 error 類型以及多種不同判斷的方法。

本文環境

  • Go 1.12 以上

Go 的 error 是什麼?

探討如何判斷 error 之前,需要先認識 Go 的 error 到底是什麼。

Go 的 error 是 1 個 interface, 裡面只規定需要 1 個函式 Error() string , error 的 interface 如下所示:

type error interface {
    Error() string
}

所以無論型別為何,只要符合 error interface 的規定,有實作 Error() string 函式就可以視為是 error type, 譬如下列範例中的 FunctionName 回傳 A{"Unknown error"} 也沒問題,因為它有實作 Error() string 函式,符合 error interface 的規定,是合法的 error:

package main

import "fmt"

type A struct {
	ErrStr string
}

func (a A) Error() string {
	return a.ErrStr
}

func FunctionName() error {
	return A{"Unknown error"}
}

func main() {
	err := FunctionName()
	fmt.Println(err.Error())
}

如何建立 Error?

建立 error 最簡單的方法是透過 errors package 的 New() 函式,如下列範例 FunctionName() error 中回傳的 errors.New("Unknown error") :

package main

import (
	"errors"
	"fmt"
)

func FunctionName() error {
	return errors.New("Unknown error")
}

func main() {
	err := FunctionName()
	fmt.Println(err.Error())
}

這時就衍伸出我們最初的問題,如果有多種不同的 error 可能回傳,那要怎麼判別不同的 error, 例如下列函式可能會回傳 2 種不同的 error:

func Compute(v int) error {
	if v == 0 {
		return errors.New("v must > 0")
	}
	return errors.New("not implement yet")
}

有人可能會想說直接判斷 Error() string 回傳的字串:

func main() {
	err := Compute(0)
	if err != nil {
		if err.Error() == "v must > 0" {
			...
		} else if err.Error() == "not implement yet" {
			...
		}
	}
}

雖然這也是 1 種方法,但是這個方法有個大問題,例如 v must > 0 如果被改成 value must > 0 ,我們必須修改所有 v must > 0 字串,確保所有的字串都相同才行,否則將會導致原有程式在判斷字串的部分失效。

也許比較好的辦法是用 err == <error type> 之類的判斷方式,所以在 Go 1.12 (含 1.12)之前,將 error.New() 抽出來變成共享的變數,再用 == 進行判斷,是很常見的做法,

Go 1.12 (含 1.12)之前用 == 比較 error

Go 1.12 之前的範例:

package main

import (
	"errors"
	"fmt"
)

var ErrValueIsZero = errors.New("v is 0")
var ErrNotImplement = errors.New("not implement yet")

func Compute(v int) error {
	if v == 0 {
		return ErrValueIsZero
	}
	return ErrNotImplement
}

func main() {
	err := Compute(0)
	if err != nil {
		if err == ErrValueIsZero {
			fmt.Println(err.Error())
		} else if err == ErrNotImplement {
			fmt.Println(err.Error())
		}
	}
}

Go 1.13 之後改用 errors.Is() 比較 error

不過到了 Go 1.13 之後,用 == 比較 error 的方式已經被棄用, Go 官方推薦使用 errors.Is() 方式進行比較,因此前述範例在 1.13 之後可以改成:

package main

import (
	"errors"
	"fmt"
)

var ErrValueIsZero = errors.New("v is 0")
var ErrNotImplement = errors.New("not implement yet")

func Compute(v int) error {
	if v == 0 {
		return ErrValueIsZero
	}
	return ErrNotImplement
}

func main() {
	err := Compute(0)
	if err != nil {
		if errors.Is(err, ErrValueIsZero) {
			fmt.Println(err.Error())
		} else if errors.Is(err, ErrNotImplement) {
			fmt.Println(err.Error())
		}
	}
}

更複雜的 error 設計如何比較

在 error 訊息中添加更詳細的資訊,幫助開發者更容易理解錯誤,也是很常見的需求,譬如找不到頁面的錯誤,我們想增加 URL 的資訊在錯誤訊息之中,可能會這樣寫:

func LoadPage(p string) error {
	if p == "/" return nil
	return errors.New("page %s not found", p)
}

這種寫法導致我們無法用 == 單純進行比較,因為我們不知道 p 切確的值是什麼,所以我們需要更複雜一些的 error 設計,例如將 p 變成 1 個結構中的欄位,並且為該結構實作 Error() string 函式:

type ErrPageNotFound struct {
	Path string
}

func (e ErrPageNotFound) Error() string {
	return fmt.Sprintf("page %s not found", e.Path)
}

用 type assertion 判斷 error

To test whether an interface value holds a specific type, a type assertion can return two values: the underlying value and a boolean value that reports whether the assertion succeeded.

針對前述更複雜一些的 error 設計,我們可以用 type assertion 進行,例如以下範例中的 _, ok := err.(ErrPageNotFound) 就是 type assertion 的用法,可以幫助我們判斷 error 是否屬於特定的 error type, 如此一來不管 Path 怎麼變,我們都還是能正確判斷該錯誤:

package main

import (
	"fmt"
)

type ErrPageNotFound struct {
	Path string
}

func (e ErrPageNotFound) Error() string {
	return fmt.Sprintf("page %s not found", e.Path)
}

func LoadPage(p string) error {
	if p == "/" {
		return nil
	}
	return ErrPageNotFound{p}
}

func main() {
	err := LoadPage("/p/1")
	_, ok := err.(ErrPageNotFound)
	if ok {
		fmt.Println("err is ErrPageNotFound")
	}
}

其執行結果為:

err is ErrPageNotFound

Go 1.13 之後可以使用 errors.As() 判斷 error type

Go 1.13 (含 1.13)之後支援用 errors.As() 判斷 error type:

func As(err error, target any) bool

errors.As() 的第 2 個參數必須是 non-nil 的指標(pointer),該指標必須指向 1 個有實作 error interface 的 type 。

因此前述用 type assertion 進行判斷的方式,可以改成:

package main

import (
	"errors"
	"fmt"
)

type ErrPageNotFound struct {
	Path string
}

func (e ErrPageNotFound) Error() string {
	return fmt.Sprintf("page %s not found", e.Path)
}

func LoadPage(p string) error {
	if p == "/" {
		return nil
	}
	return ErrPageNotFound{p}
}

func main() {
	err := LoadPage("/p/1")
	if errors.As(err, &ErrPageNotFound{}) {
		fmt.Println("err is ErrPageNotFound")
	}
}

上述範例其實只是將 type assertion 的部分改為 errors.As(err, &ErrPageNotFound{}) 而已,其執行結果為:

err is ErrPageNotFound

談談何謂 Wrapped errors 與其用途

Go 1.13 之後支援 wrapped errors, 也就是說可以對 error 進行再包裝:

An error e wraps another error if e’s type has one of the methods:

  1. Unwrap() error
  2. Unwrap() []error

只要 error 實作 Unwrap() errorUnwrap() []error 其中 1 個方法,就屬於 wrapped errors 。

error 再包裝的最簡單做法是使用 fmt.Errorf 並搭配 %w verb, 例如:

ErrUnknown := fmt.Errorf("unknown error: %w", err)

大家可能會疑問為什麼要這樣做?這樣做有什麼好處?

我們可以看 1 個範例:

package main

import (
	"errors"
	"fmt"
)

type ErrInvalidInput struct {
	Value interface{}
}

func (e ErrInvalidInput) Error() string {
	return fmt.Sprintf("invalid input %v", e.Value)
}

var ErrDeprecatedFunc = errors.New("This func is deprecated. Don't use it.")

func Compute(p int) error {
	if p == 0 {
		return ErrInvalidInput{p}
	}
	return fmt.Errorf("func: Compute | %w", ErrDeprecatedFunc)
}

func CallCompute(p int) error {
	err := Compute(1)
	if err != nil {
		if errors.As(err, &ErrInvalidInput{}) {
			fmt.Println("error handling here")
		} else {
			return fmt.Errorf("unknown error | %w", err)
		}
	}
	return nil
}

func main() {
	err := CallCompute(1)
	fmt.Println(err.Error())
}

範例執行結果如下,可以看到我們對 error 再包裝,增加了更多有用的除錯資訊:

unknown error | func: Compute | This func is deprecated. Don't use it.

上述範例中的 Compute(p int) error 函式利用 wrapped errors 的功能,將共享的 ErrDeprecatedFunc 再包裝,把自身的函數名稱也加到 error 訊息之中,方便他人除錯,如此也不用特別設計 1 種錯誤。

另外 CallCompute(p int) 函式只關注 ErrInvalidInput 的錯誤,其他類型的錯誤它就使用 fmt.Errorf("[Compute] unknown error: %w", err) 對 error 再包裝後直接回傳,透過再包裝的方法,同樣可以不用特別設計 1 種錯誤。

那麽要怎麼判斷這種再包裝後的 error 呢?其實 Go 1.13 之後的 errors.Is()errors.As() 支援遞迴形式對 error 進行 unwrap, 所以只要 unwrap 的過程 errors.Is()erros.As() 結果為 true 就代表 2 者錯誤相同,所以前述範例的 err := CallCompute(1) 可以用 errors.Is() 進行判斷,不用管它被包裝過幾次(本文中被包裝過 2 次):


func main() {
	err := CallCompute(1)
	if err != nil {
		if errors.Is(err, ErrDeprecatedFunc) {
			fmt.Println("err is ErrDeprecatedFunc")
		} else if errors.As(err, &ErrInvalidInput{}) {
			fmt.Println("err is ErrInvalidInput")
		}
	}
}

它的好處在於我們可以為 error 進行包裝,除了利用既有的 error 之外,也能加入更多錯誤訊息、功能。

實作 Unwrap() error 實現階層式(hierarchy)的 error

Python 的 Exception 其實有定義階層,例如:

BaseException
 ├── BaseExceptionGroup
 ├── GeneratorExit
 ├── KeyboardInterrupt
 ├── SystemExit
 └── Exception
      ├── ArithmeticError
      │    ├── FloatingPointError
      │    ├── OverflowError
      │    └── ZeroDivisionError

這種階層式的作法,可以讓開發者捕捉較高層級的 exception 就好,不用一項一項 exception 都要處理,例如開發者可以直接捕捉 ValueError 就好,不用管它底下的 UnicodeError 或者 UnicodeDecodeError ,相同的設計也出現在各種 Python 套件之中。

Go 則可以實作 Unwrap() 做到類似的事情,例如我們想實作 3 階層的 error 的話:

ErrBase
  └── ErrValue
      └── ErrInvalidInput

可以這樣設計:

package main

import (
	"errors"
	"fmt"
)

var ErrBase = errors.New("base error")

type ErrValue struct{}

func (e ErrValue) Error() string {
	return fmt.Sprint("ErrValue")
}

func (e ErrValue) Unwrap() error {
	return ErrBase
}

type ErrInvalidInput struct{}

func (e ErrInvalidInput) Error() string {
	return fmt.Sprint("ErrInvalidValue")
}

func (e ErrInvalidInput) Unwrap() error {
	return ErrValue{}
}

func main() {
	err := ErrInvalidInput{}
	fmt.Printf("%t\n", errors.As(err, &ErrInvalidInput{}))
	fmt.Printf("%t\n", errors.As(err, &ErrValue{}))
	fmt.Printf("%t\n", errors.Is(err, ErrBase))
}

上述範例的 ErrInvalidInput 執行 Unwrap() 之後,回傳的是 ErrValue , 而進一步執行 ErrValueUnwrap() 之後,回傳的是 ErrBase, 如下圖所示:

unwrap-chain.png

搭配 errors.As()errors.Is() 遞迴 unwrap 的特性,所以在 main() 函式中的 3 個比較結果都為 true ,這就是如何在 Go 做到類似 Python 階層式 exception 的方式。

總結

Go 的 error handling 是相當重要的課題。

儘管 Go 不像 Python, Java, JavaScript 等程式語言,並沒有 try-catch 可以使用,但其設計哲學是為了讓開發者能夠思考各種可能出現的問題,並針對這些問題提供適當的處理,而非僅僅只是捕捉錯誤,然後只做一般的處理。

所以對於習慣 Python, JavaScript 等語言的開發者來說,使用 Go 處理 error 不免會感到疑惑或者煩悶,但只要詳細了解它的設計與哲學,多半會釋懷一些。

以上!

Enjoy!

References

errors package - errors - Go Packages

Error handling and Go - The Go Programming Language

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!