- Goの
if err != nil の繰り返しは、長年にわたりユーザー調査で大きな不満として残っていたが、Goチームは エラー処理構文の変更 を当面進めないことにした
- 2018年の
check/handle、2019年の try、2024年の ? 提案はいずれも十分な合意を得られず、特に try は 隠れた制御フロー のため強い反発を受けた
- Goの提案プロセスでは一般的な合意がなければ提案は通常却下され、GoogleのGoチームのシニアメンバーの間でも現在の 最善の方向性 について全会一致に至っていない
- 現状維持を支持する側は、Goにはすでに機能しているエラー処理方式があり、新しい構文はコードスタイル・デバッグ・ドキュメント・ツール・既存コードに大きなコストをもたらすと見ている
- Goチームは、エラー処理構文を主対象とする 既存の提案と今後の新規提案 を追加調査なしで閉じ、より明確な問題認識が生まれるまでは別の改善機会に集中する
if err != nil が生んだ長年の不満
- Goで最も長く続いている不満のひとつは、エラー処理コードの 冗長さ である
- 代表的なパターンは次の形である
x, err := call()
if err != nil {
// handle err
}
- API呼び出しが多く、エラーを単に返すだけのプログラムでは、
if err != nil がコードの残り部分を圧倒してしまうことがある
- 例の関数
printSum では、関数本体10行のうち実際の処理に見える行は呼び出し・出力・返却を含めて4行で、残り6行はノイズのように見える
- Goの年次ユーザー調査では、エラー処理は長年にわたり最上位の不満だった。一時期はジェネリクス不在がこれを上回っていたが、Goがジェネリクスをサポートした後は再びエラー処理が最上位の不満に戻った
3回の主要な構文提案
- Goチームの最初の明示的な試みは、2018年のGo 2の取り組みの一環として Russ Cox が問題を 公式に整理 したことから始まった
- Marcel van Lohuizen の ドラフト設計 は
check と handle の仕組みに基づいており、他言語のアプローチや代替案の分析も含んでいた
func printSum(a, b string) error {
handle err { return err }
x := check strconv.Atoi(a)
y := check strconv.Atoi(b)
fmt.Println("result:", x + y)
return nil
}
check/handle アプローチは複雑すぎると判断され、2019年にはより単純な try 提案 が出された
check に似たキーワードは try 組み込み関数になった
handle 部分は省かれた
- 既存のエラー処理コードを
try 方式に変換するツール tryhard が作られた
- 関連する GitHub issue では900件近いコメントで激しい議論が行われた
func printSum(a, b string) error {
// use a defer statement to augment errors before returning
x := try(strconv.Atoi(a))
y := try(strconv.Atoi(b))
fmt.Println("result:", x + y)
return nil
}
try はエラーが起きると囲んでいる関数から返る方式で 制御フロー に影響し、深くネストした式の中でも return が発生し得るため、多くのユーザーには受け入れにくかった
- 当時は新しいキーワードを導入するほうがよかった可能性があり、現在では
go.mod ファイルとファイル単位のディレクティブで言語バージョンを細かく制御できる
- Jimmy Frasche の 最近の提案 は、元の
check/handle 設計に戻りつつ一部の欠点に対処する方向を取っている
try 以後の手続き面の振り返りと ? 提案
func printSum(a, b string) error {
x := strconv.Atoi(a) ?
y := strconv.Atoi(b) ?
fmt.Println("result:", x + y)
return nil
}
- この提案もすぐに多くのコメントと、好みベースの細かな修正提案であふれ、Ianは提案を閉じたうえで内容を discussion に移した
- わずかに修正された版は やや前向きな反応 を得たが、幅広い支持は得られなかった
なぜ今は止まろうとしているのか
- Goチームは、予見可能な将来においてはエラー処理の 構文上の問題 を解決しようとする試みを止めるべきだと判断した
- 提案プロセス がこの判断を支えている
- 提案プロセスの目標は、適時に結果について一般的な合意へ到達することにある
- issue tracker 上の議論で一般的な合意が見つからなければ、提案は通常却下される
- 合意も次の一手も見つからない場合、Go architects が議論をレビューし、内部合意を試みる
- どのエラー処理提案も合意に近い支持を得られず、すべて却下された
- GoogleのGoチームのシニアメンバーでさえ、現在の 最善の進め方 について全会一致ではなく、強い合意なしに合理的に進めることはできない
現状維持と変化の論拠
- 現状維持側には、Goの成熟度とエコシステムコストという現実的な理由がある
- Goが初期にエラー処理専用の構文糖衣を導入していたなら、今ほど議論は少なかったかもしれないが、Goはすでに15年が経ち、すでに機能しているエラー処理方式を持っている
- 今、完璧な解決策を見つけたとしても、不満を抱く側が変更支持者から現状維持支持者へ入れ替わるだけかもしれない
- ジェネリクスはユーザーが必ずしも自分で書く必要はないが、エラー処理の新構文は使わなければコードが非イディオマティックに見える可能性があり、事実上ほとんどの人が使うことになる
- 同じことを行う方法を複数提供しないというGoの設計原則とも、新構文の追加は衝突しうる
:= による短い変数宣言の 再宣言 機能は、エラー処理によって生じた問題を解決するために導入された
- 再宣言がなければ、連続するエラーチェックごとに異なる
err 名や別個の変数宣言が必要だった
- 当時エラー処理によりよい構文サポートがあれば、再宣言ルールやそれに伴う複雑さも不要だったかもしれない
- エラーを適切に補強して処理すれば、単純な繰り返しの比重は減る
- ユーザー調査では、エラーにスタックトレースがないという繰り返しの意見がある
- 補助関数で補強したエラーを作って返す方式が可能である
fmt.Errorf("invalid integer: %q", a) のように入力値の情報を付ければ、ボイラープレートの相対的な比重は小さくなる
- 標準ライブラリの機能でもエラー処理のボイラープレートを減らせる
- 書くこと、読むこと、デバッグすることはそれぞれ異なる活動である
- 繰り返しのエラーチェックを書くのは退屈だが、IDEやLLMによる補助コード補完で基本的なエラーチェックは簡単に生成できる
- 読む際には冗長さがより目立ち、IDEがエラー処理コードを隠すトグルを提供できるかもしれない
- デバッグ時には、すでに独立した
if 文があれば println の追加やブレークポイント設定がしやすい
check、try、? の後ろにエラー処理が隠れると、通常は if 文へ戻して考える必要があり、この過程がデバッグを複雑にしたり微妙なバグを生んだりする可能性がある
- 言語変更には、設計・実装だけでなく既存コードの変更、ドキュメント更新、ツール調整のコストも伴う
- Goチームは比較的小規模で、対処すべき他の優先事項も多い
- 優先順位やチーム規模は変わりうる
- Google Cloud Next 2025 でGoチームが会った一部のGoユーザーは、より良いエラー処理のために言語を変えるべきではないと強く述べた
- 彼らは他言語から移ってきた直後は、Goに専用のエラー処理構文がないことが最も目につくが、よりイディオマティックなGoコードを書くようになると重要性は下がると述べた
- このサンプルは代表性を持つほど大きくはないが、GitHubで見られる人々とは異なる集団かもしれない
- 変化を支持する論拠も依然として有効である
- より良いエラー処理サポートの欠如は、ユーザー調査で依然として最上位の不満として残っている
- 文字数を減らすことだけに集中するアプローチは、誤った方向かもしれない
- 基本的なエラー処理をキーワードで目立たせつつ
err != nil のボイラープレートを除去できれば、コードレビューでエラー処理の有無をより確認しやすくなる
- 問題の本質が単なる構文の冗長さなのか、それともAPIや開発者・エンドユーザーにとって意味のあるエラーを構成する「良いエラー処理」の冗長さなのかは、まだ十分に分かっていない
Goチームの決定
- これまでエラー処理を扱おうとしたどの試みも、十分な推進力を得られなかった
- Goチームは、共有された問題理解が不足しており、そもそも問題が存在するかどうかについてさえ全員が同意していないと判断している
- 予見可能な将来において、エラー処理のための 構文的な言語変更 は進めない
- エラー処理構文を主対象とする既存の提案と今後提出される提案は、追加調査なしで閉じる予定である
- コミュニティによる探索と議論は、エラー処理構文の変更にはつながらなかったが、Go言語とプロセスのさまざまな改善にはつながった
1件のコメント
Hacker Newsの意見
Goチームに「こうすればよかったのに」という類いの提案を軽く投げたいなら、まず記事でリンクされているWikiページ https://go.dev/wiki/Go2ErrorHandlingFeedback と、GitHubのIssue検索 https://github.com/golang/go/issues?q=+is%3Aissue+label%3Aerror-handling を見てほしい
いま提案しようとしているものは、ほぼ間違いなく初出ではなく、その多くはすでに深く検討されている可能性が高い
Goチームのこうした率直な姿勢は好ましく思っているし、今でも仕事で毎日Goを楽しく使っている
コメント数の多いGitHub Issueを数ページ見ても似たものは見当たらず、Goチームが言語設計の魔法使いだから、ここにいる人たちが軽く投げる解決策は当然検討済みだろう、と見るのは無理がある
GoチームはJavaと同じ過ち、つまり静的型付けでありながらパラメトリック多相性がなかったという問題を犯したし、このエラー処理問題の根もそこにあるのに、手を上げて直そうとしていないように見える
大げさで威圧的に聞こえるかもしれないが、エラーを処理可能な場所まで伝播させつつ、忘れられないようにする優雅で関数的に純粋な方法だ
Haskellのコードを書く人たちにはあまりに深く根付いたやり方なので、Goコミュニティにこれを知っていて好む人がいなかったというのは理解しがたい
ページ自体とリンクには感謝しているが、自分たちの言語をそこまで気にかける人たちが、これほど確立された解法を飛ばしたのは混乱させられる
ほぼすべての言語がそれぞれより良い方法を持っているのに、単に決められない、あるいは全員を満足させられないからなのか、それともGoという言語自体に他言語の解法が合わない具体的な理由があるのか知りたい
実際には、ほぼすべての場合で彼らのほうがはるかに多くを知っている
アマチュアは、機能を最も多く詰め込んだ言語が最高だと素朴に考えがちで、特に自分好みの機能が入っていればなおさらそうだと思う
ナイフ作りを始めたばかりの人が和包丁を見て物足りないと感じ、指用の溝、秘密の収納スペース、ライター、Bluetoothスピーカー付きの3Dプリント製ハンドルを付ければもっと良くなると考えるようなものだ
チェックボックスの一覧を作り、各項目を議論して埋め、致命的な意味論上の誤りや健全性の穴が見つからない限り、再び外さなければよい
全部埋まったら実装し、
.awaitで書くのか/awaitで書くのか.await!()で書くのかと騒いでいた人たちはまた姿を消すRustはこのように進んでいて、Issueによっては10年以上遅れることもあるが、最終的には項目が埋まり、最新のnightlyで安定化される
Goが、誰もがすぐにぶつかる単一の問題について、完成度の高い提案が複数あるにもかかわらず、そのうち一つを選べず、bikesheddingが止まるのを待って解決できないのだとしたら、その手続きは喜劇だ
チェックボックスの要件を満たせばただ追加される機能の寄せ集めではない
RustはC++のようにごちゃごちゃになっているだろうか? Goはリリース当時のように 時代に左右されない言語 のままでいるだろうか?
調査では エラー処理 に言及した割合は13%で、今の方式のままを好む人たちもいる
https://go.dev/blog/survey2024-h1-results
以前、内部関数がエラーを返すことを期待する特殊な Go 関数を書いたことがある
そのため、内部関数がエラーを返さなければ外側の関数がエラーを返して別の処理をしなければならず、内部関数がエラーを返した場合は nil を返す必要があった
要約すると、
if err != nil { ... }ではなくif err == nil { // return an error }を使うべきだったのに、習慣で前者を書いてしまい、デバッグにかなり時間がかかったif err != nilにあまりにも鈍感になっていて、脳がその構文が入ってはいけない可能性をそもそも考慮しなかったからだだから、よくある表現には構文糖が必要だと思う。極めて一般的な
if err != nilと、まれなif err == nilの違いがもっと目立っていれば、実際に役に立ったはずだif err == nilを書くたびに// invertedとコメントして目立つようにしている言語レベルで扱われるとよいが、少なくともより見えやすくする方法として共有する
if fruit != "Apple" { ... }でも同じ状況は作れるこれを改善する一般的な解法があるのか気になるし、これをエラー処理だけの問題と見るのは少し的外れな気がする
エラーに特別または固有な点はなく、ほかと同じ状態にすぎない
よくある
if err == nil { return ... }パターンが生まれると、今度はそれがコード中にあふれることになるからだ現在の解法は問題なく、主に Go を始めたばかりの人や初級者が嫌っているように見える
周囲では、明示的で明確で読みやすい「冗長な」エラー処理を好んでいる
if err != nilを単一の小さな合字記号のように強調したり、背景として薄くレンダリングしたりできるそうすれば
if err == nilのように、まさにその文字列と異なる形はかえって目立つようになるif err … {のような折りたたみ表記で解決できそうにも見えるGo の明示的なエラー処理は気に入っている
関数は常に成功するか、成功または失敗し得る。常に成功する関数は単純で、失敗し得る関数が失敗した場合、外側のコードは失敗状態のまま進めないので処理しなければならない
ここで言語ごとに分かれる。多くの言語は例外を投げ、誰かが明示的に捕捉するまで上へ伝播させ、ある種のスタックトレースを提供する
Go では、コードを書くときに常に選ぶべき選択肢がある点がよい。エラーを無視して進める(
foo, _ := doSomething())、意味のある情報なしに早期リターンする(return nil, err)、有用な文脈を付けて早期リターンする、受け取ったエラーを解釈して分岐する、などだ例えばデータベースで更新対象の行が見つからなければ、サービス層が not found エラーを返して API では 404 になったり、冪等な削除関数が not found を成功として解釈したりできる
Go 2 や別の言語なら、nil になり得るタプルの代わりに Rust/Swift 式のResult 型と、常に
errorを直接使うのではなく、よりよく型付けされ列挙可能なエラー型があるとよいただし Go 1 の慣用的なタプル返却の上に Result を追加すると、同じことをする方法が複数生まれて混乱と分断を招くため、Go 2 や新しい言語のほうが向いている
スタックの低い層はたいてい何をすべきか分からないので、エラーを処理すべきではない
エラーを処理するという方針は、結局エラーをラップしてスタックの上へ返し直す方針になりがちで、かなりの雑務になる
Go はエラーを完全に無視できるようにしており、その結果クラッシュにつながることがある
堅牢なソフトウェアを作るための要件を正確に指摘していながら、Go のエラー処理方式を好むというのは少し理解しにくい
[1]: https://borgo-lang.github.io/ | https://github.com/borgo-lang/borgo
すべての関数型言語、Rust のような多くの現代的な言語、さらには checked exception を持つ Java もこれを提供している
ジェネリクスのある言語なら Go 式の「エラー処理」はたいてい再現でき、おそらくより良いコードになるはずだ
答えが JavaScript や Python なら、それはよくある比較パターンではある
Go にはこれが正しい判断だ。最初に Go に触れたときはエラー処理が嫌いだったが、今では本当に好きになった
きっかけは二つある。https://go.dev/blog/errors-are-values の記事を読んで「エラーは値」という見方をきちんと受け入れ、その基盤の上である程度人気のあるパッケージ https://github.com/stytchauth/sqx も作った
また、本当にあり得ない不正な状態には
panic(err)を少しずつ使うことに慣れてきた親のコードが処理する感覚もないような見当違いの状態を無理にすべて処理させる理由はなく、うまく配置した panic 一つか二つで、コードベース内の何百ものエラーチェックを取り除ける
例えば ctx の中に基本ロガーがあるか、といった問題を考えられる
むしろ改善される可能性が高い
bash にも
-eがある昔 C# を初めて使ったときは、try/catch/finally の流れ、using、ネスト、catch でエラーが起きたらどうなるか、finally で起きたらどうなるかを理解するのが賢いことだと思っていた
今は、そういうことを考えないほうがよい
この記事が、Goのエラー処理の主な問題は構文があまりに冗長なことだ、と言っているように見える点が気に入らない。それはあまり気にしていない
もっと重要なのは、エラーが黙って捨てられたり、うっかり無視されたりし得ること、関数呼び出しの結果が値ではないため簡単に保存したり渡したりできないこと、
errors.Isが必要で、「ネスト」されたエラー全体が型システムとうまく合わない奇妙なランタイム上の仕組みだという点エラーに対するswitchも難しく、標準ライブラリはsentinel値を使い、ジェネリクスとの相互作用もよくないため、errgroupのようなパッケージが必要になる
ほかに見落としているものはあるだろうか?
例外のある言語なら、誰もそんなことはしなかったはず
予測可能な将来においては、エラー処理構文を変えようとする試みをこれ以上しないことにしたので、そのおかげでエラーであれ他の話題であれ、別の問題に目を向ける余裕が生まれる
Goの進化は氷河のように遅く、多くの人にとってそれはバグではなく機能
「エラーにスタックトレースがないというアンケートの意見は、補助関数が補強されたエラーを作って返すようにすれば解決できる」というような説明はおかしい
if err != nil { return fmt.Errorf("invalid integer: %q", a) }のようにスタックトレースを手動で提供することを「エラー処理」と呼ぶのは面白いGoチームの定義では、例外はエラーを自動的に処理してくれることになる。もちろんC++を除く言語での話だが
そうかもしれないが、本当にそのすべてが必要なのか?ログのコストはどうなのか?
フレームワークやランタイムのノイズを切り落とした、1行のラップされたエラーのほうがずっと良いと思う
うまくラップすれば検索もしやすく、たいていはスタックトレースより効果的に追跡できる
Goを10年以上フルタイムで使ってきたが、ランタイム関数やコールスタックの冗長なノイズが必要だったことは一度もない
Elixir開発者の視点では、これは正気とは思えない
Erlang/Elixirでは、関数が通常
{:ok, result}または{:error, description_or_struct}のタプルを返すことで解決されるこれにElixirの
with文を使うと、エラー処理を下のほうにまとめられるので、はるかに読みやすくなるGoも
with節に相当するものを追加して、エラーがnilである間は関数群を進め、下のほうにエラー処理節を置けばよいwith文を採用する可能性はまったくなさそうGoはジェネリクス、エラー処理、パッケージ管理のような基本的で明らかに価値のある構造を、コミュニティの合意不足を理由に非常に長く先送りする点が興味深い
ジェネリクスはオープンソース公開後13年かかり、16年経った今もエラー処理はなく、パッケージ管理には約9年かかった
熟考にも価値はあるが、リリースにも価値はある。GitHubコメントを900件書く人たちも結局Goを使い続けるだろうし、言語に何かが入るほうが、先送りし続けるより良かった可能性が高い
戻り型が複数ある関数は、変数に代入すること以外にできることがない
しかしErlangとElixirでは、何の重荷もなく完全に慣用的なやり方
実際にはML系よりずっと強力でもある。そちらのsum typeは開いているから
この議論を詳しく追っていたわけではないが、なぜ Rust 式のやり方をそのまま採用しないのか分からない
Go にジェネリクスが入った後、自分がすぐ追加するようになった方法もそれだ
リンク先の記事では「Rust には handle に相当するものがなく、
?演算子の便利さは適切な処理を省略させる可能性がある」という説明だけが見えるしかし、便利であることがエラーを無視するという意味なのかは理解できない
Go 方式の問題の半分は、結果について何も強制せず、エラーチェックも最低限しか強制しない点にある
x, err := strconv.Atoi("123"); fmt.Println("result:", x)はdeclared and not used: errになるが、2 回目の変換の後でerrを確認しなくても、yのゼロ値 0 のせいで問題に気づかないまま正常に実行されてしまうことがあるif err != nil { }のように空にしてもコンパイルされ実行されるので、何かがおかしいとは分からない戻り値を Result にすれば、判断を下さなければならないことが強制される。誰かが
!を乱用したり、?で楽に上へ投げてエラーケースを処理しないとしても、それならpanicも禁止するのか?そして、すべての型に定められたゼロ値がなければならないという奇妙な執着のために、合併型も追加できない
?が使いやすいと、誰ももうエラーをラップしなくなる、という意味だと理解しているかなり疑わしい論理だ
そもそも
?がエラーのラップを促すように設計すればよい例えば Rust の
Fromに相当するものは、Go ではどのような姿であるべきなのか??は視認性が低く、制御フローの分岐を 1 つの文や式の中に隠してしまうGo が三項演算子をなくし、各分岐が別々の行にある
if文を選んだ理由の一つもそれだブレークポイントも置きにくく、エラーを補強したり処理したりするより、そのまま上へ渡すことを好ませる
:=は単一文での宣言および代入だと思っていたが、例の 5 行目でerrを再宣言し、新しいerrが既存のerrを隠すのではないか?そうなら、新しい
err変数が使われていないのでdeclared and not used: errで失敗するはずだと思うそれとも、変数がすでにある場合は
:=が単に通常の代入のように動作するのか?「エラーにスタックトレースがない点は、補助関数が補強されたエラーを作って返すようにすれば解決できる」という話は、現実を楽観しすぎている
スタックトレースがある言語はこれをただで提供してくれるが、Go では毎回実装しなければならない
自分は常に詳細情報を付ける規律ある開発者かもしれないが、チームメンバー全員が同じ規律を持っているわけではない
スタックトレースの一番よいところは、エラーまでの 呼び出し経路 を示してくれることだ
複数の場所から呼ばれるメソッドでエラーが起きた場合、スタックトレースがあればどの経路を通ったのかすぐに分かる
何年もの間 sysadmin/SRE のような仕事をしながら多くの問題を解決してきたが、スタックトレースがあるときの簡単な問題は原因が明らかなので 1〜2 分で終わった
Go では、誰かがエラーを補強しなかったり同じエラーメッセージを再利用したりすると、簡単な問題でも推理作業になって時間がかかる