🐀

Go の自作エラーを errors.Is と errors.As で wrap 元のエラーと識別するときには、Unwrap も実装しよう

2022/03/21に公開約9,800字1件のコメント

概要

Go ではデフォルトのエラーに対して、自作エラーを作成して wrap するように有識者の間では推奨されています。
ただ、wrap すると本来のエラーが隠れてしまいテストや実行時に単純な比較ができなくなります。
そこで、よく紹介されるのが、errors.Iserrors.Asです。
erros.Iserrors.Asを使うと wrap 済みエラーに対して、元のエラーとの比較がおこなえます。
しかし、紹介記事では、そのままコピー&ペーストするとtrueを返してほしいときに、falseを返してしまう方法を散見します。

本記事では、間違えてしまうパターンについて紹介し、解決方法を紹介します。
元のエラーが不要で、wrap したエラーのみで比較する実装のときには、本記事の手順は不要になります。

上手く比較できないパターン

最初に、上手く比較できないパターンがどのようなときに発生するのか紹介します。

以下は、fmt.Errorfを用いてエラーを wrap した実装です。
errors.Iserrors.Asの結果は問題なくtrueを返していることがわかります。

https://go.dev/play/p/ZhQrPaCo-2Y
fmt.Errorf
package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

func main() {
	if _, err := os.Open("non-existing"); err != nil {
		var pathError *fs.PathError

		wrapedErr := fmt.Errorf("err is %w", err)
		if errors.As(wrapedErr, &pathError) {
			fmt.Println("errors.As():Failed at path:", pathError.Path)
		} else {
			fmt.Println(err)
		}
		if errors.Is(wrapedErr, pathError) {
			fmt.Println("errors.Is():Failed at path:", pathError.Path)
		} else {
			fmt.Println(err)
		}
	}
}

// Output
// errors.As():Failed at path: non-existing
// errors.Is():Failed at path: non-existing

続いて、自作エラーで wrap したパターンです。以下の実装ではうまくいきません。
一見、問題なく見えますが、errors.Iserrors.Asfalseを返していることがわかります。
なぜ、このようになるのでしょうか。

https://go.dev/play/p/LdOGTru0qBS
package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

type SampleError struct {
	message string
	err     error
}

func (se *SampleError) Error() string {
	return se.message
}

func main() {
	if _, err := os.Open("non-existing"); err != nil {
		var pathError *fs.PathError

		wrapedErr := &SampleError{message: "this is wraped err", err: err}
		if errors.As(wrapedErr, &pathError) {
			fmt.Println("errors.As():Failed at path:", pathError.Path)
		} else {
			fmt.Println(err)
		}
		if errors.Is(wrapedErr, pathError) {
			fmt.Println("errors.Is():Failed at path:", pathError.Path)
		} else {
			fmt.Println(err)
		}
	}
}

// open non-existing: no such file or directory
// open non-existing: no such file or directory

解決策

前述の問題に対する、解決策を紹介します。
タイトル通り、自作エラーにUnwrapを実装する必要があります。

https://go.dev/play/p/ryl7rLvIEt8
package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

type SampleError struct {
	message string
	err     error
}

func (se *SampleError) Error() string {
	return se.message
}

func (se *SampleError) Unwrap() error { // 追加
	return se.err
}

func main() {
	if _, err := os.Open("non-existing"); err != nil {
		var pathError *fs.PathError

		wrapedErr := &SampleError{message: "this is wraped err", err: err}
		if errors.As(wrapedErr, &pathError) {
			fmt.Println("errors.As():Failed at path:", pathError.Path)
		} else {
			fmt.Println(err)
		}
		if errors.Is(wrapedErr, pathError) {
			fmt.Println("errors.Is():Failed at path:", pathError.Path)
		} else {
			fmt.Println(err)
		}
	}
}

// Output:
// errors.As():Failed at path: non-existing
// errors.Is():Failed at path: non-existing

問題に対する解決策は以上になります。
以降は、なぜUnwrap()が必要なのか解説します。

errors.Is、errors.As

この間違いの原因にはerrors.Iserrors.Asの特徴を確認する必要があります。
以下の表に違いをまとめました。
基本的には、エラーの比較方法は 2 あり、false を返す方法は共通であることがわかります。
色々書きましたが、Unwrap できなくなるまで実行されていることが重要です。
errors.Iserrors.As の実装の詳細を確認します。

errors.Is errors.As
第 1 引数(err) Error Error
第 2 引数(target) Error any(interface{})
比較方法 1 エラーのインスタンスが同一 エラーの型に代入可能
true を返す条件 1 isComparable(比較可能)且つ err と target のインスンタンスが同じ target の型に err を代入できるとき
比較方法 2 err.Is()を使う err.As()を使う
true を返す条件 2 err にIs()が実装されている。Is(target)が true err にAs()が実装されている。As(target)が true
false を返す条件 Unwrap できなくなるまで Unwrap できなくなるまで
panic になる条件 erros.Is()内にはない target に不正なデータが含まれるとき

errors.Is

以下が、errors.Isの実装です。
表にまとめた通りの実装になっていることがわかります。
Unwrapからnilが帰ってきたら、falseを返して終了します。

https://pkg.go.dev/errors@go1.18#Is
func Is(err, target error) bool {
	if target == nil {
		return err == target
	}

	isComparable := reflectlite.TypeOf(target).Comparable() // 比較可能について確認
	for {
		if isComparable && err == target { // isComparable(比較可能)且つ err と target のインスタンスが同じ
			return true
		}
		if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) { // Is メソッドが実装されているなら、実行して結果を確認する
			return true
		}
		if err = Unwrap(err); err == nil { // Unwrapする。Unwrapできなければ false を返して終了
			return false
		}
	}
}

ちなみにですが、Is()メソッドは以下のようなときに使用できます。
wrap したエラーと比較するエラーのインスタンスが異なるときです。
具体的にはvalidator.ValidationErrorsNewSampleのバリデーション結果はエラーのインスタンスが異なるため、errors.Is()ではtrueを返しません。
そこで、構造体WrappedSampleに新しくIsメソッドを追加し、validator.ValidationErrorsのときtrueを返すようにすると動作します。
しかし、このような場合は後述するerrors.As()(以下のソースコードのコメントアウトしてある)を使用したほうが早かったりもします。

https://go.dev/play/p/erZVrg5BaRq
package main

import (
	"errors"
	"fmt"

	"github.com/go-playground/validator/v10"
)

type Sample struct {
	NaturalNumber int    `validate:"gt=0"`
	Word          string `validate:"gte=5"`
}

func validateSample(s *Sample) error {
	validate := validator.New()
	return validate.Struct(s)
}

func NewSample(aNaturalNumber int, aWord string) (*Sample, error) {
	sample := &Sample{NaturalNumber: aNaturalNumber, Word: aWord}
	if err := validateSample(sample); err != nil {
		return nil, &WrappedSample{message: "this is wrapped", err: err}
	}
	return sample, nil
}

type WrappedSample struct {
	message string
	err     error
}

func (wf *WrappedSample) Error() string {
	return wf.message
}

func (wf *WrappedSample) Unwrap() error {
	return wf.err
}

func main() {
	_, err := NewSample(-1, "word")
	if err != nil {
		// var verrs1 validator.ValidationErrors
		// if errors.As(err, &verrs1) {
		// 	for _, verr := range verrs1 {
		// 		fmt.Println(verr)
		// 	}
		// }
		var verrs2 validator.ValidationErrors
		if errors.Is(err, &verrs2) {
			for _, verr := range verrs2 {
				fmt.Println(verr)
			}
		}
	}
}

errors.As

以下が、errors.Asの実装です。
こちらも表にまとめた通りの処理が実装していることがわかります。
最後の for 文でUnwrapの戻り値がnilだったら終了(err == nil)します。
errors.Iserrors.Asfalseを返すときは、Unwrapできなくなったとき、ということがわかりました。
Unwrapの実装についてみていきます。

https://pkg.go.dev/errors@go1.18#As
errors.As
func As(err error, target any) bool {
	if target == nil { // nil だったら panic
		panic("errors: target cannot be nil")
	}
	val := reflectlite.ValueOf(target)
	typ := val.Type()
	if typ.Kind() != reflectlite.Ptr || val.IsNil() { // pointer が nil だったら panic
		panic("errors: target must be a non-nil pointer")
	}
	targetType := typ.Elem()
	if targetType.Kind() != reflectlite.Interface && !targetType.Implements(errorType) { // インタフェース以外でエラータイプを実装していなかったら panic
		panic("errors: *target must be interface or implement error")
	}
	for err != nil {
		if reflectlite.TypeOf(err).AssignableTo(targetType) { // target に err を代入可能だったら、代入してエラーを返す
			val.Elem().Set(reflectlite.ValueOf(err))
			return true
		}
		if x, ok := err.(interface{ As(any) bool }); ok && x.As(target) {
			return true
		}
		err = Unwrap(err)
	}
	return false
}

Unwrap

以下が errors に実装されている Unwrap() の処理です。
errUnwrap()が実装されてなければnilを返し、実装されていればUnwrap()を実行する単純な処理です。

https://pkg.go.dev/errors@go1.18#Unwrap
func Unwrap(err error) error {
	u, ok := err.(interface {
		Unwrap() error
	})
	if !ok {
		return nil
	}
	return u.Unwrap()
}

ここで、解決策に書いたサンプルコードを確認します。
Unwrap()では、wrap 対象のエラーを返しているだけです。
fmt.Errorf("%w", err)は既にUnwrap()が実装されているため、記述は不要でした。
errors.Iserrors.Asはエラーが一致しなかったとき、Unwrap()できなくなるまでUnwrapを繰り返すだけでした。
しかし、自作エラーにUnwrap()がないので、wrap されているのに unwrap できない状態だったことがわかります。
なので、タイトル通り「Go の自作エラーに errors.Is と errors.As を使うときには、Unwrap も実装しよう」ということになります。

再掲
package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

type SampleError struct {
	message string
	err     error
}

func (se *SampleError) Error() string {
	return se.message
}

func (se *SampleError) Unwrap() error {
	return se.err
}

func main() {
	if _, err := os.Open("non-existing"); err != nil {
		var pathError *fs.PathError

		wrapedErr := &SampleError{message: "this is wraped err", err: err}
		if errors.As(wrapedErr, &pathError) {
			fmt.Println("errors.As():Failed at path:", pathError.Path)
		} else {
			fmt.Println(err)
		}
		if errors.Is(wrapedErr, pathError) {
			fmt.Println("errors.Is():Failed at path:", pathError.Path)
		} else {
			fmt.Println(err)
		}
	}
}

// Output:
// errors.As():Failed at path: non-existing
// errors.Is():Failed at path: non-existing

まとめ

自作エラーにerrors.Iserrors.Asで比較するには、Unwrapが必要なことを具体的に紹介しました。
本記事で伝えたかったことは以下です。

  • wrap したエラーを wrap 元と比較するにはerrors.Iserrors.Asを使う
  • errors.Iserrors.Asはエラーが一致するか、Unwrap()できなくなるまで続ける
  • 自作エラーにUnwrap()を明示的に実装しないと、errors.Iserrors.Asは wrap されていても途中で操作を切り上げる

「Go のエラーは wrap しましょう」はよく見かけますが、Unwrap()の実装まで言及されている記事は見つからなかったので、本記事にまとめました。
「wrap 対象は比較しなくて、自作エラーの型だけ確認する」といった場合は、今回の処理は不要です。
Unwrap()を実装しなくてもエラーがでないので、同じことで疑問に思った方の解決策になれば幸いです。

参考

https://zenn.dev/hiroyukim/articles/d03e3860152c58

https://zenn.dev/spiegel/articles/20200926-error-handling-with-golang

https://qiita.com/hiro_o918/items/fb01014e51354b8bb49f

Discussion

Unwrapを実装するときには「wrap 元のエラーと比較する」という大前提が必要なため、タイトルを修正しました。

ログインするとコメントできます