C++
ポエム
C++Day 16

C++のエラー処理との付き合い方

現在は2018年12月16日17時48分です。
そうです。今書き始めたところです。
正直なところ、何も書くテーマがなく、とりあえずテーマをエラー処理としてみたものの、内容の道筋も立っておりません。手探りです。今日中に書き終える気がしません。

まあ、行ってみましょうか。この記事は2018年C++アドベントカレンダー、16日目の記事です。
昨日の記事はyumetodo氏のC++標準化委員会、ついに文字とは何かを理解する: char8_tです。
明日の記事はいなむ先生のC++テンプレートライブラリ書き方Tipsです。

みなさん、エラー処理は好きですか? 私は嫌いです。
なんでエラーが発生するんだ? エラーが発生しなければスッキリするのに。
プログラミングの歴史はエラー処理との戦いです。人類はエラーと戦うために様々な手段を発明して来ました。

C++にもそれらの手段の一部が実装されています。例外処理です。これを使えばエラー処理は完璧です。

とはなりません。例外を使うのをやめろ。

C++のエラーハンドリングはなぜ貧弱なのか

先日、Swiftのエラーハンドリングはなぜ最先端なのかという記事がバズっていましたが、C++の例外処理は、最先端どころか非常に貧弱なので、

  • エラー処理を強制させることができない
  • 例外を投げるつもりがなくても投げる可能性があることになりがち
  • 最適化を強制することができない
  • 受け取った例外の型を知ることができない

といった問題に悩まされることになります。

一つずつ見ていきましょう

エラー処理を強制させることができない

Javaにおける検査例外とは、例外処理が必要な部分で例外処理されているかどうかをコンパイル時に検査する仕組みです。
Javaのそれは非検査例外があって不十分ではありますが、すべてが検査例外であればコンパイル時に例外処理の忘れを検出することができます。それでも握りつぶしが横行することもありますが、ないよりはだいぶマシです。
C++には検査例外がありません。

構文上はJavaの検査例外に似たような機能として、C++11よりも古のC++規格には、動的例外仕様(この言葉自体はC++11からのものですが)というものがありました。これは、関数宣言に後置してthrow(T1, T2, ...)と書くことで、発生しうる例外を列挙できる機能でした。
ただし、この機能は仕様に盛り込まれたものの、ほとんど誰も実装しなかったので、C++17で削除されました。
もっとも、この機能は実行時型チェックなので、これを用いてもエラー処理の強制はさせられませんが。

例外を投げるつもりがなくても投げる可能性があることになりがち

動的例外仕様の代わりに、C++11からは noexcept というキーワードが導入され、例外を投げるか投げないかだけ分かる、というのがC++例外の基本となりました。以前の動的例外仕様の中で throw()throw(...) だけは noexceptnoexcept(false) と同じ意味で残りましたが、noexcept を使えばいいのでもう throw() のことは忘れましょう。

さて、noexceptによって、例外を投げる可能性がないことは明示できるようになりました。
一方で、noexceptをつけていない関数は、デストラクタを除けばすべて例外を投げることになってしまいます。そして残念なことに、世の中にあるC++プログラムの多くはnoexceptであるかどうかに注意を払っていません。

関数シグネチャを見て例外を投げる可能性があった場合、残念ながらどんな例外が投げられるのかまでは分からないので、ドキュメントを見るか1実装のソースコードを読み解くしかありません。
それが一個二個なら問題ないですが、いくつも存在する既存の関数を全てチェックするのは膨大なコストがかかり、結果として例外処理が必要な部分を見落とすことにも繋がりかねません。

最適化を強制することができない

関数自体に例外を投げる可能性があっても、事前条件を満たしていれば例外を発生させないというケースは多数あります。
例えば、 std::vector::at 関数はインデックスが配列の範囲外の時に例外を発生させますが、範囲内の時は例外を発生させません。つまり、事前に範囲がチェックされていれば、例外は発生しないものとして最適化ができるはずです。
しかし、今の所はこれをコンパイラに伝える方法はありません2

受け取った例外の型を知ることができない

C++ではあらゆる型の値を例外とすることができます。
裏を返せば、どんな型の値が飛んでくるか分からない、ということです。
一方で、全ての例外をキャッチするには catch(...) という構文を使う必要があり、ここでできることは例外処理の追加と例外の再送くらいで、例外の値が何かを判別することはできません。
std::current_exception() 関数で現在の例外を取得することはできますが、型を判別することはできません。例外ハンドラに来た時点で動的に型を判別する必要があるはずなので内部的には型情報を持っているはずなのですが、標準ライブラリの範囲ではインターフェイスは用意されていません。
もちろん、 catch で特定の型を指定することはできます。しかし、 catch し損ねた例外が発生した場合、捕捉するのが非常に困難です。

どうすればいいのか

可能であれば、例外を使わないというのが最も確実です。
つまり、全ての関数に noexcept をつけるということです。
ただ、残念なことに、例外を使わなかった場合、エラーを返すための統一的な方法は用意されていません。

標準ライブラリで、特に最近のものは、std::error_code の参照を受け取って、エラー情報をそこに書き込んで返すという手法を使っています。
他の言語では、例えば Either<T, E> のような直和型を使って、値もしくはエラーを戻り値とする、などのパターンがあります。
C++においても、std::expectedというそれに相当する型の提案がありますが、今の所規格に入る予定は立っていません。
std::expected 相当の型は std::variant をラッピングすることで作ることができますが、独自実装は将来における負債となりうるので、微妙なところではあります。
エラー情報が単に成功もしくは失敗の二値で済むのなら、 std::optional を使ってもいいでしょう。

明日からできること

とにかく noexcept を付けましょう。
少なくとも、 noexcept が付いていれば、その関数は例外を投げないことが保証されます。それは非常にコードの見通しを良くします。
もちろん、実際に例外を投げる関数には付けてはいけませんが、とりあえず新しい関数を作る時は noexcept を付けてみて、必要なら外す、くらいでもいいのではないかと思います。
コールバック関数を受け取るときなどは、SFINAEやstatic_assertなどで noexcept を強制しておいてもいいと思います。

終わりに

現在、23時29分です。なんとか間に合った(ことにしよう)
付き合い方、とかタイトルを付けておきながら、あまり良い指針を示せませんでした。4時間で書いた記事なんてそんなものです。
え、6時間はあっただろうって? シン・ゴジラを地上波に合わせて見てたので……。
来年はもうちょっとまともな記事を書けるといいな、と思います。
それでは、良いクリスマスを。


  1. ドキュメントに正確に例外の情報があれば、ですが 

  2. インライン関数の最適化やリンク時最適化などによって例外が発生しないことを検知して最適化してくれることはあります