Open10

Goのエラーハンドリングの思想についての自由研究

ピン留めされたアイテム
熊木熊木

Goのエラーハンドリングの思想についての日本語の記事が少なく、理解ができてない。

例えば以下の疑問に答えられるようにしたい。

  • なぜif err != nil { return err }を書き続ける必要があるのか?
  • なぜ直和型のResultを採用しなかったのか?
  • なぜGoにはスタックトレースがないのか?

開発者やその周辺のエラーハンドリングに関する記事やコメントを探っていくことで、エラーハンドリングの思想について理解を深めていく。

主に以下のやりとりを見ていこうと思う。
https://github.com/golang/go/issues?q=label%3Aerror-handling+sort%3Acomments-desc+is%3Aclosed

熊木熊木

https://go.dev/blog/errors-are-values

Rob Pike先生
「Errors are values」とのこと

  • エラーハンドリングは別にif err != nil { return err }という書き方にこだわる必要はない
  • エラーはプログラム上ではただの値なのだから、バッファしてから一括処理したりなど、色々工夫して良い

例:

type errWriter struct {
    w   io.Writer
    err error
}

func (ew *errWriter) write(buf []byte) {
    if ew.err != nil {
        return
    }
    _, ew.err = ew.w.Write(buf)
}

ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
// and so on
if ew.err != nil {
    return ew.err
}
熊木熊木

https://github.com/golang/go/issues/37141

Go 2への、passキーワードの提案

f, err := os.Open(filename)
pass err
defer f.Close()
  • エラーハンドリングの記述が楽になるという主張
  • passキーワードはエラーがnilでない場合にのみ関数からのリターンを行うため、コードをスキャンする際にerr == nilerr != nilを混同しない
    ・互換性が高い

err == nilerr != nilを混同しない」というのはなるほどと思った。コメントでも一定評価は受けてる。
https://github.com/golang/go/issues/37141#issuecomment-591290956

しかし、どうせやるならreturn ifとかreturn?にすべきではという意見が優勢か。
https://github.com/golang/go/issues/37141#issuecomment-591111288
https://github.com/golang/go/issues/37141#issuecomment-597433286

熊木熊木

https://about.sourcegraph.com/blog/go/gophercon-2019-handling-go-errors

2019のgopherconでの資料らしい。
ここでもConceptは「Errors are values」としており、スタックトレースに頼るよりもfmt.Errorfでラップした方が良いと主張。

スタックトレースに関して、「Stacktraces are for disasters」と言っており、以下の主張をしている。

  • main.go:10と書いてあっても、エラー箇所がわかるだけでエラー原因が分からないじゃん!
  • 読みにくいし、解析しにくい

例えば以下のように書きなさいと言ってる。

return fmt.Errorf("unique error message: %w", err)

また、errorに以下のような構造体を持たせ、自分でカスタマイズもできる。

Levelによって重大度ごとに分類できる。
Kindを定義すれば、タイプ別に分類できる。
Opを定義すれば、スタックトレースの代わりになる

熊木熊木

https://commandcenter.blogspot.com/2017/12/error-handling-in-upspin.html

こちらはRob Pike先生の記事。

というか上記のKind, Opなどを使ったパターンは、この記事のパターンにインスピレーションを受けている。

  type Error struct {
      Path upspin.PathName
      User upspin.UserName
      Op  Op
      Kind Kind
      Err error
  }

func E(args ...interface{}) error
  • エラーは価値ある情報: エラーは単なる障害ではなく、発生した問題に関する価値ある情報を提供するもの。
  • エラーの文脈: エラーを伝播する際には、発生した文脈を付与する
  • エラーの一貫性: プロジェクト全体でエラーの取り扱いに一貫性を持たせる。
  • ユーザーにとって有益なエラー: エラーは開発者だけでなく、エンドユーザーにとっても有益な情報を提供し、ユーザーがエラーの原因を理解し、可能な場合は問題を解決できるようにすることが望ましい。
  • エラー処理はアート: 過度に複雑にすることなく、適切な量の情報を提供すること。
熊木熊木

https://go.googlesource.com/proposal/+/master/design/go2draft-error-handling.md

Russ Cox先生による記事。
Go 2ではエラーハンドリングをもう少し簡素にしたいとのこと。

Draft案(あくまで議論のための足掛かりとしている)として、checkhandleをという予約語を用意し、エラーcheckで失敗すると、制御は上にある次のhandleへと移る。

func CopyFile(src, dst string) error {
	handle err {
		return fmt.Errorf("copy %s %s: %v", src, dst, err)
	}

	r := check os.Open(src)
	defer r.Close()

	w := check os.Create(dst)
	handle err {
		w.Close()
		os.Remove(dst) // (only if a check fails)
	}

	check io.Copy(w, r)
	check w.Close()
	return nil
}

tryでなくcheckにしたのは、以下の例で不自然なため。

data, err := parseHexdump(string(hex))
if err == ErrBadHex {
	... special handling ...
}
try err

また、RustではResult型とパターンマッチが存在し、これはしばしば冗長な記述になってしまうとしている。
?演算子は冗長な記述は避けられるが、上記のhandleのような(例えばerrorをラップできるような)仕組みではないと。

熊木熊木

https://github.com/golang/go/issues/20803

fmt.Fprintlnのように実は失敗しうる関数は、エラーを無視できないような書き方に強制すべきという提案。

つまり、以下のように書くように強制する。

_, _ = fmt.Fprintln(&buf, v)

また、同時にエラーを無視できるignoreという予約語も提案している。

go ignore(fmt.Fprintln(os.Stderr, findTheBug()))  // BUG: evaluated immediately

反対意見は以下。

  • hello worldの出力で_, _ =と書く必要があるのは、初学者にとって気持ち悪い
  • どちらかというとvetの機能では