😖

[Go] 独自のエラー型をerrorインタフェースを介さずに返すのは良くないという話

2024/09/27に公開

(公式のFAQ にこれが簡単にまとめられていることに後から気づいたのですが、日本語訳するくらいの気持ちで公開します)

こんにちは😊 kesojiです。

Goで以下のように「エラーに情報を付与して、かつ error インタフェースとして扱う」ことはよく行うかと思います。

type MyError struct {
    origErr error
    Code int
    Message string
}

func (e *MyError) Error() string {
    return e.Message
}

func (e *MyError) Unwrap() error {
    return e.origErr
}

func (e *MyError) IsServerError() bool {
    return e.Code >= 500
}

func Do() error {
    err := something();
    if err != nil {
        return &MyError{origErr: err, Message: "somethingに失敗", Code: 500}
    }
    return nil
}

これを作ること自体は全く問題ありませんが、 Do() error ではなく、 Do() *MyError にしても良いものなのでしょうか?
こうすることで、MyErrorのメソッドたちを呼び出し側でそのまますぐに使えますよね。

結論としては、 「良くないです。 error にした方が良いです」、なのですが、その理由を説明していきます。

まずはAIに聞いてみる

errorインターフェースを返す場合

メリット:

  • Goの慣用的な方法に従っており、他の標準ライブラリや一般的なコードと一貫性がある。
  • 関数のシグネチャがより一般的になり、将来的に異なるエラー型を返す可能性がある場合に柔軟性が高い。
  • errors.Is()やerrors.As()などの標準ライブラリの関数をそのまま使用できる。

デメリット:

  • エラーの具体的な型にアクセスするには型アサーションが必要で、少し冗長になる可能性がある。

*MyErrorを返す場合

メリット:

  • 返されるエラーの型が明確で、呼び出し元が直接エラーの詳細(この場合Codeなど)にアクセスできる。
  • 型アサーションが不要なため、コードがやや簡潔になる。

デメリット:

  • 関数のシグネチャが具体的な型に依存するため、将来的に異なるエラー型を返す必要が生じた場合に変更が困難。

選択の指針

  • ライブラリやパッケージを作成する場合、通常はerrorインターフェースを返す方が良い。これにより、利用者側での柔軟性が高まる。
  • アプリケーション内部の特定のドメインロジックで、常に特定のエラー型を扱う場合は、*MyErrorを直接返しても問題ない。
  • エラーの詳細情報へのアクセスが頻繁に必要な場合、*MyErrorを返す方が便利かもしれない。
  • しかし、一般的にはerrorインターフェースを返し、必要に応じて型アサーションを行う方法が推奨される。これにより、Goの標準的なエラーハンドリングパターンと一致し、コードの一貫性と将来の拡張性が保たれる。

最終的には、プロジェクトの要件、チームの慣習、そして将来的な拡張性を考慮して選択するのが良いでしょう。


だそうです。納得いく理由で、「じゃ気をつけて使えば*MyError返すようにしてもいいかもな」と思ってしまうのですが、そうではない(気をつけるのが難しすぎる)理由を説明していきます。

*MyErrorを返すようなDo関数を定義する

Do関数を以下のように *MyError を返すように変更します。 返り値の型を変更するだけで良いのですが、この後の説明のために「偶数ならエラーでない、奇数ならエラー発生」というようなロジックにしておきます。

func Do(i int) *MyError {
	if (i % 2) == 0 {
		return nil
	} else {
		return &MyError{origErr: errors.New("original error"), Code: 500, Message: "Error!"}
	}
}

とりあえず実行してみる。大丈夫そう

以下のmain関数で実行してみます。

func main() {
	fmt.Println("[*MyError型のデータを返す]")
	err1 := Do(1)
	if err1 != nil {
		fmt.Println("  is **NOT** nil")
		fmt.Println("    IsServerError:", err1.IsServerError())
	} else {
		fmt.Println("  is nil")
	}

	fmt.Println("[*MyError型のnilを返す]")
	err2 := Do(2)
	if err2 != nil {
		fmt.Println("  is **NOT** nil")
		fmt.Println("    IsServerError:", err2.IsServerError())
	} else {
		fmt.Println("  is nil")
	}
}
ここまでのコード
package main

import (
	"errors"
	"fmt"
)

func main() {
	fmt.Println("[*MyError型のデータを返す]")
	err1 := Do(1)
	if err1 != nil {
		fmt.Println("  is **NOT** nil")
		fmt.Println("    IsServerError:", err1.IsServerError())
	} else {
		fmt.Println("  is nil")
	}

	fmt.Println("[*MyError型のnilを返す]")
	err2 := Do(2)
	if err2 != nil {
		fmt.Println("  is **NOT** nil")
		fmt.Println("    IsServerError:", err2.IsServerError())
	} else {
		fmt.Println("  is nil")
	}
}

type MyError struct {
	origErr error
	Code    int
	Message string
}

func (e *MyError) Error() string {
	return "MyError: " + e.Message
}
func (e *MyError) Unwrap() error {
	return e.origErr
}
func (e *MyError) IsServerError() bool {
	return e.Code >= 500
}

func Do(i int) *MyError {
	if (i % 2) == 0 {
		return nil
	} else {
		return &MyError{origErr: errors.New("original error"), Code: 500, Message: "Error!"}
	}
}

実行結果はこちらです。全く問題なさそうですね。

[*MyError型のデータを返す]
  is **NOT** nil
    IsServerError: true
[*MyError型のnilを返す]
  is nil

errorインタフェースを返す関数の中で呼ぶと、あれ?

errorインタフェースを返す関数内でDoを呼んでみましょう。MyErrorの情報を使いたいのは、サーバーエラーだったかどうかをロギングしたいだけ、というような状況設定です(ちょっと苦しいが...)

func method(i int) error {
	err := Do(i)
	if err != nil {
		fmt.Println(time.Now().Format("2006-01-02 15:04:05"), "[INFO] IsServerError:", err.IsServerError())
	}
	return err
}

func main() {
	fmt.Println("[*MyError型のデータを返す]")
	err1 := Do(1)
	if err1 != nil {
		fmt.Println("  is **NOT** nil")
		fmt.Println("    IsServerError:", err1.IsServerError())
	} else {
		fmt.Println("  is nil")
	}

	fmt.Println("[*MyError型のnilを返す]")
	err2 := Do(2)
	if err2 != nil {
		fmt.Println("  is **NOT** nil")
		fmt.Println("    IsServerError:", err2.IsServerError())
	} else {
		fmt.Println("  is nil")
	}

	fmt.Println("[errorインタフェースで*MyError型のデータを返すメソッドを呼び出す]")
	err3 := method(1)
	if err3 != nil {
		fmt.Println("  is **NOT** nil")
	} else {
		fmt.Println("  is nil")
	}

	fmt.Println("[errorインタフェースで*MyError型のnilを返すメソッドを呼び出す]")
	err4 := method(2)
	if err4 != nil {
		fmt.Println("  is **NOT** nil")
	} else {
		fmt.Println("  is nil")
	}
}
ここまでのコード
package main

import (
	"errors"
	"fmt"
	"time"
)

func main() {
	fmt.Println("[*MyError型のデータを返す]")
	err1 := Do(1)
	if err1 != nil {
		fmt.Println("  is **NOT** nil")
		fmt.Println("    IsServerError:", err1.IsServerError())
	} else {
		fmt.Println("  is nil")
	}

	fmt.Println("[*MyError型のnilを返す]")
	err2 := Do(2)
	if err2 != nil {
		fmt.Println("  is **NOT** nil")
		fmt.Println("    IsServerError:", err2.IsServerError())
	} else {
		fmt.Println("  is nil")
	}

	fmt.Println("[errorインタフェースで*MyError型のデータを返すメソッドを呼び出す]")
	err3 := method(1)
	if err3 != nil {
		fmt.Println("  is **NOT** nil")
	} else {
		fmt.Println("  is nil")
	}

	fmt.Println("[errorインタフェースで*MyError型のnilを返すメソッドを呼び出す]")
	err4 := method(2)
	if err4 != nil {
		fmt.Println("  is **NOT** nil")
	} else {
		fmt.Println("  is nil")
	}
}

func method(i int) error {
	err := Do(i)
	if err != nil {
		fmt.Println(time.Now().Format("2006-01-02 15:04:05"), "[INFO] IsServerError:", err.IsServerError())
	}
	return err
}

type MyError struct {
	origErr error
	Code    int
	Message string
}

func (e *MyError) Error() string {
	return "MyError: " + e.Message
}
func (e *MyError) Unwrap() error {
	return e.origErr
}
func (e *MyError) IsServerError() bool {
	return e.Code >= 500
}

func Do(i int) *MyError {
	if (i % 2) == 0 {
		return nil
	} else {
		return &MyError{origErr: errors.New("original error"), Code: 500, Message: "Error!"}
	}
}

実行結果はこちらです。

[*MyError型のデータを返す]
  is **NOT** nil
    IsServerError: true
[*MyError型のnilを返す]
  is nil
[errorインタフェースで*MyError型のデータを返すメソッドを呼び出す]
2024-09-21 20:56:35 [INFO] IsServerError: true
  is **NOT** nil
[errorインタフェースで*MyError型のnilを返すメソッドを呼び出す]
  is **NOT** nil

!?!?!?

[errorインタフェースで*MyError型のnilを返すメソッドを呼び出す]
  is **NOT** nil

おかしいですね。nilが返っているはず (実際にログも出力されていない) なのに、main関数内では **NOT** nil と出てしまいました。

原因を探る

前述の不可解な挙動をした err4 と、 var hoge *MyError つまりポインターのゼロ値であるnilが入った変数とで比べてみます。

	err := method(2)
  	fmt.Printf("err %%v:  %v\n", err)
	fmt.Printf("err %%p:  %p\n", err)
	fmt.Printf("err %%T:  %T\n", err)
	fmt.Printf("err %%#v: %#v\n", err)
	fmt.Printf("&err %%T: %T\n", &err)
	fmt.Println("err == nil:", err == nil)

	fmt.Println("")

	var hoge *MyError
	fmt.Printf("hoge %%v:  %v\n", hoge)
	fmt.Printf("hoge %%p:  %p\n", hoge)
	fmt.Printf("hoge %%T:  %T\n", hoge)
	fmt.Printf("hoge %%#v: %#v\n", hoge)
	fmt.Printf("&hoge %%T: %T\n", &hoge)
	fmt.Println("hoge == nil:", hoge == nil)

結果

err %v:  <nil>
err %p:  0x0
err %T:  *main.MyError
err %#v: (*main.MyError)(nil)
&err %T: *error   //⭐️
err == nil: false //⭐️

hoge %v:  <nil>
hoge %p:  0x0
hoge %T:  *main.MyError
hoge %#v: (*main.MyError)(nil)
&hoge %T: **main.MyError //⭐️
hoge == nil: true        //⭐️

ほぼ一緒なのですが、 ⭐️をつけたところが異なります。

原因とまとめ

これは 公式のFAQにもある、nilがnilでない問題に帰着します。

細かい話は上記FAQや 「nil is not nil」 とかで調べていただくのが良いかと思うのですが、簡単に説明します。

今回、methodを経由することで errorインタフェースに「代入」(?)された*MyErrorは、

  • 型情報として *MyError
  • 値として nil

という状態になります。変数がインタフェースの場合は

  • 型情報として nil
  • 値として nil

のもの、つまりプレーンな(?) nil しか、 == nil でnilとは判定されなくなってしまいます。

独自型を型付けして返すようにしてしまうと、よりこの落とし穴にハマりやすくなってしまいます。
具体的には、

  • 独自のエラー型を使っているかどうかを確認する
    • 独自のエラー型だった場合はそのまま呼び出し元に返さず、必ずnilかどうかをチェックして、 nilの場合は return nil と書く
  • 必ず確認しなきゃいけないので、 return Do() みたいな書き方はできない

を徹底しないといけません。これはなかなか厳しいです。

エラーは必ず error インタフェースで返し、独自の型情報を使いたい場合は、呼び出し側で変換して使う、というGo wayを進んでいきましょう。

ソーシャルデータバンク テックブログ

Discussion