Rokiのチラ裏

学生による学習のログ

モナドの概念をC++に導入して冗長なエラーハンドリングを回避する

モナドの概念をC++に導入する事についての Jonathan Boccara 氏による投稿のシリーズを見て、興味深く感じたので、個人的なメモ。尚ソースからは若干コードなどが改変されているところがある。

さて、この具体的な方法は、新たに関数を作成する場合と、既存の関数に対する対応の2種類に分ける事ができるので、それらを区別して記載する。

新たに関数を作成する

次に4つの関数がある。

int f1(int a);
int f2(int b, int c);
int f3(int d);
int f4(int e);

これらを、次のような順番で呼び出したい。

  • f1 を二度呼び出す
  • f1 の結果を f2 の引数にして呼び出す
  • f2 の結果を f3 の引数にして呼び出す
  • f3 の結果を f4 の引数にして呼び出す


しかし、それぞれが失敗するかもしれない。エラーハンドリングの方法を考える必要がある。

  • bool 型などでエラーまたは成功ステータスを返却する、C言語スタイルの解法。
    → 関数から戻ってくる bool 型はエラーまたは成功を意味する保証はない。また結果を全て照合しなければならないので、大量のif文のネスト、または &= などによる判定が必要。エラー内容を通知する事ができない。
  • assert する
    → 処理を単に止めたければ良いかもしれないが、そうでない場合は不十分。エラー内容(エラー箇所)の通知が可能。
  • 例外を投げる
    → パフォーマンス面、例外安全性の考慮が必要。エラー内容の通知が可能。
  • Optional 型を使う
    → 関数から戻ってくる値は必ずエラーまたは成功を内包する。例外送出時のようなパフォーマンス面、例外安全性を考慮する必要はない。依存する関数の数に応じて線形に if文のネストが増える。value_or などで独自的に決められたエラーコードと照合すれば(例えば &= とかで) if文のネストは無くても良いかもしれないが、独自的に決められた値を用いた瞬間から Optional 型の意味は成さない。エラー内容を通知する事が出来ない。


実行時エラーハンドリングの方法としてはこのようなものが考えられるが、Optional + モナドC++で用いる事で、単純で安全なエラーハンドリングが可能になる。具体的には受け取った値が有効かチェックして、有効であれば関数を呼び出し、そうでなければ無効値を返し続ける関数を実装すれば良い。この関数はとても簡単に実装できるが、プロトタイプに対して2つの考慮が必要である。

  • 関数ではなく演算子とする。これによって呼び出しの連鎖が行われる時、より直感的な構文を作る事ができる。モナドがより多く使われる[要出典] Haskell において>>=が使われている事から、>>=を使う。
  • 関数は任意の呼び出し可能な型(関数、関数ポインタ、std::function、lambdaまたは関数オブジェクト)と互換性がなければならない。


実際の実装はこんな感じだろうか。

template<class T,class Invocable,std::enable_if_t<std::is_invocable_v<std::decay_t<Invocable>,T>,std::nullptr_t> = nullptr>
auto operator>>=(const std::optional<T>& t,Invocable&& invoke) noexcept(noexcept(invoke(*t))) -> decltype(invoke(*t))
{
    if(t){
        return invoke(*t);
    }else{
        return std::nullopt;
    }
}

尚 f1 ~ f4 の戻り値は、エラーまたは成功ステータスを表す Optional 型にする。

std::optional<int> f1(int a);
std::optional<int> f2(int b, int c);
std::optional<int> f3(int d);
std::optional<int> f4(int e);

これを使って、冒頭の関数らを適用させると、以下のように書ける。result には途中の関数のいずれか1つでも失敗すれば無効値が入る。

std::optional<int> result = 
    f1(x) >>= [=](int b){
        return f1(y) >>= [=](int c){
            return f2(b,c) >>= [=](int d){
                return f3(d) >>= [=](int e){
                    return f4(e);
                };
            };
        };
    };

既存の関数に対応する

次に、f1 ~ f4 を次のように連鎖的に呼び出したい場合を考える。この f1 ~ f4 は Optional 型を返すものではなく、冒頭で述べたようなプロトタイプである場合を考える*1

f4( f4( f3( f2( f1(x), f1(y) ) ) ) )

まずこの時の x と y は Optional 値だとする。一般的な解は以下のようになるだろうか。

if(x and y){
    f4( f4( f3( f2( f1(x), f1(y) ) ) ) )
}

これは確かに単純で明快だが、この場合も、エラーチェックをラッピングしてやる事で if文を隠す事が可能だ。単純に、呼び出し時に引数が有効でなければ無効値を返し続けさせるラムダを構築してやれば良い。

template<class R,class... Param>
auto make_failable(R (*f)(Param...))
{
    return 
        [f](std::optional<Param>... xs) -> std::optional<R>
        {
            if((xs && ...)){
                return make_optional( f(*(xs)...) );
            }else{
                return std::nullopt;
            }
        };
}

これで、以下のように使う事ができる。

auto failable_f1 = make_failable(f1);
auto failable_f2 = make_failable(f2);
auto failable_f3 = make_failable(f3);
auto failable_f4 = make_failable(f4);

std::optional<int> result = failable_f4( failable_f3( failable_f2( failable_f1(x), failable_f1(y) ) ) );

x または y が無効値である場合、result には無効値がくる。これだけでも中々シンプルにまとまっているがさらに加えて、既存の関数 f1 ~ f4 が失敗する場合、つまり f1 ~ f4 が無効値を返してくる場合、結果も無効とするように make_failable を書き加える。

int f1(int a);
int f2(int b, int c);
std::optional<int> f3(int d); // f3 だけ Optional
int f4(int e);

これは単に先ほどの関数に加えて、以下のような関数をオーバーロードさせれば良い。

template<class R,class... Param>
auto make_failable(std::optional<R> (*f)(Param...))
{
    return 
        [f](std::optional<Param>... xs) -> std::optional<R>
        {
            if((xs && ...)){
                return f(*(xs)...);
            }else{
                return std::nullopt;
            }
        };
}

Reference:

*1:この動作は f1 の評価順序に依存しないものとする