今、Cライブラリをラップしているのだが、副作用を起こす関数の戻り値をどうするか考えている。
前提:全体のエラーハンドリング
前提として、エラーハンドリングにはここまで例外送出でなくboost::optional
とexpected
を用いている。
基本的に、value_or()
でなく無理やりunwrap()
的なことをしたり、std::string
やstd::vector
などが内部で例外をぶん投げたりしない限り例外は送出されないようにしている。
ところで、例外はどこからどのタイミングで投げられるかとか、どこでキャッチするのかを考えるのが非常にしんどい。
コードが長くなるにつれて飛んでくるかもしれないものと考慮しないといけない条件がどんどん増えていくので(リソース確保途中で飛んできたら解放を保証できるか?)、最近はもう諦め気味で、例外が飛んできたらもうstd::terminate()
で良いかな……という状態になった。
それならoptional
を使って毎回受け取る度にチェックする方がまだマシである。
エラーメッセージなしに失敗する可能性のある関数は、以下のようなシグネチャになる。
optional<result_type> may_fail(args...);
ラップする対象のライブラリでエラーメッセージが取得できる場合は、以下のようにしている。
expected<result_type, boost::string_view> may_fail(args...);
Cライブラリなので、get_error()
がライブラリ内部に取られたC文字列へのポインタを返す。これをstring_view
にくるんで返している。
std::string
は(Short String Optimizationが働かない場合)動的メモリ確保を行うが、boost::string_view
はそれを行わないため速度面で優位であると予想できる。
ただし何の代償もないわけではなく、このやり方だとライブラリ側がエラー文字列をfree
したり上書きするとstring_view
が不正になってしまう。
まあ次のAPI呼び出しよりも先にエラーチェックをしろという話だが、これに関しては、expected<T, E>
をT
から変換可能な型U
、E
から変換可能な型F
を持ったexpected<U, F>
に変換可能にしておけば、boost::string_view
をstd::string
へ変換することでエラーメッセージを保持できる。
expected<result_type, std::string> result = may_fail(args...);
とりあえずはこれでいいかな、という感じでここまで来た。
本題:エラーが発生するかもしれないが、成功した場合返すものがない関数
さて、副作用がある関数がある。例えば画面に四角形を描画するとしよう。APIは以下のようなものだろう。
int draw_rectangle(window* dest, const rect* rectangle);
返却値が0なら成功、-1なら失敗、というように決まっている。失敗したらget_error()
を使ってconst char*
を受け取る。
この関数をこれまでと一貫性を保ってラップするとしたらどうすればいいか。
状況を整理しよう。この関数は既知のWindowに既知の四角形を描画することが目的だ。 なので、成功した場合返すものが存在しない。ただし、失敗した場合はその理由を書き込んだ文字列を取得できる。
もちろんこれはWindowの状態を変えるので状態変更後のWindow構造体を返しても良いわけだが、このCライブラリはそのようにできてはいないし、毎度のコピーは速度を下げる。 純粋関数型言語のコンパイラならそういう状況を考慮して作られていそうで、最適化が十分仕事をするかもしれないが、C++コンパイラにそのコピーを消し去るような最適化を要求できるかというと微妙な気持ちになる。 まあムーブをし続ければまだましなのかな。
なので、ここは「成功した場合返すものはないが、失敗した場合はエラーメッセージを返したい」としよう。
すぐさま思いつくのは、「無いかも知れないもの」を包むoptional
だろう。だがこれには、ここで使うには致命的な問題がある。
使ってみるとしよう。上の例を継続して使う。
optional<boost::string_view> draw_rectangle(window& win, const rect& rectangle); // ... auto result = draw_rectangle(win, box); if(result) { /* 1: error? */ } else { /* 2: error? */ }
どちらがエラーっぽいだろうか。当然、else
節だろう。普通、bool
への変換は成功した場合にtrue
だ。他の場合はどれもそうなっている。
だが、optional
はあくまでも「値がある場合にtrue
」なので、この使い方だと「エラーメッセージがある場合(失敗した場合)にtrue
」になってしまう。
例えばウィンドウを作成して四角形を描画する一連の流れを書くと、以下のようになるわけだ。
auto win = make_window({640, 480}); if(win) { auto result = draw_rectangle(*win, box); if(result) { std::cerr << "an error occured!: " << *result << std::endl; } else { // successfully drawn. go next step. } // ... } else { std::cerr << "an error occured!: " << win.unwrap_error(); }
このコードのどこに一貫性があると言うのか。一瞬でif
とelse
の関係が逆転するこの設計は、明らかにミスを誘発する。
返すものがないならエラーコードや真偽値を返せばいいのでは? とも思わなくはないが、ユーザーにget_error()
呼び出しをさせるのは避けたい。
ライブラリが提供するエラーメッセージは上書きされる可能性もあるし、適切なタイミングで呼ばせるというのは多少負担になる。
微々たるものとはいえ、消しされる負担は消し去るべきだ。
また、他にエラーメッセージ書き込み用のstd::string&
を受け取っておく、というのも策としてあり得る。
ただ、私は関数の引数は少ないほうが良いと思っており、必要なステップも短いほうがいいと思っているので、これはあまり個人的には好ましくない。
それにこのやり方だと成功する場合でも関係なくstd::string
の初期化コストがかかってしまう。
解決策
で、どうするか。
要するに以下のように書ければ良いわけだ。
auto result = draw_rectangle(win, box); if(result) { /* go next step */ } else { std::cerr << "an error occured!: " << result.unwrap_error(); }
または、
auto result = draw_rectangle(win, box); if(result.is_error()) { std::cerr << "an error occured!: " << result.unwrap_error(); }
このインターフェースなら、expected
でいいのではないか。
variant
を空にする場合、boost::blank
やstd::monostate
などを入れると良い。その気持ちで行けば、単純に
expected<boost::blank, boost::string_view>
draw_rectangle(window& win, const rect& rectangle);
のようにすれば良いのではないだろうか。
成功した場合は無を返し、失敗した場合はエラーメッセージを返す。普通にexpected
を使うことを考えた場合、単純で最もわかりやすい方法な気がする。