世界を動かす技術を、日本語で。

エラー処理のための構文サポートについて

概要

Go言語の エラーハンドリング の冗長さは長年議論されてきた課題。 Goチームとコミュニティは 様々な提案 (check/handle、try、?演算子など)を試みたが、 十分な合意 には至らなかった。 現状のエラーハンドリング方法は冗長だが、 言語仕様の変更は見送られる方針。 今後は 標準ライブラリやツールの改善 で利便性向上を目指す。 IDEやツールの進化も 冗長さの緩和 に貢献可能。

Go言語のエラーハンドリングの歴史と議論

  • Go言語のエラーハンドリング は「if err != nil」パターンが多用される冗長さが課題。
  • API呼び出しや単純なエラー返却が多いプログラムでは、 本質的な処理よりエラー処理が目立つ 傾向。
  • 例:printSum関数の10行中6行がエラー処理となり、ノイズと感じられる。
  • 長年のユーザー調査でも 「エラーハンドリングの冗長さ」 が最上位の不満事項。

Goチームによる主な提案

  • 2018年:「check/handle」機構(Go 2構想)

    • Marcel van Lohuizenのドラフト設計に基づく。
    • 他言語との比較や代替案も詳細に分析。
    • 設計が複雑すぎるとして却下。
  • 2019年:「try」提案

    • check/handle案を簡素化し、「try」組み込み関数を導入。
    • コントロールフローの隠蔽や可読性低下が懸念され、コミュニティの反発も大きく却下。
  • 2024年:「?」演算子案

    • Rustの「?」演算子を参考にした提案。
    • ユーザー調査では直感的な理解が得られたが、細かな修正提案や意見対立が多発し、合意に至らずクローズ。
  • その他コミュニティからも多数の提案

    • Ian Lance Taylorによる「エラーハンドリング改善提案まとめ」やGo Wikiで議論を整理。
    • Sean K. H. Liaoのブログなどで膨大な提案が可視化。

合意形成の難しさと現状維持の決定

  • いずれの提案も 十分なコンセンサスを得られず却下
  • Goの設計思想「同じことを複数の方法で書かせない」にも合致せず。
  • 新構文導入は 既存ユーザーとの分断や非互換性リスク も伴う。
  • Generics導入時との違い:Genericsは使わなくても済むが、エラーハンドリング構文は全員が使う必要が生じる。

現状のエラーハンドリングと今後の方向性

  • 現行の冗長なエラーハンドリングも「 実際にエラーを適切に扱う場合」はノイズが目立たない。
  • エラー情報の付加(例:fmt.Errorfで詳細メッセージ追加) により、実用的なコードが書ける。
  • 標準ライブラリの拡充 (例:cmp.Orで複数エラーの同時処理)による利便性向上が期待。
  • IDEやLLM支援によるコード補完、エラーハンドリング部分の 表示切替機能 など、ツールによる冗長さ緩和も可能。
    • デバッグ時もif文が独立していることでブレークポイント設定が容易。

結論

  • エラーハンドリング構文の抜本的な変更は当面行わない 方針。
  • 標準ライブラリやツールの改善、IDEの進化などで 実用性と可読性の向上 を目指す。
  • Goの設計原則 とコミュニティの多様な意見を尊重した判断。

Hackerたちの意見

Goにとってこれは正しい選択だと思う。最初はGoのエラーハンドリングが嫌いだったけど、今は本当に好きになった。最初にこの言語を知ったときは、全然理解できなかったんだ。変わったきっかけは、この記事にもある「https://go.dev/blog/errors-are-values」を読んで、しっかりと理解したこと。それを元にそこそこ人気のあるパッケージも作ったよ - https://github.com/stytchauth/sqx。あとは、本当にひどい無効な状態のときにちょっとpanic(err)を使うことに慣れたことかな。親のコード全体に無意味なエラー処理を強いる理由はないし、適切な場所でのpanicがあれば、コードベースから何百ものエラーチェックを省けるからね。考えてみて、ctxにデフォルトのロガーはある?

これはただ悲しい。あなたのサポートのどちらも、Goのエラーハンドリングがひどいこととは関係ないし、改善することで悪化することもない。むしろ、改善されるはずだよ。

PHPですら、エラーハンドリングにレベルがあって、呼び出し元でエラーを抑制するための@(atオペレーター)があるんだよ。bashだって-eがあるし :)

チェックボックスのリストを作って、一つ一つのポイントを議論して、チェックを入れていく。致命的な意味のエラーや整合性の穴が見つからない限り、チェックを外すことはない。完成したら実装されて、.await/await.await!()のスペルについて意見を持っていた人たちは、どこかに消えていく。何が問題なの?Rustはこんな感じで動いてる。時には問題が10年以上も遅れることもあるけど、最終的には全てのチェックが入って、最新のナイトリービルドで安定する。もしGoが、言語に対してみんながすぐに抱く唯一の問題を解決できないなら、完璧な提案がいくつもあるのに、提案の選択ができずに人々が無駄に議論を続けるのを待っているだけなら、そのプロセスは茶番だよ。

Goは言語に対してみんながすぐに抱く唯一の問題を解決できない... 何それ?調査によると、13%がエラーハンドリングについて言及してるよ。そして、実際にそのままの方がいいって人もいる。https://go.dev/blog/survey2024-h1-results

これがRustが読みにくい言語で、一貫性のない構文を持つという評判を得る理由なんだよね:委員会によるデザイン。

"完全で完璧な提案がいくつかあるにもかかわらず" そんなものは存在しない。

完全で完璧 これは完全に主観的で、Goコミュニティを逆説的に描いてるね。頑固でありながら変化を求めている。残念な現実は、Goのエラーハンドリングが言語設計の理念とGoを書く開発者を満足させる中で、最もマシな選択肢だってこと。俺はVのスタイルのエラーハンドリングを実装するのが好きだけど、実際にそれを実装するのが全てうまくいくわけじゃないってのも理解してる。

この議論を詳しく追っていないから、関連する話が抜けてたらごめんね。でも、Rustスタイルをそのまま採用すればいいのにと思う。今、Goにジェネリクスがあるから、すぐに追加することにしてる。リンクされた記事にこんな一文があった: > でもRustにはhandleの同等物がない:?演算子の便利さは、適切なハンドリングが省略される可能性を伴う。便利さがエラーを無視することに等しいとは思えない。これがGoのアプローチに対する私の問題の半分で、結果に関する何も強制されず、エラーのチェックも最小限しか強制されないから。例えば、これが「宣言されていて使われていない: err」になる: x, err := strconv.Atoi("123") fmt.Println("result:", x) でも、これが普通に動く(yのデフォルト値が0だから、何が問題か分からない): x, err := strconv.Atoi("123") if err != nil { panic(err) } y, err := strconv.Atoi("1234") fmt.Println("result:", x, y) これもコンパイルされて普通に動くけど、また何が問題か分からない: x, err := strconv.Atoi("123") if err != nil { } fmt.Println("result:", x) 戻り値をresultにすることで、決断を下さなきゃいけなくなる。誰が!を使ったり、便利に?を使ってエラーケースを処理しないことを禁止するの?panicも禁止するつもり?

GoにはResultがないのは、合計型がないからで、奇妙なことに、すべての型に指定されたゼロ値が必要だと主張しているから追加できないんだ。

Rustスタイルが採用されない理由がわからない。主に、GoにおけるRustスタイルの相当物がはっきりしないからだよね。例えば、Rustの「From」はどんな感じになるんだろう?

Goにジェネリクスが使えるようになった今、すぐに追加するものだね。もしよかったら、具体的なコード例を見せてくれるとめっちゃ興味ある!

便利さがエラーを無視することと同じだとは思えないな。?を書く便利さがあるから、誰もエラーをラップすることを気にしなくなるっていうのは、かなり疑わしい意見だと思う。だって、?をラップを促すようにデザインすればいいだけなんだから。

ちょっと待って… x, err := strconv.Atoi("123") で、errがnilじゃなかったら { panic(err) } y, err := strconv.Atoi("1234") fmt.Println("result:", x, y) > これもコンパイルしてちゃんと動くけど、また何か問題があるなんて分からないよね。うーん、Goは使ったことないけど、":="は「一文で宣言して代入」って意味だと思ってた。5行目の例では「err」を再宣言してるんじゃないの?だから新しい「err」変数(古いerr変数を隠すやつ)が未使用とみなされて「declared and not used: err」ってエラーになるんじゃないの?それとも、:=は既存の変数があれば普通の代入になるの?

  • 可視性が悪いし、制御フローの分岐を一つの文/式に隠しちゃう。それがGoが三項演算子を廃止して、各分岐を別の行に書かなきゃいけないif文を選んだ理由の一つ。 - ブレークポイントを簡単に設定できない。 - エラーを豊かにしたり処理するよりも、そのまま「バブルアップ」することを好む。

以前、Goの関数で、珍しくも内部関数からエラーが返ってくることを期待していて、内部関数からエラーが返ってこなかったらエラーを返さなきゃいけなかったんだ。逆に、内部関数がエラーを返したらnilを返す必要があった。要するに、if err == nil { // エラーを返す }をしなきゃいけなくて、if err != nil { ... }は使えなかった。こうやって分解すると簡単そうに聞こえるけど、間違って後者を書いちゃって、実はその構文に慣れすぎててデバッグにすごく時間がかかった。頭の中でif err != nilがそこにあるべきじゃないって考えなかったから。この経験から、一般的な表現のための構文糖が必要だと思った。if err != nil(めっちゃ一般的)とif err == nil(かなり珍しい)の間にもっと明確な区別があったら、私には大きなメリットになったと思う。

"if err == nil"を書くときは、目立つように// invertedって書くようにしてる。言語がこれを処理してくれればいいんだけど、少なくとももう少し目立たせる方法を共有したかったんだ。 if err == nil { // inverted return err }

いい指摘だね。エディタで「if err … {」みたいな折りたたみ表記で解決できるかもしれない。

もちろん、if fruit != "Apple" { ... }も同じ状況に置かれるよね。これを改善する一般的な解決策はあるのかな?エラー問題だけとして見るのは、ちょっと的外れな気がする。結局、エラーには特別なことなんてないからね。ただの状態に過ぎない。

このスレッドで多くの人が軽々しくGoチームが代わりにできたことを提案しているなら、この記事のリンクをクリックして、これに関するウィキページを見てほしい: https://go.dev/wiki/Go2ErrorHandlingFeedback またはGitHubのイシュー検索: https://github.com/golang/go/issues?q=+is%3Aissue+label%3Aer... あなたが提案していることは、あなたが初めてではないし、しっかりと考慮されていることが多いよ。Goチームのこの誠実なアプローチには感謝していて、毎日仕事でGoを使うのを楽しんでいる。

どこかで既に答えが出てるかもしれないけど、なんでGoだけがこんなに問題になってるのか気になるんだよね。他のほとんどの言語にはもっといい方法があるのに、いろんなアプローチがあるし…問題は、みんなを納得させる決定ができないってことなのか、それともGoという言語特有の何かがあって、他の解決策がうまくいかないのかな?

みんなのフィードバックの基になってる設計文書にはC++、Rust、Swiftが言及されてるけど、上でリンクされてるフィードバック文書にはHaskellやScala、OCamlで使われるdo-notationやfor-comprehensions、monadic-letのことは書かれてなかった。最もコメントが多いGitHubのイシューの最初の数ページでもそんなのは見当たらなかったよ。Goチームがプログラミング言語デザインの魔法使いみたいに描かれてるけど、ここで提案されてる解決策は彼らが考慮したはずのものだって言ってるのを忘れちゃいけない。GoチームはJavaが犯したのと同じ過ち(パラメトリックポリモーフィズムなしの静的型付け)をやらかして、そのせいでこのエラーハンドリングの問題が起きてるのに、手をこまねいてるだけだよ。

本当に賢くて経験豊富な人たちがあのページを書いて、何年もアプローチを議論してきたのに、Haskellの解決策、つまりMaybeとEitherモナドや、bindオペレーターを使ったdo-notationが全く言及されてないのが不思議だよ。なんかすごくおしゃれで intimidating に聞こえるけど、エラーを適切に処理できる場所に伝播させる非常にエレガントで機能的に純粋な方法なんだ。これがHaskellコードを書く人たちにとっては当たり前のことになってるから、どうしてこれが考慮されなかったのか本当に理解できない。Goコミュニティにはこれを知っていて、もしかしたら評価している人もいるはずだよね?Haskellの学問的なイメージに intimidated になってる人たちを除外しても、宗教的な議論を避けても。あのページへのリンクとその存在には感謝してるけど、言語に対してこんなに気を使ってる人たちが、こんなに確立された解決策を見逃すのが本当に不思議だよ。

実際のエラーハンドリングコードに戻ると、エラーがきちんと処理されていれば冗長さは目立たなくなるよね。良いエラーハンドリングには、エラーに追加情報を加えることがよく必要だし。例えば、ユーザー調査でよく出るコメントは、エラーに関連するスタックトレースがないことについてだ。この問題は、拡張されたエラーを生成して返すサポート関数で解決できるかもしれない。この(ちょっと無理やりな)例では、ボイラープレートの量がずっと少なくなるよね: [...] if err != nil { return fmt.Errorf("無効な整数: %q", a) } [...] 「スタックトレースを手動で提供すること」を「エラーを処理する」と呼ぶのが面白いな。Goチームのエラー処理の定義によれば、例外は「自動的にエラーを処理してくれる」んだよね。 もちろんC++以外の言語ではね。

スタックトレースが画面いっぱいに表示されて、どれだけ明確で役立つかって言う人を見ると面白いな。確かにそうかもしれないけど、本当にそんなに必要?ログにどれだけのコストがかかるのか?フレームワークやランタイムのノイズを切り抜ける一行のラップされたエラーの方がずっといいよ。そう、トレースも同じくらい効果的にできるし(たいていはそれ以上に)、ラップがうまくできてればgrepしやすいからね。Goをフルタイムで書いて10年以上になるけど、ランタイム関数やコールスタックの他の冗長なゴミなんて全然気にしたことないよ。

Goの明示的なエラーハンドリングが好きだな。俺の中では、関数は常に成功する(エラーなし)か、成功するか失敗するかのどちらかだと思ってる。常に成功する関数は分かりやすいよね。もし関数が失敗したら、その失敗を処理しなきゃいけない。外側のコードは失敗したら進めないからね。ここが言語の違いが出るところだと思う。多くの言語は例外を使ってエラーを投げて、誰かがそれを捕まえるまで待つんだ。そうするとスタックトレースができるけど、エラーがどこで発生したかは分かるけど、いつも役立つ情報が得られるわけじゃない。Goでは、コードを書くときに選択肢が必ずあるのが好きだな:1. エラーを無視して進む(foo, _ := doSomething()) 2. エラーを処理して早めに終了するけど、意味のある情報は提供しない(return nil, err) 3. エラーを処理して、役立つコンテキストを持って早めに返す(一般的なラップされたエラーを返す) 4. 受け取ったエラーを解釈して、異なる分岐をする。例えば、データベースが変更する行を見つけられなかったら、サービス層は見つからないエラーを返さなきゃいけなくて、それがAPIで404として反映される。もしかしたら、冪等な削除関数が見つからないエラーに遭遇して、それを成功と解釈するかもしれない。Go 2や他の言語では、見たい変更はResult型を追加して、nil可能なタプル(Rust/Swift風)を使うことと、エラータイプの発見や列挙を助けるために、常にerrorを直接使うのではなく、より型付けされた列挙されたエラータイプを使うことかな。これがGo 2(または新しい言語)にぴったりだと思う。Go 1の慣用的なタプル返却の上にResult型を追加すると、同じことをする方法が増えて混乱を招くから。

エラーに関する俺の経験では、エラーハンドリングのポリシーは呼び出し元に委ねるべきだと思う。スタックの低レベルな部分がエラーを処理するべきじゃない。彼らは何をすべきか分からないからね。エラーを処理するポリシーは、結局エラーをラップしてスタックに返すポリシーになっちゃうことが多い。無駄な作業が増えるだけだよ。

Elixirの開発者から見ると、これはクレイジーだね。Erlang / Elixirでは、関数が一般的に {:ok, result} や {:error, description_or_struct} のタプルを返すことで問題が解決されるんだ。これに加えて、Elixirのwith文を使うことで、エラーハンドリングを下にまとめることができて、読みやすさが格段に向上する。Goもwith句の同等のものを追加すれば、エラーがnilの間は関数を続けて、エラーハンドリングの句を下に置くことができるんじゃないかな。

Goの複数の戻り値って、個人的にはすごく変だと思う。複数の戻り値を持つ関数では、変数に代入する以外に何もできないからね。

Rustの「?」が文法的に言及されてるのは変だけど、Goにsum typesが来るって話は全然出てこないのが不思議。Goの冗長なエラーハンドリングとsum typesがないことが、私の言語に対する唯一の不満なんだ。RustのResult型をモデルにして、両方の問題が解決されるといいな。

[遅延]