『関数型ドメインモデリング』勉強会 第9章 実装:パイプラインの合成(後編)

  • 2025.05.08
56
『関数型ドメインモデリング』勉強会 第9章 実装:パイプラインの合成(後編)

当記事は、 関数型ドメインモデリング (達人出版会) を参考書として社内で行った勉強会の内容をまとめたものです。この記事の内容が気になった方は、ぜひ本書も参照いただければと思います。


前編までで主要なテクニックを一通り扱ったので、後半ではより深堀した内容を扱っていく。

部分適用による依存関係の注入

本書の9.5節や9.6節では、部分適用によって依存関係を注入して関数のシグニチャの不整合を調整する手法が紹介されている。例えば9章で実装しているワークフロー全体は大まかには下記のようになる。

let placeOrder : PlaceOrderWorkflow =
  fun unvalidatedOrder ->
   unvalidatedOrder
   |> validateOrder
   |> priceOrder
   |> acknowledgeOrder
   |> createEvents

しかしvalidateOrderの型は下記のようになっている。

type ValidateOrder =
    CheckProductCodeExists
    -> CheckAddressExists
    -> UnvalidatedOrder
    -> ValidatedOrder

依存関係としてCheckProductCodeExistsとCheckAddressExistsを引数として持っているので、このままでは合成できない。

そのために、本書では部分適用を用いてunvalidatedOrderを1引数の関数にする事でパイプラインの合成を行えるようにしている。

let validateOrderWithDependenciesBakedIn =
  validateOrder checkProductCodeExists checkAddressExists
// type validateOrderWithDependenciesBakedIn = UnvalidatedOrder -> ValidatedOrder

流石にこれは名前が悪いので、シャドーイングで元の名前を上書きしたり、アポストロフィを付けて区別する事が紹介されている。

let validateOrder =
  validateOrder checkProductCodeExists checkAddressExists
let validateOrder' =
  validateOrder checkProductCodeExists checkAddressExists

通常はこれで良いのだが、本書ではさりげなく「モナド」という言葉が登場する。部分適用との違いや適用シーン、モナドの考え方を学ぶために、モナドについてもう少し掘り下げてみる。

モナドは圏論的には自己関手圏のモノイド対象とも言われるが、実際には『文脈を扱う合成のルール』として理解すれば、プログラムを書く上での実用としては十分な事が多いと思われる。実際のところ、モナドの効能として挙げられるものとしては

  • 失敗するかもしれない計算(Maybe, 本書でいうResult)や非同期処理(Async)、IO全般
  • 状態遷移
  • 環境へのアクセス

こういったものが挙げられる。そして先に出した依存関係の話題については、「ワークフローの実装者の扱う環境外部に存在する関数に依存している」、即ち「外部環境の読み出し」というように考える事ができる。このような外部環境の値の読み出しにワークフローのある関数が依存しており、結果としてワークフロー全体がその外部環境に依存している構造になる。このような依存を明示的に取り扱うには、Readerモナドのような抽象が有効となると言われている。

というわけで、実際にモナドがどのようなものになるかを、本書の主題とはずれるが前述の理由のために見ていく事にする。なお、以降のモナドの定義は下記の記事を参考にした。

ReaderモナドのようなものをF#で実装すると、その型は以下のようになる。

type Reader<'e, 't> = Reader of 'e -> 't

このReader ofという書き方だが、これは「'environment -> 'a 型の関数を持つ Reader 型の値」という意味で、関数をラップしてReader型を作るという意味合い。

そしてある関数をモナドに変換するための関数が必要になるので、それを定義する。

let retn x =
    let f e = x // f: e -> x
    Reader f

これは任意の関数をReaderモナドに変換するためのもので、Readerで読み取る環境の事は無視して単に元の関数を評価するだけのものである。(そのため、ここでは)なぜこれが必要かというと、関数合成で型を合わせるためだ。

続いてモナドには結合則が必要なので、それを定義する。

let run e (Reader f) = f e

let (>>=) f1 f2 =
    let newAction env =
      let x = run env f1
      run env (f2 x)
    Reader newAction

これは何をやっているのかというと、まずrunという関数を定義している。これは環境と、その環境に依存するReaderモナドを与える事で、Readerモナドでラップされている関数に環境を与えて実行するという関数になっている。

続いて>>=の定義だが、これはReaderモナドのf1と、元のf1の戻り値を受けてReaderを返す関数f2の合成を行っている。そのために、まず内部でReader f1run eを適用して評価し、その結果のxf2に適用して、戻り値にrun eをさらに適用するという関数をローカルに定義している。そのローカル関数をReaderでラップしたものを返すと、モナドの結合になっている。

これらを総合すると、パイプラインはこのように実装できる。

type Dependencies = {
    CheckProductCodeExists: CheckProductCodeExists
    CheckAddressExists: CheckAddressExists
}
type ValidateOrder' = Reader<Dependencies, UnvalidatedOrder -> ValidatedOrder>

let dep = {
    CheckProductCodeExists: CheckProductCodeExists,
    CheckAddressExists: CheckAddressExists
}

let validateOrder' dep unvalidatedOrder = validateOrder dep.CheckProductCodeExists dep.CheckAddressExists unvalidatedOrder

let validateOrderMonad = Reader (fun dep -> validateOrder' dep)

let workflow =
  validateOrderMonad
  >>= (fun validatedOrder -> 
         Reader (fun dep ->
           let priced = priceOrder validatedOrder
           let acknowledged = acknowledgeOrder priced
           createEvents acknowledged))

let placeOrder unvalidatedOrder =
    let (Reader runWorkflow) = workflow
    runWorkflow { CheckProductCodeExists = ...; CheckAddressExists = ... } unvalidatedOrder

部分適用とモナドどちらが良いか?

部分適用の利点は、シンプルで直接的であることに尽きる。実際9章で紹介されているような依存関係のケースでは、部分適用で考えた方が遥かに直観的でわかりやすい。

ではモナドがどのような時に効力を発揮するかというと、依存関係が多い場合や、複雑な文脈(エラー処理や状態管理など)を扱う場合に有用と言われている。特に様々な文脈が入り混じっている場合にはモナドを使って合成していった方が最終的にコードの拡張性や個々の関数の独立性が高くなる可能性もある。

実際に上記の説明では細かい部分は省いているが、AsyncResultが入ってくる都合上Result型もモナドとして捉えて、「失敗していたらその後の処理をスキップしてエラー型を返し、成功していたら続ける」という結合則を定義する事で、関数合成を容易に行うようなやり方も考えられる。そのため、9章の内容ではむしろResultやAsyncResultの方がモナドの適用事例としてはご利益がありそうだ。

AsyncResultモナドを作る

9.3節で型の持ち上げや関数アダプターを定義したが、実はモナドのやっている事はそれとかなり近い事がある。例えばvalidateOrderの戻り値はAsyncResult<ValidatedOrder,ValidationError>だが、これをモナドとして捉えると、簡略化して書くとこんな感じになる。

type AsyncResult<'T, 'Error> = Async<Result<'T,'Error>>
type (>>=) = (AsyncResult<'a,'b> -> ('b -> AsyncResult<'a,'c>) -> AsyncResult<'a,'c>)

let (>>=) x y = // x: AsyncResult<'a,'b>, y: 'b -> AsyncResult<'a,'c>
  async {
    let! result = x
    match result with
    | Ok value -> return! y value
    | Error err -> return Error err
  }

let retn a = Ok a

これにより、仮にワークフローの全ての関数がAsyncResultを返す関数だった場合、下記のように結合することができる。

let placeOrder : PlaceOrderWorkflow =
  fun unvalidatedOrder ->
   unvalidatedOrder
   >>= validateOrder
   >>= priceOrder
   >>= acknowledgeOrder
   >>= createEvents

この時、ワークフローの各ステップで直前のステップの結果が成功か失敗かといった事を考える必要はない。ある意味では、関数アダプターの頻出パターンを定義して簡単に使えるようにしている、という見方もできる。

関数合成によるワークフローの構築のまとめ

9章の内容を総括すると、部分適用、関数アダプター、各種の型システムを用いる事で、個別のワークフローの各ステップを厳密に型付けする事で実装のミスを減らし、またそれらを単純な1引数1出力の結合可能な(=再利用性の高い)関数として扱えるようにする事で、ワークフロー全体の見通しを極めてクリアにしている。他の関数プログラミングというかHaskellなどで良く出てくるモナドの話も、大きな目的意識の一つとしては、関数の結合に関する便利な仕組みを取り入れる事で、関数の独立性や再利用性を高めている事には違いなく、本書で紹介されている手法とその哲学を共有するところは多いように考えられる。