🆖

なぜGoでerrors.Newを都度呼び出すことが推奨されないのか

2023/12/08に公開

モチベーション

golangci-lintを利用されている方も多いと思います。
後述のようなコードを書くと指摘してくれるgo-err113というルールがなぜあるのかということをきっかけに書いた記事になります。

func F() error {
	err := something()
	if err != nil {
		return errors.New("failed call something fn")
	}
}

ネタバラシすると、go-err113のREADMEにルールがある理由は親切に書かれているのですが、Goを始めたばかりの私にはすべての疑問は解消することができず、より深く調べました。

Starting from Go 1.13 the standard error type behaviour was changed: one error could be derived from another with fmt.Errorf() method using %w format specifier.
So the errors hierarchy could be built for flexible and responsible errors processing.
And to make this possible at least two simple rules should be followed:

  1. error values should not be compared directly but with errors.Is() method.
  2. error should not be created dynamically from scratch but by the wrapping the static (package-level) error.

結論

私の解釈としては、インターフェースの比較の仕様の観点などから意図せず比較不可能なerrorインスタンスを生み出さないためであると理解しました。

Goの値の比較のルール

Goでは基本形であるブーリアン、数値、文字列、配列は==演算子を利用することで実際にその値を比較することができます
スライス、マップは単純比較することはできず、構造体はフィールドが比較可能であれば単純比較できます。
インターフェースも比較可能ですが、実装の型が同じかつ値がブーリアン、数値、文字列、配列である場合だけ期待した比較結果が得られます。
参照型であるスライス、マップ等、構造体などの特殊ルールを含む場合参照先が同じであるかが条件になるため期待結果が得られない可能性があります。

errorはインターフェースで表現されていますので、この比較の仕様が関係していそうです

errors.Newとはそもそも何か

実際のerrros.Newの実装は以下です

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
	return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
	s string
}

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

入力された文字列を持つerrorインターフェースを実装したインスタンスを生成しerrorインターフェース型で返す関数であることがわかります。
また、同じ入力であっても常に異なるインスタンスを生成していることもわかります。

インターフェースも比較可能ですが、実装の型が同じかつ値がブーリアン、数値、文字列、配列である場合だけ期待した比較結果が得られます。
参照型であるスライス、マップ等、構造体などの特殊ルールを含む場合参照先が同じであるかが条件になります。

前項に書いたこのインターフェースの比較の条件に当てはめると、errors.Newが返すインスタンスは比較の際に参照先を比較するため同じ文字列から生成したインスタンスであっても異なると判断されますね

errorの比較

errors.Isを使わずこのように比較するのはアンチパターンですが説明のためあえてこうしています


var myErr = errors.New("my error")

func main() {
	err := F()
	if err != nil {
		if err == myErr {
			fmt.Println("occur my error")
		}
		fmt.Println("occur unknown error")
	}
}

どうでしょうか、呼び出し元はerrがmyErrだった場合に処理を分岐したいようです
しかし、この分岐に入ることはありません。

その理由はF()が返すerrは呼び出されるたびに新しく生成され、インターフェースであるerror型は演算子での比較の際前項のルールに従います

インターフェースも比較可能ですが、実装の型が同じかつ値がブーリアン、数値、文字列、配列である場合だけ期待した比較結果が得られます。
参照型であるスライス、マップ等、構造体などの特殊ルールを含む場合参照先が同じであるかが条件になります。

これが通常の構造体のように特定条件で値比較されればまだ良いのですが、errors.Newはerrorインターフェースを実装したインスタンスをを返すので比較の際に型に加えて実装値の参照が同じであるかが見られます。

またerrors.Isもインターフェース同士の比較を行うため同様です

// 長いので比較箇所付近だけ抜粋.
func Is(err, target error) bool {
	if target == nil {
		return err == target
	}

	isComparable := reflectlite.TypeOf(target).Comparable()
	for {
		if isComparable && err == target {
			return true
		}
		if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
			return true
		}

終わり

このような比較不可能な状態を生み出さないため、Goではerrorを静的に定義しエラーの一貫性を担保することが大切であり、errors.Newのような簡単にerrorインターフェースを実装したインスタンスを生成できる関数に誤った使い方をさせないためのLintルールがあるのも納得です

Discussion