[Go] 独自のエラー型をerrorインタフェースを介さずに返すのは良くないという話
(公式の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
と書く
- 独自のエラー型だった場合はそのまま呼び出し元に返さず、必ずnilかどうかをチェックして、 nilの場合は
- 必ず確認しなきゃいけないので、
return Do()
みたいな書き方はできない
を徹底しないといけません。これはなかなか厳しいです。
エラーは必ず error
インタフェースで返し、独自の型情報を使いたい場合は、呼び出し側で変換して使う、というGo wayを進んでいきましょう。
Discussion