エラー処理のための構文サポート[賛成 | 反対]

[ On | No ] syntactic support for error handling by Robert Griesemer

Goに対する不満の中でも特に古くからあり、根強く言われ続けているものの一つが、エラー処理の冗長さです。 私たちは皆、次のようなコードパターンをよく知っています(人によっては、痛いほどよく知っていると言うかもしれません)。

x, err := call()
if err != nil {
        // errの処理
}

if err != nil という検査があまりにも至るところに現れるため、残りのコードがかき消されてしまうことすらあります。 これは、APIの呼び出しを大量に行い、エラー処理が単純にそのまま返すだけの初歩的なものであるプログラムでよく起こります。 中には次のようなコードになってしまうプログラムもあります。

func printSum(a, b string) error {
    x, err := strconv.Atoi(a)
    if err != nil {
        return err
    }
    y, err := strconv.Atoi(b)
    if err != nil {
        return err
    }
    fmt.Println("result:", x + y)
    return nil
}

この関数本体の10行のコードのうち、実質的な処理をしているように見えるのはわずか4行(呼び出し部分と最後の2行)だけです。 残りの6行はノイズのように見えます。この冗長さは紛れもない事実であり、エラー処理に関する不満が毎年の利用者調査で 長年にわたり首位を占め続けているのも不思議ではありません。(一時期はジェネリクスの欠如がエラー処理への不満を 上回っていましたが、Goがジェネリクスをサポートした今、エラー処理が再び首位に返り咲いています。)

Goチームはコミュニティからのフィードバックを真摯に受け止めており、長年にわたって、Goコミュニティからの意見も 取り入れながらこの問題の解決策を見出そうと試みてきました。

Goチームによる最初の明確な取り組みは2018年に遡ります。当時「Go 2」と呼んでいた取り組みの一環として、 Russ Coxがこの問題を正式に記述しました。 彼はMarcel van Lohuizenによるドラフト設計 に基づいて、考えられる解決策の概要を示しました。この設計は checkhandle という仕組みに基づいており、 かなり包括的なものでした。この草案には、他の言語で採られているアプローチとの比較も含めて、代替となる解決策についての 詳細な分析が含まれています。自分の考えたエラー処理のアイデアが以前に検討済みかどうか気になる人は、 ぜひこの文書を読んでみてください。

// 提案されたcheck/handle機構を使ったprintSumの実装。
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
}

checkhandle によるアプローチは複雑すぎると判断され、ほぼ1年後の2019年に、私たちはずっと単純化した、 そして今となっては悪名高い try 提案を発表しました。 これは checkhandle の考え方に基づいていますが、疑似キーワードだった check は組み込み関数 try になり、 handle の部分は省かれました。 try という組み込み関数の影響を調べるために、既存のエラー処理コードを try を使った形に書き換える簡単なツール(tryhard)を作成しました。 この提案は激しく議論され、GitHubのissueへのコメントは900件近くにのぼりました。

// 提案されたtry機構を使ったprintSumの実装。
func printSum(a, b string) error {
    // returnする前にエラーに情報を追加するためdefer文を使う
    x := try(strconv.Atoi(a))
    y := try(strconv.Atoi(b))
    fmt.Println("result:", x + y)
    return nil
}

しかし try は、エラーが発生した場合に囲んでいる関数からリターンするという形で制御フローに影響を与え、 しかもそれが深くネストされた式の中から行われることもあったため、この制御フローが見た目から隠されてしまいました。 これが多くの人にとって受け入れがたいものとなり、この提案に相当な労力を注いだにもかかわらず、私たちはこの取り組みも 断念することにしました。振り返ってみると、新しいキーワードを導入する方が良かったかもしれません。これは今なら、 go.mod ファイルとファイル単位のディレクティブによって言語バージョンをきめ細かく制御できるので、実現可能なことです。 try の使用を代入文と文に限定していれば、他の懸念のいくつかは和らいでいたかもしれません。Jimmy Frascheによる 最近の提案は、本質的には元の checkhandle の設計に立ち返りつつ、 その設計の欠点のいくつかに対処するもので、この方向性を追求しています。

try 提案の余波は、Russ Coxによる一連のブログ記事 「Thinking about the Go Proposal Process(Goの提案プロセスについて考える)」 を含む、多くの内省をもたらしました。得られた結論の一つは、コミュニティからのフィードバックの余地がほとんどない、 ほぼ完成された提案を、しかも「脅迫的」とも言える実装スケジュールとともに提示したことで、より良い結果を得る機会を 自ら狭めてしまった可能性が高いというものでした。 「Go Proposal Process: Large Changes(Goの提案プロセス:大きな変更について)」 によれば、「振り返ってみれば、try は十分に大きな変更だったので、私たちが公開した新しい設計は[中略]実装スケジュール付きの 提案ではなく、2番目の草案設計とすべきだった」とされています。しかし、このケースにおけるプロセスやコミュニケーションの 失敗の有無にかかわらず、この提案に対する利用者の感情は非常に強く否定的でした。

当時の私たちにはより良い解決策がなく、その後数年間、エラー処理に関する構文の変更は追求しませんでした。 とはいえ、コミュニティの多くの人々が触発され、互いによく似たものが多いものの、中には興味深いもの、理解不能なもの、 実現不可能なものも含め、エラー処理に関する提案が絶えることなく寄せられ続けました。広がり続けるこの状況を把握するために、 さらに1年後、Ian Lance Taylorがエラー処理改善のための提案の現状をまとめる包括issue (umbrella issue)を作成しました。関連するフィードバックや議論、記事を集めるための Go Wikiも作られました。それとは別に、他の人々も長年にわたる エラー処理提案の数々を独自に記録し始めました。たとえばSean K. H. Liaoのブログ記事 「go error handling proposals」を見ると、 その数の多さには驚かされます。

エラー処理の冗長さに対する不満は根強く残り(Go Developer Survey 2024 H1 Resultsを参照)、 Goチーム内部で提案を練り直す作業を重ねた末、Ian Lance Taylorは2024年に 「? を使ってエラー処理の定型コードを減らす」を発表しました。今回のアイデアは、 Rustで実装されている構文、具体的には ? 演算子を借用するというものでした。 すでに確立された記法を持つ既存の仕組みに乗ることと、これまでの年月で得た教訓を踏まえることで、ついに前進できるのでは ないかという期待がありました。? を使ったGoのコードをプログラマに見せる小規模な非公式のユーザー調査では、 参加者の大多数がコードの意味を正しく推測でき、これがもう一度挑戦してみようという確信をさらに強めました。 この変更の影響を確認できるように、Ianは通常のGoコードを提案された新しい構文を使ったコードに変換するツールを 作成し、私たちはコンパイラでもこの機能を試作しました。

// 提案された「?」文を使ったprintSumの実装。
func printSum(a, b string) error {
    x := strconv.Atoi(a) ?
    y := strconv.Atoi(b) ?
    fmt.Println("result:", x + y)
    return nil
}

残念ながら、他のエラー処理のアイデアと同様に、この新しい提案もすぐに大量のコメントで埋め尽くされ、個人の好みに 基づくものが多い、細かな修正の提案が数多く寄せられました。Ianはこの提案をクローズし、議論をしやすくし さらなるフィードバックを集めるために、内容をdiscussionへと移しました。 少し手を加えたバージョンは やや好意的に受け止められましたが、 広範な支持を得るには至りませんでした。

これほど長年試みを重ね、Goチームによる3つの本格的な提案と、文字通り数百(!) にものぼるコミュニティからの提案(その大半は同じテーマのバリエーションです)がありながら、そのいずれもが 十分な支持を得ることに失敗した(圧倒的な支持どころではありません)今、私たちが直面している問いはこうです。 どう進めるべきか。そもそも進めるべきなのか。

私たちは、そうは思いません。

より正確に言うと、少なくとも当面の間は、 構文上の問題 を解決しようとすることをやめるべきだと考えています。 この決定については提案プロセスがその正当性を裏付けています。

提案プロセスの目標は、適切な時間内に結果について大方の合意に達することです。提案のレビューにおいて、 issueトラッカー上の議論から大方の合意を見出せない場合、通常はその提案は却下されます。

さらに、次のように続けています。

提案のレビューで大方の合意が見出せない場合でも、その提案を即座に却下すべきではないことが明らかな場合が あります。[中略]提案レビューグループが合意にも次のステップにも至らない場合、今後の方針の決定はGoアーキテクトに 委ねられ[中略]、彼らが議論を検討し自分たちの間で合意に達することを目指します。

エラー処理に関するどの提案も、合意に近づくことすらありませんでした。そのため、そのすべてが却下されました。 GoogleにいるGoチームの最も古参のメンバーでさえ、 現時点で どちらの方向に進むのが最善かについて意見が 一致していません(いずれ状況は変わるかもしれません)。しかし、強い合意がない以上、私たちは道理にかなった 形で前に進むことはできません。

現状維持を支持する妥当な論拠もあります。

  • もしGoが早い段階でエラー処理専用の糖衣構文を導入していたら、今日それについて議論する人はほとんど いなかったでしょう。しかし、私たちは15年もの道のりを歩んできており、その機会はすでに過ぎ去ってしまいました。 そして、時に冗長に見えることはあっても、Goには十分にうまく機能するエラー処理の方法がすでにあります。

  • 別の角度から見てみましょう。仮に今日、完璧な解決策に出会えたとします。それを言語に組み込んだとしても、 それは単に、不満を持つ利用者の集団を一方(変更を望んでいた側)から他方(現状維持を望む側)へと 入れ替えるだけに終わってしまうでしょう。ジェネリクスを言語に追加すると決めたときも私たちは似たような 状況にありましたが、一つ重要な違いがありました。今日、誰もジェネリクスの使用を強制されておらず、 優れたジェネリックなライブラリは、型推論のおかげで、利用者がそれがジェネリックであるという事実を ほとんど意識せずに済むように書かれています。それとは対照的に、もしエラー処理のための新しい構文が 言語に追加されれば、コードが慣用的でなくなってしまわないようにするために、事実上すべての人がそれを 使い始める必要に迫られるでしょう。

  • 余分な構文を追加しないことは、Goの設計原則の一つ、すなわち「同じことをする方法を複数用意しない」という 原則にも合致しています。この原則には、利用頻度の高い箇所では例外があり、代入がその一例です。皮肉なことに、 短い変数宣言(:=)において変数の 再宣言 を許す機能は、エラー処理に起因して生じた問題に対処するために 導入されたものでした。再宣言がなければ、一連のエラーチェックのたびに、それぞれ別の名前の err 変数 (あるいは別途の変数宣言)が必要になってしまいます。当時、より良い解決策は、エラー処理により多くの 構文サポートを与えることだったかもしれません。そうすれば再宣言のルールは不要だったかもしれませんし、 それがなくなれば、それに付随するさまざまな複雑な問題もなくなっていたでしょう。

  • 実際のエラー処理のコードに話を戻すと、エラーが実際に 処理 されているのであれば、冗長さは背景に 退いていきます。優れたエラー処理では、しばしばエラーに追加の情報を付加する必要があります。たとえば、 利用者調査で繰り返し挙がるコメントの一つに、エラーに紐づくスタックトレースがないというものがあります。 これは、拡張されたエラーを生成して返す補助関数によって対処できます。次の(正直なところ多少作為的な) 例では、定型コードの相対的な量はずっと小さくなります。

    func printSum(a, b string) error {
        x, err := strconv.Atoi(a)
        if err != nil {
            return fmt.Errorf("invalid integer: %q", a)
        }
        y, err := strconv.Atoi(b)
        if err != nil {
            return fmt.Errorf("invalid integer: %q", b)
        }
        fmt.Println("result:", x + y)
        return nil
    }
    
  • 標準ライブラリの新機能によっても、エラー処理の定型コードを減らせます。これはまさに、 Rob Pikeが2015年に書いたブログ記事「エラーは値」の精神に沿ったものです。 たとえば、場合によっては一連のエラーをまとめて扱うために cmp.Or を 使うこともできます。

    func printSum(a, b string) error {
        x, err1 := strconv.Atoi(a)
        y, err2 := strconv.Atoi(b)
        if err := cmp.Or(err1, err2); err != nil {
            return err
        }
        fmt.Println("result:", x+y)
        return nil
    }
    
  • コードを書くこと、読むこと、デバッグすることは、それぞれかなり異なる作業です。繰り返しのエラーチェックを 書くのは面倒に思えるかもしれませんが、今日のIDEはLLMによる支援も含め、強力なコード補完機能を提供しています。 基本的なエラーチェックを書くこと自体は、こうしたツールにとっては簡単な作業です。冗長さが最も目立つのは コードを読むときですが、ここでもツールが役立つかもしれません。たとえば、Go言語向けの設定を持つIDEであれば、 エラー処理のコードを隠すトグルスイッチを提供できるでしょう。こうしたスイッチは、関数本体など他のコード部分に 対してはすでに存在しています。

  • エラー処理のコードをデバッグする際には、すぐに println を追加できたり、デバッガでブレークポイントを 設定するための専用の行やソース上の位置があったりすると役立ちます。すでに専用の if 文があれば、これは 簡単です。しかし、すべてのエラー処理のロジックが checktry、あるいは ? の裏に隠れている場合、 デバッグのためにはまずコードを通常の if 文に書き換える必要が生じるかもしれず、それがデバッグを複雑にし、 さらには微妙なバグを生み出しかねません。

  • 現実的な考慮事項もあります。エラー処理のための新しい構文のアイデアを思いつくこと自体は安上がりです。 だからこそコミュニティから数多くの提案が生まれ続けています。しかし、精査に耐えうる優れた解決策を 考え出すことは、それほど簡単ではありません。言語の変更を適切に設計し、実際に実装するには、集中的な 取り組みが必要です。そして本当のコストはその後にやってきます。変更しなければならないすべてのコード、 更新しなければならないドキュメント、調整しなければならないツール群です。これらすべてを踏まえると、 言語の変更は非常にコストがかかり、Goチームは比較的小規模であり、対処すべき他の優先事項も数多くあります。 (これらの後者の点は変わりうるものです。優先順位は移り変わりますし、チームの規模も増減しうるからです。)

  • 最後に一点。私たちの何人かは最近、Google Cloud Next 2025に 参加する機会があり、そこではGoチームがブースを出展し、小規模なGoのミートアップも主催しました。 そこで話を聞くことができたGoの利用者は誰もが、より良いエラー処理のために言語を変更すべきではないと 強く主張していました。多くの人が、Goに専用のエラー処理サポートがないことが最も気になるのは、 そうしたサポートを持つ他の言語から移ってきたばかりのときだと述べていました。より習熟し、より慣用的な Goのコードを書くようになるにつれて、この問題ははるかに重要でなくなっていきます。もちろん、これは 代表性を持つと言えるほど十分に大きな集団ではありませんが、GitHub上で目にする人々とは異なる層である 可能性があり、彼らのフィードバックはまた一つの判断材料となります。

もちろん、変更を支持する妥当な論拠もあります。

  • より良いエラー処理サポートの欠如は、私たちの利用者調査において依然として不満のトップであり続けています。 Goチームが本当に利用者のフィードバックを真剣に受け止めているのであれば、いずれはこれに対して何かしらの 対応をすべきでしょう。(もっとも、 言語の変更に対しても圧倒的な支持があるようには見えませんが。)

  • 文字数を減らすことだけに焦点を当てるのは、もしかすると見当違いなのかもしれません。より良いアプローチは、 定型コード(err != nil)を取り除きつつも、標準的なエラー処理をキーワードによって高く可視化しておく ことかもしれません。そうすることで、読み手(コードレビュアー!)が「二度見」することなくエラーが 処理されていることを見て取りやすくなり、結果としてコードの品質と安全性の向上につながるかもしれません。 これは、checkhandle の原点に立ち返ることを意味します。

  • この問題が、エラーチェックの単純な構文上の冗長さによるものなのか、それとも、APIの有用な一部となり 開発者にもエンドユーザーにも意味のあるエラーを組み立てるという、優れたエラー処理の冗長さによるものなのか、 私たちには実のところよくわかっていません。これは今後さらに深く検討していきたい点です。

それでもなお、これまでにエラー処理へ対応しようとした試みは、どれも十分な支持を得られていません。 正直に現状を振り返ってみると、私たちには問題についての共通理解もなければ、そもそも問題が存在するという ことについての全員の合意すらないと認めざるを得ません。これを踏まえて、私たちは次のような現実的な決定を下します。

当面の間、Goチームはエラー処理のための構文上の言語変更を追求することをやめます。また、エラー処理の構文を 主な論点とする、現在オープンになっている提案および今後寄せられる提案についても、それ以上の検討を行うことなく すべてクローズします。

コミュニティは、これらの問題を探求し、議論し、論じ合うために多大な労力を注いできました。それがエラー処理の 構文の変更という結果には結びつかなかったとしても、こうした取り組みはGo言語や私たちのプロセスにおける他の 多くの改善につながっています。もしかすると、いつか将来、エラー処理について、より明確な展望が見えてくるかも しれません。それまでの間、私たちは、この素晴らしい情熱が、Goをすべての人にとってより良いものにするための 新しい機会に向けられていくことを楽しみにしています。

ありがとうございました!

By Robert Griesemer