皆さん、いきなりですが、例外処理で消耗していませんか? C++ の時代から慣れ親しんできた「例外」ですが、果たして現在ではいいプラクティスなのでしょうか? この記事では例外をどう扱うかについて、今までの実践と、Go の革新的アプローチ、そしてそれを C# のようなトラディショナルな例外処理をする言語でどう導入するかについて議論します。
例外処理・今までのパターン
以下は、M365 Copilot が生成した例外処理をするコードの例に、自前で例外を投げるコードを足したものです。
using System;
class Program
{
static void Main()
{
try
{
Console.WriteLine("割り算をします。分子を入力してください:");
int numerator = int.Parse(Console.ReadLine());
Console.WriteLine("分母を入力してください:");
int denominator = int.Parse(Console.ReadLine());
if (denominator == 0)
{
throw new DivideByZeroException();
}
int result = numerator / denominator;
Console.WriteLine($"結果: {result}");
}
catch (DivideByZeroException ex)
{
Console.WriteLine("エラー: 0で割ることはできません。");
Console.WriteLine($"詳細: {ex.Message}");
}
catch (FormatException ex)
{
Console.WriteLine("エラー: 数値を入力してください。");
Console.WriteLine($"詳細: {ex.Message}");
}
catch (Exception)
{
Console.WriteLine("予期しないエラーが発生しました。");
}
finally
{
Console.WriteLine("処理が終了しました。");
}
}
}
このコードで大事なことは以下の通りです。
- 例外処理には
try句、catch句、そしてオプションでfinally句を使う -
try句には、例外を捕捉したいコードを書く -
catch句には、対応する例外が起こった際のコードを書く。書いた順に捕捉されるため、基底のExceptionは最後に書く必要がある -
finally句には、例外が発生したかどうかにかかわらず行う処理を書く
例では C# を使っていますが、例外処理を実装する言語 (C++, Java, JavaScript など) ではおおむね同じ指針でコードを書くことになるでしょう。
例外の例外、Go
その一方で、独自のアプローチをとったのが Go です。これまでの例外シンタックスを捨てたコードをご覧ください。こちらも M365 Copilot が作成したコードです。
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func main() {
reader := bufio.NewReader(os.Stdin)
fmt.Println("割り算をします。分子を入力してください:")
numeratorStr, _ := reader.ReadString('\n')
numerator, err := parseInt(numeratorStr)
if err != nil {
fmt.Println("エラー: 数値を入力してください。")
fmt.Printf("詳細: %v\n", err)
return
}
fmt.Println("分母を入力してください:")
denominatorStr, _ := reader.ReadString('\n')
denominator, err := parseInt(denominatorStr)
if err != nil {
fmt.Println("エラー: 数値を入力してください。")
fmt.Printf("詳細: %v\n", err)
return
}
result, err := safeDivide(numerator, denominator)
if err != nil {
fmt.Println("エラー:", err)
} else {
fmt.Printf("結果: %d\n", result)
}
fmt.Println("処理が終了しました。")
}
func parseInt(s string) (int, error) {
// 改行や空白を取り除く
s = strings.TrimSpace(s)
return strconv.Atoi(s)
}
func safeDivide(numerator, denominator int) (int, error) {
if denominator == 0 {
return 0, fmt.Errorf("0で割ることはできません")
}
return numerator / denominator, nil
}
Go の基本的な流儀として、エラーは戻り値として返すということが挙げられます。また、例外が発生した箇所で速やかにエラーハンドリングするというのも特徴です。
これができるのも、Go の戻り値を1つに絞らないという仕様の賜物ですね。
例外処理のはらむ問題
では、そもそも Go はなぜ今までのスキーマからわざわざ脱却したのでしょうか? それを探ると既存の例外処理の問題が見えてきます。
- 例外処理は重たい
-
tryをどこまでも敷き詰めてしまいがち - どこで例外が発生したのか、スタックとレースを見ないと分からない
-
tryのネストが深くなり、どこで捕捉するのか分からなくなることもある - フローが分かりづらくなる
- 予見できる例外は例外ではない
- おまけ・例外という言葉は誤訳ともいえる
それでは各項目を見ていきましょう。
例外処理は重たい
一般的に例外を投げるとフローを無視することになるので、処理が重たくなります。そのため、C# には変換できなかった場合に例外を投げる Parse 関数だけではなく、変換ができたかどうかを返す TryParse 関数があるほどです。なお、C# 使いには説明不要と思いますが、実際に変換した数値は out 引数として返すという邪悪な仕様です。もし戻り値が複数持てる仕様だったら Parse のみで十分だったでしょう (伏線)。
例外コストについて調べられた記事があります。
Go はエラーを投げるのではなく、戻り値の1つとして処理するため、フローを止めません。
try をどこまでも敷き詰めてしまいがち
特に開発初心者にありがちなのは、どこで例外を吐くか分からないからとりあえず try を張っておいて、例外捜査網をコード全域に広げることです。これでは「3. どこで例外が発生したのか、スタックとレースを見ないと分からない」につながってしまいます。「事件は会議室 (catch 句) で起きているんじゃない、現場で起きているんだ!」
また、どんな例外が発生するか分からないので、十把一からげに例外処理を実装することもよくあります。本来だったら <exception cref="DivideByZeroException"><paramref name="denominator> is zero.</exception> というような XML ドキュメントをメソッドに貼って、どのような例外が発生するのかを明記しておきたいです。Java だったら @throws をね。
どこで例外が発生したのか、スタックトレースを見ないと分からない
上記のように例外捜査網を広げすぎると、どこで例外が起こるのか一目で見分けられません。実際に例外を吐いた行はスタックトレースを注意深く覗く必要があります。その点 Go は例外が発生したその場で処理をすることで分かりやすくしています。
try のネストが深くなり、どこで捕捉するのか分からなくなることもある
try 句はあくまでも制御構造です。そのため try 句の中に try 句を再度作れます。つまり、入れ子の catch 句で例外を投げたらルートの catch が再度捕捉することになります。これは面倒ですし、コードとして分かりづらくなりがちです。
フローが分かりづらくなる
例外処理のフローはかなり特殊です。口の悪い人は「goto 句の亜種だ」と言うほどです。実際、React で波紋を呼んだ Suspence 実装で throw を使うパターンはその分かりやすい例と言えるでしょう。例外処理というフローはエラーとそれに対する処理を分離し、見通しを悪くしています。
Go はフローの分かりやすさを重視しているのでエラーを特別扱いしません。
予見できる例外は例外ではない
そもそも、我々の遭遇する例外で、本当に予見ができないものはどれくらいあるのでしょうか? 間違いなくそれは不可抗力というものです。例えば、先のコード例は「入力プロンプトで数字以外を入れてしまう」「割る数 (分母) に 0 を入れてしまう」という凡ミスは先回りして防げます。Go の例外処理の考え方は「例外はほぼ予見できる」という見地に立っていることで有名です。
おまけ・例外という言葉は誤訳ともいえる
例外とは英語の Exception の翻訳であることは説明しなくてもよいかと思われますが、日本語でいう例外はコトバンクによると「通例にあてはまらないこと。一般原則の適用を受けないこと。また、そのもの。」であり、プログラミングにおけるそれとはニュアンスが違います。
少なくとも通常の意味では問題を含むかどうかではなく、規則から外れているかどうかという方が大事です。
それでは英英辞典ではどうでしょうか。Oxford Learner's Dictionaries によると、
- a person or thing that is not included in a general statement (一般的な説明に含まれない物・者)
- a thing that does not follow a rule (ルールに従わない物)
1 番が日本語でいう「例外」にニュアンスが近く、プログラミングにおける Exception は 2 番と共通の概念そうだなということが分かります。例えば先のプログラムでは分母に 0 が入力されていることは「0 以外の数字ではないものが入っている」ことよりも「0 で割ってはいけないというルールに従っていない」という方がより重要です。
以上、プログラミングとは関係ないけれど、ずっと気になっていることを書きました。
ではどのように例外を避けるべきか?
カスタムコードでは例外を使わないようにしていくのもこれからは大事ではないでしょうか。例外を投げるのは握りつぶすと深刻な事態、例えばバグを引き起こすものだけにしたいものです。
戻り値も本来得たい値だけでなく、タプルで処理結果も論理値なり文字列なりで返すことができます。タプルが無い言語なら Result パターンを使うべきです。世の中で例外として扱っていたものは9割方この処理でよいはずです。
ただ、コンストラクタはタプルも Result パターンも使えません。この場合の戦略は以下の 3 つが取れます。
- 素直に例外を投げる
- エラーメッセージプロパティを使い、状態が不正であることを通知する (C# や JavaScript のようにプロパティが仕様に組み込まれている言語の場合)
- コンストラクタの代わりにファクトリーメソッドを使う (C# はオブジェクト初期化構文があるのでファクトリーが作りやすい)
また、例外を生じうるコードはメソッド化して切り出し、戻り値を発生した例外を含むタプルで返すというのもありかもしれません。C# において var のスコープが try 句の中に閉じ込められてしまう問題はこうしないと解決できません。できれば例外は発生してすぐ処理したいものです。
If it ain't broke...
しかし、この作法は既存の言語が今まで培ってきたものとは大きく異なります。そして C# においては仕様や構文となることもまずないでしょう (少なくとも GitHub においてそのような議論はありません)。つまり、OSS のような不特定多数に向けたプログラミングでこのようなスタイルを取るのは難しい話です。
しかしそれでも、例外を極力減らす方針は一考の余地があると思います。動作の軽量化、フローの簡略化に伴うトレーサビリティの向上と、いい面は多いはずです。
