🦜

Goとエラーハンドリング慣習について

2023/05/20に公開
5

エラー返値が無用な条件

  • 関数ないしメソッドの実装がオンメモリ操作のみで完結
  • 将来も(メモリ以外の)I/O操作は追加されることがない

逆にいうと上記の条件のいずれかが達成できない可能性がある関数やメソッドはエラー返値を付与すべき。

返値エラー型はerrorで統一する

返すエラーがerrorインターフェース型でなければそのエラーは正常にハンドリングできません。またerrorインターフェースを満たす別の返値型で返してerrorインターフェース型で受け取るのも後述のトラブルの元です。

Goの実装方針に「インターフェースで利用するものもコンストラクター相当では構造体ポインタで返す」というものがありますがコンストラクタを呼ぶ側は元型にアクセスすることが多いのでこういう方針になっています。が、エラー値に関しては元型を意識せずに利用可能にするという役割があって、この実装方針は当てはまりません。

エラーチェックはアーリーリターンスタイルで

以下の様に...主たる処理...を実行するための前提条件を事前に確認します。
満たさない様な場合、returnして上流にエラーを委譲します。
委譲する時、できるだけ何に失敗したのか情報を付与してラップしたエラーを返すのが推奨です。

func hoge() error {
	if err :=  f1(); err != nil {
		return fmt.Errorf("f1 fail: %w", err)
	}
	if err :=  f2(); err != nil {
		return fmt.Errorf("f2 fail: %w", err)
	}
	...主たる処理...
}

nilをラップしちゃだめ

エラー発生を確認してラップしましょう。nilをラップした場合、それを受け取った側では正しくエラーハンドリングできません。

空のエラーではnilリテラルをリターンする

errorの戻り値にTyped-nilは厳禁です。受け取ったあとerrorインターフェース型に変換されるのもダメ。

NG
func find() error {
	var result *MyError
	...主たる処理...
	if err := do(); err != nil {
		result = &MyError{err.Error()}
	}
	return result // resultがnilの場合、ここでTyped-nilが作られてしまう
}
OK
func find() error {
	...主たる処理...
	if err := do(); err != nil {
		return &MyError{err.Error()}
	}
	return nil
}

ただ、このNG関数定義、利用しようとするとstaticcheckがちゃんと警告してくれます。

this comparison is always true (SA4023)

一部のエラーを無視する

Closeメソッドの多くはエラーを返しますが、慣習的に無視します。

defer fd.Close()

この慣習の理由はClose()の期待される役割が「リソースの開放」だからです。「リソースの開放」に失敗するというのは基本あり得ない状況です。エラーになる可能性を大きさ順で並べると、「ファイルオープン>>ファイル読み書き>>ファイルクローズ」です。ファイルクローズでエラーになる可能性は限りなくゼロに近く、エラーになるのはOSの機能に致命的なトラブルが発生した時ぐらいです。

ただ、ファイルストリームに書く処理におけるClose()ではバッファに残るものを書き出すという処理がリソース開放前に追加で行われます。なのでファイル書き込みに関してはfd.Sync()のエラーチェックをするのが推奨されます。

func LogWriter(ch chan string) {
	fd, err := os.Create("sample.log")
	if err != nil {
		... // エラーハンドル
	}
	defer fd.Close()
	for l := range ch {
		fmt.Fprintf(fd, "%v", ...)
	}
	if err := fd.Sync(); err != nil {
		... // エラーハンドル
	}
}

fd.Sync()を呼んでおいた場合、fd.Close()でバッファの書き出し処理は行われません。

自作の実装でもCloseメソッドはシンプルにリソース開放するだけのシンプルなものを期待されますのでこれらに習って実装しましょう。

ポインタとエラーを返す時どちらかがnilであれば他方はnot nil

これ意識してないとたまにやらかしてしまう。

こういう関数で「nil, nil」を返すのは非推奨

非推奨
func FindObject(name string) (*Object, error) {
	if 見つからなかった {
		return nil, nil
	}
	...
}

この場合、エラーチェックとは別にオブジェクトがnilかどうかの判定が必要になっちゃいます。これはGoの慣習とミスマッチ。絶対にこうしちゃダメって程の事でもないんだけど、Gopherはエラーがnilじゃないならもう一方が有効な値であることを期待します。例えばnot-foundなエラーを返すほうが誤解されないかと。

「Findなんとか」で見つからなかった場合の返値についてしっかりドキュメントがあれば双方nil返しもありかもしれませんが、以下のように書くほうが自然に返値の長さを確認してアクセスするということが明確です。

また、この件は必須のエラーチェックをすることで「nilポインタ参照をほぼ無くす」というのに貢献する慣習でもあります(nil-safeは目指さないがベターなnil-safe)。

参考:
https://zenn.dev/nobonobo/articles/f49dd93073fdd3c6c663

以下のように書くのが推奨

推奨
func FindObjects(name string) ([]*Object, error) {
	objects := []*Object{}
	...
	return objects, nil
}

この場合、エラーがnilなら「not nilな値」を返すことになります。なにもオブジェクトが見つからない時はゼロ長のスライスが返ります。

カスタムエラーは極力作らない

  • ユーザーにエラーの詳細を把握させるような実装を控えよう
  • もちろん本当に必要ならOK

エラーに関する付帯処理はできるだけ直近で済ます

  • ファイルへの書き出しが失敗したらファイルそのものを消したいとか
  • エラーだったらしなきゃいけない事はエラー検出したらすぐにやる
  • これを上流にやらせようとするとカスタムエラーやエラー分岐が必要になっちゃう
  • 脳死でエラーを上流に流すのは良くない癖
  • 上流はエラーの詳細を知らないで済むようになっているのが理想
  • 上流でやれることはログに吐くか上流タスクの中断くらいにとどめるべき

これらの慣習の意識が薄いときにハマるコード

以下のコードはコンパイルが通ってしまい、かつDo2の返値がどうなっていてもif分岐が固定ルートに流れてしまう。

package main

import "log"

type Response struct{}

type MyError struct {
	msg string
}

func (e MyError) Error() string { return e.msg }

func Do1() (*Response, error) {
	return &Response{}, nil
}
func Do2() (*Response, *MyError) {
	return &Response{}, nil
}

func main() {
	res1, err := Do1()
	if err != nil {
		log.Fatal(err)
	}
	res2, err := Do2()
	if err != nil {
		log.Fatal(err) // <- ここを通る
	}
	log.Println(res1, res2)
}

何が起こっているのかというと、

  • 2値返しを「:=」で受け取るとき、ひとつでも新変数があればもう一方に既存の同じシンボルがあれば「代入」という挙動になる
  • Do1の結果を受けた時、errはerror型で初期化
  • Do2の結果を受けるerrはerror型のまま*MyError型のnilを代入することでTyped-nilになる
  • *MyErrorがerrorインターフェースを満たしていればコンパイルエラーにはならない

しかし、staticcheckで以下のような警告は出ます。

this comparison is always true (SA4023)
	main.go:##:2: the lhs of the comparison gets its value from here and has a concrete typego-staticcheck

まとめ

  • Typed-nil(参考)を作らない返さない
  • エラー値の空を返す時はnilリテラルを返そう
  • エラー返値はerror型で統一すること
  • NGパターンの多くはstaticcheckが検出してくれる
  • エディタにstaticcheckをセットアップして警告に注意しよう
  • いくつか守ることが推奨される慣習があるので、書きなれた人のコードを参考にしよう
  • 慣れないうちはカスタムエラーとかは作らないほうがいい
  • エラーを何も考えずに上流に渡すというのはGoではミスマッチ
  • そのエラーに対しやっておかないといけない処理はエラーを検出したところで処理しよう
  • その時コードがDRYにならないなら一つの関数の責務が複雑すぎるかもしれない(参考
  • そうすると、エラータイプ分岐の必要なケースはかなり限定的になるはず
  • 他の言語処理系のやり方を無理やり持ち込むと害も大きくなるし利も大してない
  • 「Goのエラーハンドリングはこれだけ罠だらけなのクソやな」とはよく言われるが、Goほどエラーハンドリングを真摯に考え抜かれている言語もなかなかなかったりする(参考
  • ここにある数少ない慣習を頭に入れておいて、staticcheckに頼ることでほとんどのケースで罠にはまることはないはず

追記

Goは直積&多値返しという戦略を採用したわけですが、最適化にもよるでしょうけれど多値返しはオブジェクト返しに比べるとCPUフレンドリーという利点があります。

また、ほぼ直和の概念で運用されているGoのエラーハンドリングだけど、直積が必要なところもレアケースであります。

なので「直和ならいいのに」とか「Result型が欲しい」とか要望出してもGoで採用されることは無さそうです。

また、例外処理機構は並行処理系にはミスマッチでした。なので例外処理機構のほうがたとえメジャーであってもGo本体に採用されることはないでしょう。
https://zenn.dev/nobonobo/articles/9a9f12b27bfde9#goはなぜ例外機構を辞めたの?

Discussion

NoboNoboNoboNobo

これ見てGoを敬遠して良かったみたいなコメントを見ましたが・・・。例外機構にしろcatchコールバックにしろResult型にしろ、それぞれツラミがあったり不統一みたいなものがあって、ちゃんと書くのにはそれなりに慣習みたいなものを学び、慣れが必要だったり、適切なエラーハンドリング設計をするためにはそれなりの経験が要求されたりします。

Go以外の「エラーハンドリングのベストプラクティス」を調査してみるとよいかもしれません。意外とよくできていると評判のものも考慮すべきことは意外と多岐にわたります。エラーハンドリングを統一した仕掛けで捌くのは意外と難しい問題なのです。その中でGoのエラーハンドリングはCPUフレンドリーでerror型と多値返しで統一され、シンプルな仕掛けで実現できていることは評価できるポイントだと思っています。

Goでは抑えるポイントはここに書いた程度で済んでいてstaticcheck環境さえあれば実際には困るという事はほとんどないです。まぁいろんな方式で書いてみないと本当のところは分かってもらえないのでぜひGoを敬遠せずに試してみてほしいところ。

tenntenntenntenn

Go1.20でerrors.Join関数が登場したものの、ラップされて木構造を形成するGoのエラーとの関係で、errors.Split関数が提供されなかったと思います。この場合、Joinされたエラーを取得するにはinterface{ Unwrap() []error }に型アサーションしつつ、再帰でウォークするしかないなと思うんですが、そのあたり良い手法とかってご存知ないですか?

NoboNoboNoboNobo
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
		}
		switch x := err.(type) {
		case interface{ Unwrap() error }:
			err = x.Unwrap()
			if err == nil {
				return false
			}
		case interface{ Unwrap() []error }:
			for _, err := range x.Unwrap() {
				if Is(err, target) {
					return true
				}
			}
			return false
		default:
			return false
		}
	}
}

ああ、こういうやつですね。これは現状こういう風に再帰で処理するしかなさそう。

ただ、再帰構造になったエラーを追跡する用途があるのかというとあまりなさそうな。
複雑な構造データのバリデータとかテンプレートパースエラーくらい?

もしこのあたりでエラー保持が必要なら解析結果ツリーとエラーツリーを別々に持たせて後で突き合わせるよりは、解析結果ツリーの各ノードにエラー情報持たせるような気がする。(こういう用途にはもともと使えなかった)ー>Joinなエラーが返る処理はドキュメントされていて、必要なら展開して使うという使い方が基本ですね。

しかし、このJoinしたエラー、ログに吐くときに勝手に改行コード入れてくるのか・・それは自前でフォーマットしたいかもしれない。

NoboNoboNoboNobo

ちょっと考えてみたけれど、nilをerrors.Joinすることはできないので、データ階層構造と同じ形状のエラーを作ることはできない。やはり、これもまた「複数のエラーを返す処理」の直上が必要なら展開してハンドリングするというのが原則になりそう。実装側と利用側がお互いに理解している場合にのみ利用される仕掛け。
メタ的にエラーを処理したい(例えばログの書式化をしたい)場合にはerrors.Isのような再帰で階層を拾っていく感じになるかなぁ。

NoboNoboNoboNobo

ふと思った感想ですが、上記の使われ方であれば「[]error」を返すという形でよいような気がしますね。