🤔

Goのエラーハンドリングの考え方が良く分からない

2021/06/21に公開

はじめに

Goのエラーハンドリングの考え方が良く分からない、ので誰か教えて、という記事です。

最近、以前流行ってるから触ってみたものの「好みじゃないな」と思って使うのを辞めてたGoに再入門しようかと思いチャレンジ中です。ちょうど今作ってるアプリのユースケースにハマりそうだったので。改めて使ってみると以前と違い割と違和感なく使えそうな感じ。

ただ、やっぱりエラーハンドリング周りがイマイチ腑に落ちないので、実践の場ではどうしてるんだろう、と。とくに結論は無く疑問を疑問と書いてるだけの記事になるので、その点はご注意ください。

また、念のため書きますがJavaや他言語と同様のtry-catchが無いからダメだとか、Goがイケてないという趣旨ではなく純粋にどういう考え方なんだろう、という疑問の話です。

Goのエラーハンドリング

GoではJavaのような例外機構は無くErrorインターフェースを多値で返すのが基本の認識です。PanicやらRecoverやらは多用するものではない。これ自体には大きな疑問は無いです。C言語からの素朴な拡張という視点も分かりますし、Errorインターフェースもカスタマイズ出来るようなので必要な情報を込める事も出来るし型によるハンドリングもできそう。JavaのOptionalとかも似たようなスタイルですしね。

ただ、入門的なコードしか見てないせいもあると思うけど、毎回毎回if文でチェックして例外処理を都度書いてるけど、こんなこと業務コードでも本当にやってるの? と言うのが自分の素朴な疑問。なにしろ自分の中でそれはある種のアンチパターンなので。

そもそも例外処理として行いたいこと

自分がエラー時にしたい事は8割から9割くらいは 「適切なエラーを吐いて速やかに死ぬこと」 です。

もちろん、接続のリトライやリカバリー系の処理を書きたいケースは当然あるのですがそれはほんのごく一部。ほとんどは 「ユーザに規定のエラーメッセージ」 を返して 「システムサイドにエラー箇所や原因が分かるようなログを出して」 速やかに死んでほしい。ただそれだけで、これはバッチでもリアルタイムでもコマンドでも変わらないかな、と。

なので、どんなエラーかは対象のコードを使う時点ではほとんど興味が無く(ドキュメントにはもちろん書いておいて欲しいですが)、FWやコールスタックの上位で共通のエラー処理をして欲しいのですよね。共通のエラー処理を集約しないとエラー実装ミスとかも起こりがちだし修正も大変になるので。Javaは非検査例外(とtry-with-resources)を多用すればこういった思想の例外ハンドリングが組みやすいです。

ただ、Goはこういったニーズに向いた言語機能には見えないので、たぶん思想そのものが違うのだろうな、と。

たぶんこんな感じ? 1 - 可能な限り素朴に

(うろ覚えなので勘違いかもですが)Go言語は素朴に書ける事を重視してという認識です。学習コストが低く誰が書いても似たようなコードになることを目指している。
なので 「エッジケースをより簡潔に書くための仕組みであってもそのポリシーから外れるなら実装しない」 という思想かな、と。実際、3項演算子もなくif式でもない。Genericsも無い(これは2で入るという噂?)。map/reduce的なfor ループの代わりの関数型スタイルもない。好き嫌いは置いとくとしてこれも言語の思想として良いと思います。

その流れで比較的にC言語に近い形で単純にエラーのコード値を返すのを例外処理の基本において、複雑な例外機能を使わず誰でも読める、という点を重視したのかなぁ、という気がしなくもない。

自分はそれでも8割のケースと上で書いてる部分にはケアが欲しいけど、Go的にはそれよりも単一の書き方が重要だったり、そもそも8割のケースではない、ということなのかな、と。

たぶんこんな感じ? 2 - 例外ハンドリングに責任を持て

呼び出し側が 「どんな例外が起こりうるか」 を理解するのが大事、と言う話もあります。この場合、戻り値として書いてあれば意識せざる得ないよね、というのは分かりやすくJavaの検査例外が目指したものも似たような感じなのかな、と。なので、戻り値に入れる事で個別のハンドリングをしやすくしている

とはいえ、TLで色々教えてもらったけどErrorをひたすらReturnで上流にエスカレーションして、そこで共通のエラーハンドリングをするというのは普通にやるみたい。
特に情報付加もしないなら即Returnみたいな。やっぱり、エラーハンドリングは共通でしたいのでこれは納得。

if err := funcA(); err != nil { return err }

この場合でもErrorがあるなら失敗したケースを意識はせざる得ないので呼んだ先が責任を持つという点はクリアできてる気がするし。ただJavaのRuntimeExceptionラップのようなダーティーハック感もちょっとする。。。
あと毎回これ書くの面倒だから普通に構文糖衣はないの? って思う

たぶんこんな感じ? 3 - 例外ハンドリングは自ら最適なものを設計せよ

素朴な例外機構しか持たないということは言語設計者ではなく利用者側で工夫の余地が多いという事。例外にスタックトレースのような詳細なコールスタックがあった方が良いケースも無意味だから不要と言うケースもまちまち。であれば、拡張可能なErrorインターフェースだけ作って、あとは利用時に好きに値詰め込んで良いよ! って思想。可能性は無くはない。

ただ、Goは結構標準ライブラリ主義の人が多い気がするからこれは違う気がするなぁ。

まとめ

とりあえず、自分なりに例外処理機能何を求めるかを整理して、Goの例外機構がなぜこうなってるかを想像してみました。
なんとなく、3は違うと思うけど、1と2あたりの思想が中心の気はする。「誰が実際はこうだよ」とかあればコメントいただければと。

そもそも根本的な所が理解できなかったりするのは大いにありそうだし。

今回書いたような話題は動く動かないはさておきGoらしいコードの書き方を学ぶためのきっかけにはなると思うので、どうせ学ぶならしっかりとその言語の哲学みたいなのは押さえておきたいですね。

それではHappy Hacking!

追記

ゼロ除算とかはエラーじゃなくてPanicでスローされるのね。なんとなく使い分けはイメージ付くけど、Error型のReturnだけじゃなくてPanic時も一貫した例外ハンドリングするようにアプリ側としては作らないといけないのか。ちょっとめんどいね。。。

deferとrecoverを使って、同じエラーハンドラに渡すくらいしかよさげな手は無い感じかなぁ?

defer func() {
    if err := recover(); err != nil {
        MyErrorHandler(err)
    }
}()

参考

https://qiita.com/nayuneko/items/dea02377b797c2a52053
https://qiita.com/Maki-Daisuke/items/80cbc26ca43cca3de4e4
https://zenn.dev/nobonobo/articles/0b722c9c2b18d5

Discussion