Permalink
Switch branches/tags
Nothing to show
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
1239 lines (880 sloc) 56.1 KB

鉄道指向プログラミング(翻訳)

関数型アプリケーションのためのレシピ パート2

原文:Railway oriented programming


前回はユースケースを処理単位に分解する方法と、発生したエラーを以下のようにエラー用の回路に逃がす必要があるという説明をしました:

「失敗」パスを1つにまとめる

今回はこれらのステップ関数を様々な方法で1つのユニットとして組み立てる方法を紹介します。 関数の内部設計については別の記事で説明する予定です。

1つのステップを表す関数を設計する

まずはステップの詳細を確認していきましょう。 たとえば検証用の関数です。 これはどのように機能すべきでしょうか? 何かしらのデータを受け取った際、何を出力すればいいのでしょうか?

おそらくは2つのケースが考えられます。 1つはデータが正常な場合(正常パス)、そしてもう1つは何か問題がある場合で、この場合には以下のように別の経路をたどるようにして、残る処理がスキップされるようにします:

検証用関数

しかし以前と同様に、この関数は適切な関数にはなり得ません。 関数は1つの出力しか行えないため、前回定義したResultを使うことになります。

type Result<'TSuccess, 'TFailure> =
    | Success of 'TSuccess
    | Failure of 'TFailure

そうするとダイアグラムも以下のようになります:

1出力の検証用関数

実際の動作は以下のような具体的な検証用関数の例で確認できるでしょう:

type Request = { name:string; email:string }

let validateInput input =
    if input.name = "" then Failure "名前を入力してください"
    else if input.email = "" then Failure "メールアドレスを入力してください"
    else Success input // 成功パス

この関数の型を確認すると、コンパイラはRequestを受け取って成功時にはRequestを、失敗時にはstringをデータに持つようなResultを返すものだと判断したことがわかります:

validateInput : Request -> Result<Request,string>

別の関数に対しても同じ方法で分析することができます。 いずれの関数も同じ「形」をしていることが確認できます。 つまり何らかの入力を受け取り、成功/失敗を出力するという形になっているはずです。

注釈:関数は2つの出力を持つことができないと説明したばかりですが、上のような関数を指して「2つの出力を行う」関数と呼んでしまうことがあるかもしれません。 当然ながらそれは2つのケースを出力にする関数のことを指しているつもりです。

鉄道指向プログラミング

さて、そうするとたくさんの「1入力 -> 成功/失敗を出力する」という関数が揃いましたが、どうやってこれらを連結すればよいのでしょうか?

やりたいことはSuccessの出力を次の入力として渡すけれども、Failure出力の場合には次の関数をスキップするようにしたいということです。 この概念を表すダイアグラムは以下のようになります:

成否によって続く関数をスキップする

この状況は皆さんがおそらく見慣れている、うってつけのもので例えることができます。 鉄道です!

鉄道にはスイッチ(イギリスではポイント)があり、電車の進路を別の路線へと切り替えることができるようになっています。 つまり「成功/失敗」関数を以下のような鉄道のスイッチと見なすことができるわけです:

「成功/失敗」関数としてのスイッチ

そして横につなげてみることができます。

横につなげた2つのスイッチ

2つの失敗路線を連結するにはどうすればよいでしょうか? もちろんこういう感じです!

失敗路線の連結

たくさんのスイッチがある場合でも、下の図のようにすればどこまでも2路線のシステムを延長できます:

2路線システム

上側の路線が成功パスで、下側が失敗パスです。

ここで少し話を戻して全体像を見てみると、2車線の線路を覆い隠すようなブラックボックス関数がいくつかあり、それぞれの関数はデータを処理した後に次の関数へと結果を受け渡していることがわかります:

2路線鉄道上にまたがった関数

しかし関数の中身を見ると、実際にはそれぞれの中にスイッチがあり、不正なデータであれば失敗用の路線に切り替えている形になっています:

関数の中身

なお一度失敗用の路線に入ってしまうと(本来であれば)成功パスには決して戻さないという点に注意してください。 終点にたどり着くまでは以降の処理をスキップさせるだけです。

基本的な合成

それぞれの関数を「連結する(glue)」前に、合成がどのように機能するのか簡単に説明しましょう。

標準的な関数を線路上に置かれたブラックボックス(あるいはトンネル)だとします。 ここには1つの入力と1つの出力があります。

1路線用の関数を複数接続したい場合、>>というシンボルで表される、左から右への合成演算子を使用します。

左から右への合成演算子

この合成演算子は2路線用の関数にも適用できます:

2路線関数に対する合成演算子

合成における唯一の制限は、左辺の関数における出力の型が右辺の関数における入力の型と一致しなければいけないということだけです。

今回の鉄道の例であれば、1路線出力を1路線入力、あるいは2路線出力を2路線入力に接続することができますが、2路線出力を1路線入力に接続することはできないということです。

不正な合成

スイッチを2路線用の入力に変換する

さてここで問題があります。

各ステップ用の関数はそのままだと1路線入力のスイッチになります。 しかし処理全体としては両方の路線を覆うような2路線システムになっていなければいけません。 つまり各関数は単に1路線入力(Request)ではなく、2路線入力(Result)できるようになっていなければいけないということです。

どうすれば2路線システムにスイッチを導入できるのでしょうか?

答えは単純です。 以下の図にあるように、各関数用の「穴」あるいは「スロット」を持ち、適切な2路線用の関数へと変換するような「アダプター」関数を用意すればよいのです:

アダプター関数

また、実際のコードは以下のようになります。 このような処理は標準的にはbindと呼ばれることが多いため、ここでもそれにならっています。

let bind switchFunction =
    fun twoTrackInput ->
        match twoTrackInput with
        | Success s -> switchFunction s
        | Failure f -> Failure f

bind関数はスイッチ関数を引数にとり、新しい1つの関数を返します。 新しい関数は2路線入力(Result型)を取り、各ケースをチェックします。 入力がSuccessであればその値を引数にしてswitchFunctionを呼びます。 しかし入力がFailureの場合にはスイッチ関数がスキップされます。

このコードをコンパイルしてみると以下のようなシグネチャになっていることが確認できます:

val bind : ('a -> Result<'b,'c>) -> Result<'a,'c> -> Result<'b,'c>

このシグネチャは、bind関数が引数('a -> Result<..>)を1つとり、2路線関数(Result<..> -> Result<..>)を出力とするというように解釈することもできます。

さらに具体的には以下の通りです:

  • bindの引数(switchFunction)は任意の型'aをとり、'b(成功路線用)と'c(失敗路線用)を返します。
  • リターンされた関数自体は、'b(成功用)と'c(失敗用)を型に持つResult型の引数(twoTrackInput)をとります。 型'aswitchFunctionが期待する1路線の型と同じものです。
  • リターンされた関数の出力は'b'cという別の型を持ったResult型です。 この型はスイッチ関数の出力と同じ型になっています。

上記のように、この型シグネチャがまさに期待する通りのものになっていることが分かると思います。

なおこの関数は完全にジェネリックで、任意のスイッチ関数と任意の型に対応できます。 制限されるのはswitchFunctionの「形」だけで、具体的な型については特に制限されません。

bind関数を別の方法で作成する

少し話がそれますが、bind関数は別の書き方をすることもできます。

1つは以下のように、内部で定義したtwoTrackInputを2番目の引数として明示的に受け取るようにする方法です:

let bind switchFunction twoTrackInput =
    match twoTrackInput with
    | Success s -> switchFunction s
    | Failure f -> Failure f

これは最初の定義と全く同じものです。 2引数の関数が1引数の関数と全く同じだと言える理由が分からなければ、是非 カリー化 の記事を参照してください!

もう1つの書き方としては、以下のようにmatch..withfunctionキーワードで書き換えてしまう方法です:

let bind switchFunction =
    function
    | Success s -> switchFunction s
    | Failure f -> Failure f

以上3つのコードスタイルを見かけることになると思いますが、引数が明確になっていたほうが非エキスパートであってもコードを読みやすいと思うので、筆者としては2番目のスタイル(let bind switchFunction twoTrackInput =)を推奨します。

例:複数の検証用関数を組み合わせる

コンセプトがうまくいっているかテストするために、ここで少しコードを書いてみましょう。

まずは既に定義済みのものがあります。 RequestResultbindは以下の通りです:

type Result<'TSuccess, 'TFailure> =
    | Success of 'TSuccess
    | Failure of 'TFailure

type Request = {name:string; email:string}

let bind switchFunction twoTrackInput =
    match twoTrackInput with
    | Success s -> switchFunction s
    | Failure f -> Failure f

次に3つの関数を作成します。 それぞれは「スイッチ」関数で、最終的には1つの巨大な関数として組み合わせることになります:

let validate1 input =
    if input.name = "" then Failure "名前を入力してください"
    else Success input

let validate2 input =
    if input.name.Length > 50 then Failure "名前は50文字以下で入力してください"
    else Success input

let validate3 input =
    if input.email = "" then Failure "メールアドレスを入力してください"
    else Success input

次はこれらを連結できるように、それぞれの関数に対してbind関数を呼び出して2路線用の新しい関数を作成します。

その後、以下のように標準の関数合成演算子を使用して2路線関数を連結します:

/// 3つの検証用関数を1つにまとめます
let combinedValidation =
    // スイッチ関数を2路線関数に変換します
    let validate2' = bind validate2
    let validate3' = bind validate3
    // 2路線関数を連結します
    validate1 >> validate2' >> validate3'

validate2'validate3'は2路線を入力とするような新しい関数です。 シグネチャを確認すると、Resultを引数にとってResultをリターンするようになっていることがわかります。 しかしvalidate1は2路線入力できるように変換する必要はないという点に注意してください。 この入力は1路線のままになっていて、出力は既に2路線になっているため、合成に必要な条件を満たしています。

validate1(未bind)スイッチとvalidate2validate3スイッチをそれぞれvalidate2'validate3'アダプターにして連結すると下図のようになります。

検証用関数の連結

以下のようにbindを「インライン化」することもできます:

let combinedValidation =
    // 2路線用関数を連結します
    validate1
    >> bind validate2
    >> bind validate3

不正な入力2パターンと正常入力1パターンをテストしてみましょう:

// テスト1
let input1 = {name=""; email=""}
combinedValidation input1
|> printfn "結果1=%A"

// ==> 結果1=Failure "名前を入力してください"

// テスト2
let input2 = {name="Alice"; email=""}
combinedValidation input2
|> printfn "結果2=%A"

// ==> 結果2=Failure "メールアドレスを入力してください"

let input3 = {name="Alice"; email="good"}
combinedValidation input3
|> printfn "結果3=%A"

// ==> 結果3=Success {name = "Alice"; email = "good";}

是非上のコードを実際に試してみたり、違う値をテストしてみたりしてください。

上記3つの関数を直列ではなく並列に実行して、検証エラーを一度に取得できないだろうかと思うかもしれません。 もちろん可能です。 この記事で後ほど説明する予定です。

パイプ化演算子としてのbind

bind関数の説明が続きますが、スイッチ関数をパイプ化するシンボルとしては一般的に>>=が使用されます。

定義は以下の通りで、左右に指定した関数を簡単に連結できるようになっています:

/// 中置演算子を作成します
let (>>=) twoTrackInput switchFunction =
    bind switchFunction twoTrackInput

このシンボルは、合成演算子>>の後に線路のシンボル=を続けたものと覚えてください。

こういった演算子を使用すると、>>=演算子をスイッチ関数用のパイプ演算子(|>)とみなすことができます。

通常のパイプ演算では、左辺に1路線の値を指定して、右辺に通常の関数を指定します。 しかし「bindパイプ」演算子の場合、左辺には2路線の値を指定して右辺にスイッチ関数を指定します。

この演算子を使用すると、combinedValidation関数を以下のような方法でも作成できます。

let combinedValidation x =
    x
    |> validate1  // validate1は1路線入力なので通常のパイプ演算を使用しますが
                  // 結果としては2路線を出力するので...
    >>= validate2 // ...「bindパイプ」が使用できます。この結果も2路線出力なので
    >>= validate3 // ... さらに「bindパイプ」が使用できます。

前回の実装と異なる点は、今回の場合は関数指向というよりはデータ指向の実装になっているという点です。 初期のデータ値としてxを明示的に受け取るようになっています。 xは最初の関数に渡されて、出力データが後続する関数に渡されていくという形になっています。

前回の実装では(以下に再掲しますが)データ引数を全くとらないものでした! 関数自体に焦点が置かれていて、関数毎のデータフローについては対象になっていません。

let combinedValidation =
    validate1
    >> bind validate2
    >> bind validate3

bindを使用しない方法

スイッチ関数を連結する別の方法として、2路線入力関数に接続するのではなく、単純に直接それぞれのスイッチ関数を連結して、より大きなスイッチ関数を作成することもできます。

つまり、以下のスイッチ関数があるとして:

2つのスイッチ関数

以下のように連結します:

連結後のスイッチ関数

しかしよく考えてみると、この連結した路線もまた違ったスイッチ関数だと見なすことができます! 中央のあたりを隠してみましょう。 そうすると1入力2出力になっていることがわかります:

2つのスイッチ関数を合成すると新しいスイッチ関数のように見える

つまり実際には以下のようにしてスイッチ関数を連結できるというわけです:

2つのスイッチ関数を連結する

それぞれの合成結果が別のスイッチになっているわけなので、さらに別のスイッチを追加してより大きな関数となり、そこへさらに別のスイッチを追加できるといった具合です。

スイッチを合成するコードは以下のようになります。 標準的なシンボルは>=>で、通常の合成用シンボルに似ていますが、間に線路が置かれた形になっています。

let (>=>) switch1 switch2 x =
    match switch1 x with
    | Success s -> switch2 s
    | Failure f -> Failure f

今回も実際の実装としては非常に単純です。 1路線入力xを最初のスイッチに渡します。 そして結果が成功であれば2番目のスイッチに結果を渡し、失敗した場合には2番目のスイッチが完全にスキップされます。

さてこれでbindを使用せずにcombinedValidation関数が作成できるようになりました:

let combinedValidation =
    validate1
    >=> validate2
    >=> validate3

これが一番シンプルな形ではないかと思います。 もちろん拡張も非常に簡単で、4つめの検証用関数を追加したい場合には、最後の位置に追加するだけです。

bind 対 スイッチ合成

それぞれは一見すると同じに見えるものの、コンセプトがそれぞれ異なります。 何が異なるのでしょうか?

それぞれの機能は以下の通りです:

  • bindは1つのスイッチ関数を引数にとります。 スイッチ関数を完全な2路線(つまり2路線入力かつ2路線出力)関数に変換します。
  • スイッチ合成は2つのスイッチ関数を引数にとります。 一連のスイッチ関数を連結して、新しい1つのスイッチ関数を作成します。

スイッチ合成よりもbindを使用したほうがいい場合があるのでしょうか? それはコンテキストによって異なります。 既に2路線システムが構築されていて、そこへさらにスイッチを追加する必要がある場合には、bindでスイッチ関数を2路線入力できるように変換する必要があります。

既存の2路線システムがある場合にはbindが必要

一方、全体的なデータフローがスイッチの連鎖で構成されている場合にはスイッチ合成のほうが簡単でしょう。

スイッチの合成

bindの観点からのスイッチ合成

偶然にも、スイッチ合成はbindを使用して記述することができます。 1つめのスイッチをbind後の2つめのスイッチと連結させればスイッチ合成と同じことができます。 つまり2つのスイッチがそれぞれあるとして:

2つの独立したスイッチ

それぞれを合成してより大きなスイッチが作られます:

合成語のスイッチ

これは2つめのスイッチにbindを使用した場合と同じです:

2つめのスイッチにbindを使用する

この考え方でスイッチ合成を書き直すと以下のようになります:

let (>=>) switch1 switch2 =
    switch1 >> (bind switch2)

このスイッチ合成の実装は最初のものよりも単純で、それでいながらより抽象化されています。 しかしそれが初学者にとって簡単かどうかはまた別の問題です! 関数をデータの流れから考えるのではなく、正しく機能として認識することができるようになれば、このアプローチのほうが理解しやすいのではないかと思います。

単純な関数を鉄道指向プログラミングモデルへと変換する

いったんコツがつかめれば、ありとあらゆるものをこのモデルに適用できるようになります。

たとえばスイッチ関数ではなく、普通の関数を考えてみましょう。 また、それをフローの中に組み込みたいものとします。

具体的には検証が完了した後、メールアドレスから前後の空白を削除しつつ小文字に揃えたいとします。 コードとしては以下のような関数を用意します:

let canonicalizeEmail input =
    { input with email = input.email.Trim().ToLower() }

このコードは(1路線の)Requestを受け取り、(1路線の)Requestを返します。

これを検証処理と更新処理の間に挿入するにはどうしたらよいでしょうか?

この単純な関数をスイッチ関数に変換出来たとすれば、後は既に説明したようにスイッチ合成を行うだけです。

別の言い方をすれば、この関数用のアダプターブロックが必要だということです。 コンセプトとしてはbindの場合と同じですが、今回の場合はアダプターブロックが1路線関数用のスロットを持ち、全体の「形」としてはアダプターブロックがスイッチになっていなければいけないという違いがあります。

1路線関数用のスロットを持ったアダプターブロック

実装コードは単純です。 1路線関数の出力を2路線用の出力へと変換してやればよいだけです。 今回の場合、結果は常にSuccessになります。

// 通常の関数をスイッチに変換します
let switch f x =
    f x |> Success

鉄道用語で言えば、ある意味で廃線を増やしたとも言えるでしょう。 全体からすると(1路線入力、2路線出力の)スイッチ関数のように見えますが、当然ながら実際には失敗用の路線は単なるダミーで、決して使用されることがありません。

失敗路線の増設

switchが出来上がれば、あとはcanonicalizeEmail関数を最後の位置に連結させるだけです。 機能も増えてきたため、あわせて関数の名前をusecaseに変更しましょう。

let usecase =
    validate1
    >=> validate2
    >=> validate3
    >=> switch canonicalizeEmail

そうするとどうなるか確認してみましょう:

let goodInput = {name="Alice"; email="UPPERCASE   "}
usecase goodInput
|> printfn "正規化された正常な結果 = %A"

// 正規化された正常な結果 = Success {name = "Alice"; email = "uppercase";}

let badInput = {name=""; email="UPPERCASE   "}
usecase badInput
|> printfn "正規化された不正な結果 = %A"

// 正規化された不正な結果 = Failure "名前を入力してください"

1路線関数から2路線関数を作成する

先ほどの例では1路線関数を元にしてスイッチ関数を作成しました。 そうすることによって、スイッチ合成できるようになったわけです。

しかし場合によっては2路線モデルを直接使用して、1路線関数を2路線関数に直接変換したいということもあるでしょう:

1路線関数を直接2路線関数に変換する

この場合もやはり、単純な関数をスロットにもつようなアダプターブロックが必要です。 このようなアダプターを一般的にmapと呼んでいます。

mapというアダプター

今回もやはり直感的に実装できます。 2路線入力がSuccessの場合には関数を呼び出して、結果をSuccessとして返すだけです。 一方、入力がFailureだった場合には関数を完全にスキップします。

コードは以下の通りです:

// 通常の関数を2路線関数に変換します
let map oneTrackFunction twoTrackInput =
    match twoTrackInput with
    | Success s -> Success (oneTrackFunction s)
    | Failure f -> Failure f

canonicalizeEmailと組み合わせると以下のようになります:

let usecase =
    validate1
    >=> validate2
    >=> validate3
    >> map canonicalizeEmail // 通常の合成

map canonicalizeEmailは完全に2路線の関数を返し、validate3スイッチの出力と直接連結させることができるため、通常の合成を使用している点に注意してください。

別の言い方をすると、1路線関数に対しては>=> switch>> mapが全く同じ機能をするということです。

行き止まり関数を2路線関数に変換する

もう1つ、「行き止まり」関数もよく使うことがあります。 これはつまり入力を受け付けるものの、有効な出力を行わないようなものです。

たとえばデータベースのレコードを更新する関数を考えてみましょう。 この関数は副作用を起こすことだけが重要で、通常は特に返り値を返しません。

こういった関数をどうすればフローの中に組み込めるでしょうか?

必要な処理は以下の通りです:

  • 入力のコピーを保存する
  • 関数を呼び出して、それが出力を持つなら出力を無視する
  • 元々の入力をチェインの次の関数に渡す

鉄道用語でいえば、以下のように行き止まり用の待避路線を用意することになります。

行き止まり用の待避路線

これが機能するには、switchのようなまた新しいアダプター関数を用意する必要があります。 ただし今回は1路線の行き止まり関数用のスロットを持ち、行き止まり関数を1路線入出力のパススルー関数に変換するようなものになります。

行き止まり関数用のアダプター

コードは以下の通りで、UNIXのteeコマンドにならってteeと名付けています:

let tee f x =
    f x |> ignore
    x

これで行き止まり関数を単純な1路線パススルー関数に変換できるようになったので、先に説明したswitchあるいはmapを使用してデータフローに追加できます。

「スイッチ合成」スタイルのコードだと以下のようになります:

// 行き止まり関数
let updateDatabase input =
    () // 今のところはダミー

let usecase =
    validate1
    >=> validate2
    >=> validate3
    >=> switch canonicalizeEmail
    >=> switch (tee updateDatabase)

あるいはswitch>=>の代わりにmap>>を使用することもできます。

通常の合成を使用する「2路線」スタイルの場合だと以下のような実装になります。

let usecase =
    validate1
    >> bind validate2
    >> bind validate3
    >> map canonicalizeEmail
    >> map (tee updateDatabase)

例外処理

このデータベース更新関数は何も値を返さないかもしれませんが、かといってそれが例外をスローしないというわけではありません。 例外時にはクラッシュしてしまうのではなく、例外をキャッチしてそれを失敗として通知したいはずです。

コードはswitchに似ていますが、例外をキャッチしているという違いがあります。 この関数をtryCatchと名付けることにしましょう:

let tryCatch f x =
    try
        f x |> Success
    with
    | ex -> Failure ex.Message

データベース更新用の関数に対してはswitchの代わりにtryCatchを使用した場合のコードは以下のようになります。

let usecase =
    validate1
    >=> validate2
    >=> validate3
    >=> switch canonicalizeEmail
    >=> tryCatch (tee updateDatabase)

2路線入力の関数

これまでの関数はいずれも成功パスにおいてしか機能しないものばかりだったので、どの関数も1つの入力しか受け付けませんでした。

しかし両方の路線を 確実に 処理しなければならないような関数が必要になることもあります。 たとえばログ処理関数は成功も失敗もどちらともログとして残さなければいけません。

ここでもやはりアダプター関数を作成することになります。 ただし今回は1路線関数用のスロットを2つ持てるようにします。

2つの1路線関数用スロットを持つアダプター

コードは以下の通りです:

let doubleMap successFunc failureFunc twoTrackInput =
    match twoTrackInput with
    | Success s -> Success (successFunc s)
    | Failure f -> Failure (failureFunc f)

ちなみにこの関数で失敗用の関数としてidを使用すると、mapをシンプルに実装できます:

let map successFunc =
    doubleMap successFunc id

ではdoubleMapを使用して、ログ処理をデータフローに組み込んでみましょう:

let log twoTrackInput =
    let success x = printfn "DEBUG. 今のところ問題なし: %A" x; x
    let failure x = printfn "ERROR. %A" x; x
    doubleMap success failure twoTrackInput

let usecase =
    validate1
    >=> validate2
    >=> validate3
    >=> switch canonicalizeEmail
    >=> tryCatch (tee updateDatabase)
    >> log

テストコードとその結果は以下のようになります:

let goodInput = {name="Alice"; email="good"}
usecase goodInput
|> printfn "良い結果 = %A"

// DEBUG. 今のところ問題なし: {name = "Alice"; email = "good";}
// 良い結果 = Success {name = "Alice"; email = "good";}

let badInput = {name=""; email=""}
usecase badInput
|> printfn "悪い結果 = %A"

// ERROR. "名前を入力してください"
// 悪い結果 = Failure "名前を入力してください"

1つの値を2路線用の値に変換する

完全のためには1つの単純な値から2路線用の成功または失敗へと変換するような単純な関数も作成しておくべきでしょう。

let succeed x =
    Success x

let fail x =
    Failure x

見たままの通り、単にResult型のコンストラクタを呼び出しているだけですが、コーディングの経験を積むうちに、直接ユニオン型のコンストラクタを呼び出すのではなく、このように関数を挟むことによって内部的な変更と外部のコードを切り離すというテクニックを見かけることもあるでしょう。

関数を並列に連結する

これまでで一連の関数を連結できるようにはなりました。 しかしたとえば検証用の関数などにおいて、以下のように複数のスイッチを並列に実行して結果を集計したいこともあります:

複数のスイッチを並列に実行する

この処理を簡単にするために、スイッチ合成と同じトリックを使用してみましょう。 多数のスイッチではなく1組のスイッチに焦点を絞って、それらを「足した」スイッチが作成できれば、任意個のスイッチを「加算」できます。 つまりは以下のような実装が必要です:

スイッチを加算する

2つのスイッチを並列に加算するにはどうしたらよいでしょうか?

  • まず受け取った入力をそれぞれの関数に渡します
  • 次に両方のスイッチからの結果を見て、両方が成功していれば最終結果としてSuccessを返します
  • どちらかが失敗している場合には最終結果としてFailureを返します

plusと名付けたこの関数は以下の通りです:

let plus switch1 switch2 x =
    match (switch1 x),(switch2 x) with
    | Success s1, Success s2 -> Success (s1 + s2)
    | Failure f1, Success _  -> Failure f1
    | Success _ , Failure f2 -> Failure f2
    | Failure f1, Failure f2 -> Failure (f1 + f2)

しかしここには新しい問題があります。 2つの結果がいずれも成功、またはいずれも失敗の場合にどうしたらよいのでしょう? どうやって結果を組み合わせればよいのでしょうか?

上のコードではs1 + s2f1 + f2になっていますが、ここでは+演算子のようなものが使用できることにしてしまっています。 文字や整数であれば確かに用意されていますが、一般的に用意されているとは限りません。

値を連結する方法はコンテキストによって異なるため、いついかなる場合にも対応するようなものを探すのではなくて、必要に応じて呼び出し側で連結処理を行う関数を指定できるようにすることにしましょう。

書き直すと以下のようになります:

let plus addSuccess addFailure switch1 switch2 x =
    match (switch1 x),(switch2 x) with
    | Success s1, Success s2 -> Success (addSuccess s1 s2)
    | Failure f1, Success _  -> Failure f1
    | Success _ , Failure f2 -> Failure f2
    | Failure f1, Failure f2 -> Failure (addFailure f1 f2)

ここでは部分適用できるように、新しい関数用の引数を最初にとるようにしています。

並列的な検証機能を実装する

では検証機能に対する「plus」を作成しましょう。

  • 両方の関数が成功した場合、リクエストを変更しないまま返せばよいため、 addSuccess関数ではどちらの値を返してもよい
  • 両方の関数が失敗した場合、それぞれ異なる文字列が返されるはずなので、 addFailureでは2つの文字列を結合した値を返すようにする

そうすると検証用関数の「plus」演算は「論理積(AND)」のようになっています。 つまり両方が「true」の場合だけ「true」が返されます。

なので演算子記号としては&&が直感的に思い浮かびますが、これは既に予約されているため、以下のように&&&を使用することにしましょう:

// 検証用関数の「plus」関数
let (&&&) =
    let addSuccess r1 r2 = r1 // 1つめを返します
    let addFailure s1 s2 = s1 + "; " + s2 // 連結します
    plus addSuccess addFailure

そして&&&を使用して、3つの小さな検証用関数を連結して1つの検証用関数を作成します:

let combinedValidation =
    validate1
    &&& validate2
    &&& validate3

では先ほどと同じテストを実行してみましょう:

// テスト1
let input1 = {name=""; email=""}
combinedValidation input1
|> printfn "結果1=%A"
// ==> 結果1=Failure "名前を入力してください; メールアドレスを入力してください"

// テスト2
let input2 = {name="Alice"; email=""}
combinedValidation input2
|> printfn "結果2=%A"
// ==> 結果2=Failure "メールアドレスを入力してください"


// テスト3
let input3 = {name="Alice"; email="good"}
combinedValidation input3
|> printfn "結果3=%A"
// ==> 結果3=Success {name = "Alice"; email = "good";}

最初のテストでは期待通り、2つの検証エラーが1つの文字列として返されています。

次に、この3つの検証関数を組み合わせたものをメインデータフロー関数であるusecaseに組み込みます:

let usecase =
    combinedValidation
    >=> switch canonicalizeEmail
    >=> tryCatch (tee updateDatabase)

この関数をテストすると、成功時にはメールアドレスが小文字かつ前後の空白無しで出力されることが確認できます:

// テスト4
let input4 = {name="Alice"; email="UPPERCASE   "}
usecase input4
|> printfn "結果4=%A"
// ==> 結果4=Success {name = "Alice"; email = "uppercase";}

同じようにして論理和(OR)を作成できないものかと思うかもしれません。 それはつまりどちらかが成功すれば成功を返すということでしょうか? もちろん実装できます。 是非試してみてください! シンボルとしては|||をおすすめします。

関数の動的な注入

もう1つ欲しい機能としては、設定ファイルやデータの中身に応じて関数をフローへ動的に追加または削除するというものです。

一番簡単な方法としては、もし不要であればid関数に置き換えられるような、フローの間に注入できる2路線関数を作成する方法です。

アイディアとしては以下のようなものです:

let injectableFunction =
    if config.debug then debugLogger else id

実際のコードにしてみましょう:

type Config = {debug:bool}

let debugLogger twoTrackInput =
    let success x = printfn "DEBUG. 今のところ問題なし: %A" x; x
    let failure = id // ここではログをとらない
    doubleMap success failure twoTrackInput

let injectableLogger config =
    if config.debug then debugLogger else id

let usecase config =
    combinedValidation
    >> map canonicalizeEmail
    >> injectableLogger config

以下のように使用します:

let input = {name="Alice"; email="good"}

let releaseConfig = {debug=false}
input
|> usecase releaseConfig
|> ignore

// 出力なし

let debugConfig = {debug=true}
input
|> usecase debugConfig
|> ignore

// デバッグ出力
// DEBUG. 今のところ問題なし: {name = "Alice"; email = "good";}

鉄道路線関数:ツールキット

これまでに実装した機能を振り返ってみましょう。

鉄道路線を比喩とすることで、任意のデータフロースタイルのアプリケーションでも動作するような機能を多数作成しました。

大まかには以下の区分に分けられます:

  • 新しい路線を作成するための「コンストラクタ(constructors)」
  • ある種の路線を別の種類に変換するための「アダプター(adapters)」
  • 路線区間を連結して、さらに大きな路線を作成するための「コンバイナー(combiners)」

これらの関数はいわゆるコンビネーターライブラリを構成するものです。 コンビネーターライブラリとはある型(ここでは鉄道路線)を処理する関数群で、小さな関数を変形したり連結したりしてより大きな機能を作成できるように設計されたものです。

bindmapplusのような関数は任意の関数型プログラミングシナリオで活用できます。 そのためこれらは全く同じとは言えませんが、OOの「ビジター(visitor)」「シングルトン(singleton)」「ファサード(facade)」のようなパターンと同じように、関数的なパターンだとみなすこともできるでしょう。

まとめると以下の通りです:

コンセプト 説明
succeed 1路線入力をとり、成功路線に続くような2路線用の値を作成するコンストラクタです。別のコンテキストではreturnあるいはpureとも呼ばれます。
fail 1路線入力を取り、失敗路線に続くような2路線用の値を作成するコンストラクタです。
bind スイッチ関数をとり、2路線用の入力を受け付けるような新しい関数を作成するアダプターです。
>>= 2路線値をスイッチ関数に連結させる、bindの中置バージョンです。
>> 通常の関数合成です。通常の関数を2つつなげて新しい関数を作成するコンバイナーです。
>=> スイッチを合成します。2つのスイッチ関数をつなげて新しいスイッチ関数を作成するコンバイナーです。
switch 通常の1路線関数をとり、スイッチ関数へと変換するようなアダプターです。(コンテキストによっては「lift」とも呼ばれます。)
map 通常の1路線関数をとり、2路線関数へと変換するようなアダプターです。(コンテキストによっては「lift」とも呼ばれます。)
tee 行き止まり関数をとり、データフロー内で使用できるような1路線関数を返すアダプターです。(tapとも呼ばれます。)
tryCatch 通常の1路線関数をとり、スイッチ関数へと変換するアダプターですが、例外をキャッチするようになります。
doubleMap 2つの1路線関数をとり、1つの2路線関数へと変換するようなアダプターです。(bimapとも呼ばれます。)
plus 2つのスイッチ関数をとり、それらを「並列」に連結して結果を「加算」して返すような新しいスイッチ関数を作成するコンバイナーです。(コンテキストによっては++<+>とも表されます。)
&&& 検証関数用に特化した「加算」コンバイナーで、論理積をモデルにしています。

鉄道路線関数:コード全容

コードの全容を以下にまとめておきます。

なお上で紹介したコードから若干変更しています:

  • 多くの関数はコア関数eitherを使用して定義されるようになっています。
  • tryCatchは例外ハンドラ用の引数をとるようになっています。
// 2路線型
type Result<'TSuccess, 'TFailure> =
    | Success of 'TSuccess
    | Failure of 'TFailure

// 1入力を2路線用の値に変換します
let succeed x =
    Success x

// 1入力を2路線用の値に変換します
let fail x =
    Failure x

// 成功用関数または失敗用関数のいずれかを適用します
let either successFunc failureFunc twoTrackInput =
    match twoTrackInput with
    | Success s -> successFunc s
    | Failure f -> failureFunc f

// スイッチ関数を2路線関数に変換します
let bind f =
    either f fail

// 2路線値をスイッチ関数に接続します
let (>>=) x f =
    bind f x

// 2つのスイッチを1つに連結します
let (>=>) s1 s2 =
    s1 >> bind s2

// 1路線関数をスイッチに変換します
let switch f =
    f >> succeed

// 1路線関数を2路線関数に変換します
let map f =
    either (f >> succeed) fail

// 行き止まり関数を1路線関数に変換します
let tee f x =
    f x; x

// 1路線関数を例外処理ありのスイッチに変換します
let tryCatch f exnHandler x =
    try
        f x |> succeed
    with
    | ex -> exnHandler ex |> fail

// 2つの1路線関数を1つの2路線関数に変換します
let doubleMap successFunc failureFunc =
    either (successFunc >> succeed) (failureFunc >> fail)

// 2つのスイッチを並列に加算します
let plus addSuccess addFailure switch1 switch2 x =
    match (switch1 x),(switch2 x) with
    | Success s1,Success s2 -> Success (addSuccess s1 s2)
    | Failure f1,Success _  -> Failure f1
    | Success _ ,Failure f2 -> Failure f2
    | Failure f1,Failure f2 -> Failure (addFailure f1 f2)

型 対 形

ここまでは電車の積み荷ではなく、路線の形だけを考慮していました。

この路線には魔法がかけられているので、運ばれる路線に応じて積み荷の姿が変わるのです。

たとえばパイナップルがfunction1というトンネルを通過するとアップルになります。

パイナップルからアップルに

また、アップルがfunction2というトンネルを通過するとバナナになります。

アップルからバナナに

この魔法の路線には1つ重要なルールがあります。 それは同じ積み荷を運ぶ路線同士しか連結できないというものです。 今回の場合、function1を通った積み荷(アップル)はfunction2の入り口(こちらもアップル)と同じものになっているので、function1function2を連結できます。

function1とfunction2を連結

当然ながら必ずしも同じ積み荷が運ばれるとは限らないため、違う種類の積み荷を運ぶ路線を接続してしまうと問題が起こります。

ところがこれまでは積み荷という話が全く出てこなかったことに気がついたことと思います! 代わりに1路線対2路線関数という話に終始していました。

もちろん積み荷が一致しなければいけないことは言うまでもありません。 しかし重要なのは路線の形であって、運搬される積み荷ではないという点に気付いていただきたいのです。

ジェネリック型の強み

では何故積み荷の種類を気にする必要がないのでしょうか? それはすべての「アダプター」および「コンバイナー」関数が完全にジェネリックだからです! bindmapswitchplus関数は路線の形だけに注意しており、積み荷の種類については何も制限しないのです。

完全にジェネリックな関数には2つの利点があります。 1つは明らかで、関数がジェネリックになるほど再利用性が向上するという点です。 たとえばbind関数は(形さえ正しければ)任意のデータ型に対して機能します。

しかしジェネリック関数には潜在的な、それでいて特筆すべき利点がもう1つあります。 一般的にはどのような型が使用されるのか 全くわからないため、出来ることと出来ないことが非常に制限されることになります。 その結果、バグにつけいる隙を与えないようにできるのです!

具体例としてmapのシグネチャを見てみましょう:

val map : ('a -> 'b) -> (Result<'a,'c> -> Result<'b,'c>)

この関数は'a -> 'bという関数引数とResult<'a,'c>の値を引数にとり、 Result<'b,'c>を返します。

'a'b'cについては何の情報もありません。 分かるのは以下のことだけです:

  • 'aという 同じ型が関数引数と1つめのResultSuccessケースにある
  • 'bという 同じ型が関数引数と2つめのResultSuccessケースにある
  • 'cという 同じ型が1つめと2つめ両方のResultFailureケースにあるが、関数引数にはない

ここから何が分かるでしょうか?

返り値には型'bが含まれます。 しかしこれはどこから来たのでしょうか? 'bが実際にはどんな型なのか分からないので、この型のオブジェクトを作り出すことはできません。 しかし関数引数であればそれを作り出す方法を知っています! 'aを指定すれば'bが手に入るはずです。

しかし'aをどこから手に入れればいいのでしょうか? やはり'aの型が分からないので、これもまた作り出すことができません。 ところが結果の1つ目の引数には'aがあるので、Result<'a,'c>からSuccessの値を 無理矢理取り出して関数引数に渡す事ができます。 そして返り値であるResult<'b,'c>Successケースは 必ず関数の結果から作り出されることになります。

最後に、'cにも同じ事を行います。 入力引数のResult<'a,'c>からFailureケースの値を無理矢理取り出して、返り値であるResult<'a,'c>Failureケースを作り出します。

別の言い方をすると基本的には map関数を実装する方法はたった1つしかないということです! 型シグネチャがジェネリックなのでそれ以外にはなり得ないのです。

一方、mapが以下のように型を極めて限定していた場合を考えてみましょう:

val map : (int -> int) -> (Result<int,int> -> Result<int,int>)

この場合はかなり多様な方法で実装できます。 たとえば以下のようなことが可能です:

  • 成功と失敗の路線を入れ替えることができる
  • 成功の路線に乱数を追加できる
  • 関数引数を全く無視してしまって、成功と失敗の両方の場合に0を返すことができる

これらはいずれも期待する通りに動作しないという意味で「バグを起こしやすい」ものになるでしょう。 しかし型がintであるということが前もって分かっているだけなので、想定しないような方法でも値を処理出来てしまうのです。 型に関する情報が少なければ少ないほど、失敗を起こしづらくなります。

失敗型

今回作成した関数の多くは、成功用の路線だけで変換を行っていました。 失敗用の路線はそのままにされる(map)か、入力された失敗用の路線とマージされます(bind)。

このことから、失敗用の路線では常に 同じ型になっていることがわかります。 今回の場合はstringでしたが、次回はもう少し便利になるように失敗用の型を検討してみる予定です。

総括とガイドライン

シリーズを始めた時に、私は読者が応用できるようなレシピを公開すると約束しました。

しかし今回はだいぶ疲れてしまったのではないでしょうか。 話を簡単にするはずが、逆に難しくしてしまったようです。 同じことを何通りもの方法で実現できるということを示しました! bindと合成や、mapとswitchなどいくつもありました。 どの方法を使用すべきなのでしょうか? どれが一番いい方法なのでしょうか?

もちろん、すべてのシナリオに通用するような「正しい方法」などありません。 しかしそれであっても、信頼できて再利用性も高いレシピの基礎として使用できるようなガイドラインを約束通り以下に提示します。

ガイドライン

  • データフロー処理を行う場合は2路線鉄道モデルを採用すること。
  • ユースケースの各ステップに対応する関数を作成すること。 各ステップ用の関数はさらに小さな関数(たとえば検証用の関数)を組み合わせて作成すること。
  • 関数を連結する場合は標準の合成演算子(>>)を使用すること。
  • スイッチをフローに組み込む場合はbindを使用すること。
  • 1路線関数をフローに組み込む場合はmapを使用すること。
  • その他の種類の関数をフローに組み込む場合は適切なアダプターブロックを作成してそれを使用すること。

これらのガイドラインを採用すると、最終的なコードが簡潔でエレガントではなくなってしまうかもしれません。 しかしその一方で、一貫したモデルを採用できるため、 メンテナンスが必要になった場合でも理解しやすいものになるでしょう。

これまでのコードをこれらのガイドラインに沿って書き換えてみましょう。 特に、最後のusecaseでは>>しか使用していない点に注目してください。

open RailwayCombinatorModule

let (&&&) =
    let addSuccess r1 r2 = r1 // 1番目を返す
    let addFailure s1 s2 = s1 + "; " + s2 // 文字列連結
    plus addSuccess addFailure

let combinedValidation =
    validate1
    &&& validate2
    &&& validate3

let canonicalizeEmail input =
    { input with email = input.email.Trim().ToLower() }

let updateDatabase input =
    () // 今のところはダミー

// 例外処理用の新しい関数
let updateDatabaseStep =
    tryCatch (tee updateDatabase) (fun ex -> ex.Message)

let usecase =
    combinedValidation
    >> map canonicalizeEmail
    >> bind updateDatabaseStep
    >> log

最後にもう1つ提案しておきます。 もしも非エキスパートで構成されたチームで作業を進める場合、見慣れないシンボルは人々を動揺させるだけです。 そこで、演算子用のガイドラインをいくつか挙げておきましょう:

  • >>|>よりも「奇妙な」演算子を使用しないこと。
  • つまり具体的には全員の理解が得られないのであれば>>=とか>=>といった演算子は 使うべきではありません
  • ただしモジュールの先頭や、関数内で演算子を定義する場合は例外とします。 たとえば検証用のモジュールで&&&演算子を定義して、モジュール内でこの演算子を使うのは構いません。

前回 次回
←1. 完全なプログラムを設計して作成する方法 3. プロジェクト内のモジュールを整理する→