エラー・ハンドリング再考

前回golang.org/x/xerrors パッケージを紹介かたがた簡単なコードをお試しで書いてみたのだが,大雑把に言って golang.org/x/xerrors パッケージの特徴は以下のようなものだと言えるだろう。

  • エラーの発生位置を取得・表示できる
  • エラーの等値性(equality)を検証できる
  • エラー・チェイン内の指定した型のインスタンスを抽出できる

であるならエラー・ハンドリングの設計もこれらの特徴を活かしたいものである。

errors 標準パッケージからの置き換え

たとえば以下のような

func foo() error {
    ...
}

func bar() error {
    ...
}

関数 foo()bar() から返却される error が等値であれば処理をまとめられる筈である。 今までは errors.New() 関数を使って

var (
    ErrInstance1 = errors.New("error instance 1")
    ErrInstance2 = errors.New("error instance 2")
    ErrInstance3 = errors.New("error instance 3")
)

のように,あらかじめ必要な error インスタンスを作成しておき

func errorHandling(err error) {
    switch err {
    case ErrInstance1:
        fmt.Fprintln(os.Stderr, "Error number 1 was triggered.")
        return
    case ErrInstance2:
        fmt.Fprintln(os.Stderr, "Error number 2 was triggered.")
        return
    case ErrInstance3:
        fmt.Fprintln(os.Stderr, "Error number 3 was triggered.")
        return
    default:
        fmt.Fprintln(os.Stderr, "Unknown error")
        return
    }
}

みたいな感じにインスタンごとに処理を書いていけばよかった。 しかし errorsxerrors に置き換えた場合,このままでは上手くいかないことが分かる。

実際にコードを書いて確かめてみよう。 errorHandling() 関数ではエラーの発生位置も出力するようにしてみる。

package main

import (
    "fmt"
    "os"

    "golang.org/x/xerrors"
)

var (
    ErrInstance1 = xerrors.New("error instance 1")
    ErrInstance2 = xerrors.New("error instance 2")
    ErrInstance3 = xerrors.New("error instance 3")
)

func errorHandling(err error) {
    switch err {
    case ErrInstance1:
        fmt.Fprintf(os.Stderr, "Error number 1 was triggered.\n%+v\n", err)
        return
    case ErrInstance2:
        fmt.Fprintf(os.Stderr, "Error number 2 was triggered.\n%+v\n", err)
        return
    case ErrInstance3:
        fmt.Fprintf(os.Stderr, "Error number 3 was triggered.\n%+v\n", err)
        return
    default:
        fmt.Fprintf(os.Stderr, "Unknown error.\n%+v\n", err)
        return
    }
}

func foo() error {
    return ErrInstance1
}
func bar() error {
    return ErrInstance1
}

func main() {
    err1 := foo()
    err2 := bar()
    fmt.Println("foo() error == bar() error ?", err1 == err2)
    errorHandling(err1)
    errorHandling(err2)
}

これを実行すると以下のようになる。

$ go run handling1/handling1.go
foo() error == bar() error ? true
Error number 1 was triggered.
error instance 1:
    main.init
        /tmp/xerrors/handling1/handling1.go:11
Error number 1 was triggered.
error instance 1:
    main.init
        /tmp/xerrors/handling1/handling1.go:11

これは xerrors.New() 関数を起動した行がエラー発生位置になるためで,これでは本当のエラー発生位置が分からない。 それじゃあ,というので

func foo() error {
    return xerrors.New("error instance 1")
}
func bar() error {
    return xerrors.New("error instance 1")
}

と書き換えても駄目である。 xerrors.New() 関数はインスタンスへのポインタ値を返すが foo()bar() 関数で返される error と ErrInstance1 は異なるインスタンスなので Go 言語の等値演算子(equality operator)では(ポインタ値を比較するだけで)等値であることを示せない。 したがって実行結果も

$ go run handling1b/handling1b.go
foo() error == bar() error ? false
Unknown error.
error instance 1:
    main.foo
        /tmp/xerrors/handling1b/handling1b.go:34
Unknown error.
error instance 1:
    main.bar
        /tmp/xerrors/handling1b/handling1b.go:37

と “Unknown error” になる。 もちろん Error() 関数で出力される文字列を比較するなんてのは論外である。

Is 関数をカスタマイズする

上述の問題を解決する方法は色々あると思うが,今回はエラーメッセージのメンテンスのしやすさも考慮して,以下の方法を紹介してみる。

まず基本となる error インスタンスを以下のように定義する。

package werror

type Num int

const (
    ErrInstance1 Num = iota + 1
    ErrInstance2
    ErrInstance3
)

var errMessage = map[Num]string{
    ErrInstance1: "error instance 1",
    ErrInstance2: "error instance 2",
    ErrInstance3: "error instance 3",
}

func (n Num) Error() string {
    if s, ok := errMessage[n]; ok {
        return s
    }
    return "unknown error"
}

インスタンスの比較を簡単にするために数値型の Num を定義し,値と値に紐づくエラーメッセージを定義する。

次に Num 型を埋め込む形で xerrors 互換の wrapError 型を定義する。

type wrapError struct {
    Num
    frame xerrors.Frame
}

func New(n Num) error {
    return &wrapError{Num: n, frame: xerrors.Caller(1)}
}

func (we *wrapError) Format(s fmt.State, v rune) {
    xerrors.FormatError(we, s, v)
}

func (we *wrapError) FormatError(p xerrors.Printer) error {
    p.Print(we.Error())
    we.frame.Format(p)
    return nil
}

Format() および FormatError() 各関数については前回を参照のこと。 これで

return werror.New(werror.ErrInstance1)

などとすれば定義された error 値を使って werror.New() 関数の起動位置で error インスタンスを生成することが出来る。 ただし,このままでは xerrors.Is() 関数によるインスタンスの等値性を正しく検証できない。

そこで Num および wrapError に以下の関数を追加する。

func (n Num) Is(target error) bool {
	var t1 *wrapError
	if xerrors.As(target, &t1) {
		return n == t1.Num
	}
	var t2 Num
	if xerrors.As(target, &t2) {
		return n == t2
	}
	return false
}

func (we *wrapError) Is(target error) bool {
    var t1 *wrapError
    if xerrors.As(target, &t1) {
        return we.Num == t1.Num
    }
    var t2 Num
    if xerrors.As(target, &t2) {
        return we.Num == t2
    }
    return false
}

これで等値演算子 == の代わりに Num.Is() および wrapError.Is() 関数がインスタンスの等値性をチェックしてくれる。

この werror パッケージのクラス図はこんな感じかな。

werror.png

この werror パッケージを使って最初のコードを書き換えてみよう。

package main

import (
    "fmt"
    "os"

    "demo-xerrors/handling2/werror"

    "golang.org/x/xerrors"
)

func errorHandling(err error) {
    switch true {
    case xerrors.Is(err, werror.ErrInstance1):
        fmt.Fprintf(os.Stderr, "Error number 1 was triggered.\n%+v\n", err)
        return
    case xerrors.Is(err, werror.ErrInstance2):
        fmt.Fprintf(os.Stderr, "Error number 2 was triggered.\n%+v\n", err)
        return
    case xerrors.Is(err, werror.ErrInstance3):
        fmt.Fprintf(os.Stderr, "Error number 3 was triggered.\n%+v\n", err)
        return
    default:
        fmt.Fprintf(os.Stderr, "Unknown error.\n%+v\n", err)
        return
    }
}

func foo() error {
    return werror.New(werror.ErrInstance1)
}
func bar() error {
    return werror.New(werror.ErrInstance1)
}

func main() {
    err1 := foo()
    err2 := bar()
    fmt.Println("foo() error == bar() error ?", xerrors.Is(err1, err2))
    errorHandling(err1)
    errorHandling(err2)
}

これを実行すると以下のような結果になる。

$ go run handling2/handling2.go
foo() error == bar() error ? true
Error number 1 was triggered.
error instance 1:
    main.foo
        /tmp/xerrors/handling2/handling2.go:30
Error number 1 was triggered.
error instance 1:
    main.bar
        /tmp/xerrors/handling2/handling2.go:33

よーし,うむうむ,よーし。

ブックマーク

参考図書

photo
プログラミング言語Go
アラン・ドノバン (著), ブライアン・カーニハン (著), 柴田芳樹 (著)
丸善出版 2016-06-20 (Release 2021-07-13)
Kindle版
B099928SJD (ASIN)
評価     

Kindle 版出た! 一部内容が古びてしまったが,この本は Go 言語の教科書と言ってもいいだろう。感想はこちら

reviewed by Spiegel on 2021-05-22 (powered by PA-APIv5)