Optionに見るコンピュテーション式のつくり方

またの名を入門コンピュテーション式(嘘

この記事は、「コンピュテーション式ってどうやって作ればいいの?」に対する自分なりの回答です。

matchのネスト

optionを返す3つの関数f, g, hがあったとします。 で、このような処理がしたいとしましょう。

let niceFunction (arg1, arg2, arg3) =
  match f arg1 with
  | Some x ->
      match g arg2 with
      | Some y ->
          match h arg3 with
          | Some z ->
              Some (x, y, z)
          | None -> None
      | None -> None
  | None -> None

この関数は、3つの関数すべてが成功したときだけ、その結果をまとめて成功として返しています。 それ以外は何もせずに失敗として返しています。

このように、「すべて成功したときだけ計算したい」という状況はよく起こります。 例えば、fがDBから何か取得する関数、gファイルシステムからファイルを取得する関数、hがネットワークから何か取得する関数だとして、 これらすべてが成功したらそれらの情報を使って何か処理がしたい、というケースが考えられます。

これを毎回書くのはだるいですし、計算のもとになるソースが増えれば増えるほど、matchがネストしていきます。 どうにかできないでしょうか?

コードの「形」の共通部分

ここで注目してほしいのは、このコードの構造が再帰構造になっている点です。

match <expr> with
| Some <v> ->
    +----------------+
    | 全体と似た構造 |
    +----------------+
| None -> None

このように、Someの場合の処理に、全体の構造に似た形が再び現れることが分かります。 まずは、この部分をカスタマイズできるように関数の引数として渡せるようにしてみましょう。

コードの再帰構造の関数化

<expr>の部分と、Someの場合に行う処理を引数に取ればよさそうです。 また、Someの場合に行う処理では、Someが持っている値も必要になるため、関数の引数として渡すことにします。

let matchSome target procForSome =
  match target with
  | Some v -> procForSome v
  | None -> None

この関数は、procForSomeに渡す関数の中で再びmatchSome関数が呼び出されることを想定しています。 これを使うと、最初のコードはこう書けます。

let niceFunction (arg1, arg2, arg3) =
  matchSome (f arg1) (fun x ->
  matchSome (g arg2) (fun y ->
  matchSome (h arg3) (fun z ->
    Some (x, y, z)
  )))

無名関数のネストはありますが、疑似的にフラットに出来ました。 ghの間に何か挟まってきても、

let niceFunction (arg1, arg2, arg3) =
  matchSome (f arg1) (fun x ->
  matchSome (g arg2) (fun y ->
  matchSome (hoge (arg1, arg2, arg3)) (fun w ->
  matchSome (h arg3) (fun z ->
    Some (x, y, w, z)
  ))))

なんとか対処できます。 ただ、末尾の閉じカッコはどんどん増えていきます。 どうにかならないでしょうか・・・

無名関数によるletの除去

さて、ちょっと話題を変えて、letを除去する方法を考えてみましょう。

let x = 2
let y = "aaa"
let z = [0..x]
printfn "%A" (x, y, z)

F#で変数を導入したい場合に真っ先に思い浮かぶのがletです。 しかし、他にも変数が導入できるものがあります。 関数の引数です*1

let x = 42
printfn "%A" x

このコードを無名関数を使って書き直すと、

(fun x -> printfn "%A" x) 42

となります。 これを参考に、最初のコードを書き替えてみます。

(fun x -> (fun y -> (fun z -> printfn "%A" (x, y, z)) [0..x]) "aaa") 2

これでは読めないので、|>を使ってさらに変形します。

2      |> (fun x ->
"aaa"  |> (fun y ->
[0..x] |> (fun z ->
  printfn "%A" (x, y, z))))

2xに入れ、"aaa"yに入れ、[0..x]zに入れ、本体を実行しているように見えませんか?

コンピュテーション式の導入

さて、話を元に戻しましょう。

let niceFunction (arg1, arg2, arg3) =
  matchSome (f arg1) (fun x ->
  matchSome (g arg2) (fun y ->
  matchSome (hoge (arg1, arg2, arg3)) (fun w ->
  matchSome (h arg3) (fun z ->
    Some (x, y, w, z)
  ))))

このコードの末尾部分のカッコをどうにかしたいのでした。 そして、letは無名関数で除去できる、ということを見ました。

では、無名関数をletで除去できないでしょうか・・・? これは残念ながらできません。 ですが、コンピュテーション式を使えばlet!という構文を使うことで可能になります。 let!は、簡単にはコンピュテーションビルダーのBindメソッド呼び出しに変形されます。

builder {
  let! v = expr
  ...
}

このコードは、

builder.Bind(expr, (fun v -> ...))

このように変形されます。

ここで、matchSome関数を思い出してください。

let matchSome target procForSome =
  match target with
  | Some v -> procForSome v
  | None -> None

この関数、Bindが要求する形に似ていますね。 実は、matchSome関数は、ほとんどそのままBindとして使えます。

では、コンピュテーションビルダーを定義してみましょう。

type OptionBuilder() =
  member __.Bind(x, f) = matchSome x f
  member __.Return(x) = Some x

let option = OptionBuilder()

これを使えば、元のコード

let niceFunction (arg1, arg2, arg3) =
  matchSome (f arg1) (fun x ->
  matchSome (g arg2) (fun y ->
  matchSome (h arg3) (fun z ->
    Some (x, y, z)
  )))

は、こう書き直せます。

let niceFunction (arg1, arg2, arg3) =
  option {
    let! x = f arg1
    let! y = g arg2
    let! z = h arg3
    return (x, y, z)
  }

フラットになりました!

このように、コンピュテーション式はある種のネスト構造をフラットに(読みやすく、かつ編集しやすく)書けるようにする機能を持ちます*2

コンピュテーション式を作るには

自分でコンピュテーション式を作ろうとする場合、その「同じような構造がネストしている」ことを発見できねばなりません。 というか順番が逆で、「同じような構造がネストしている」のがだるいからコンピュテーション式でフラットにするのであって、コンピュテーション式が作りたいからそのような構造を見つけるのではないです。

で、先人はいくつも「同じような構造がネストしている」パターンを見つけてくれており、それぞれに名前まで付けてくれています。

例えば、上で例にした'a optionを対象にしたものは、MaybeモナドやOptionモナドとして広く知られています。 コンピュテーション式を作れるようになるための近道、それは色々なモナドを理解し、そのコンピュテーション式を実際に作ってみることです。

余談

モナドさえ理解できればコンピュテーション式は作れるようになるか、というとそういうわけではありません。 コンピュテーション式はモナド以上のことができてしまうため、モナドだけわかってもすべての機能の実装はできません(し、間違った実装を提供してしまいます)。

また、全然モナドじゃないコンピュテーション式も作れます。 作れますが、それが実用的になることはそうそうないでしょう。 コンピュテーション式の基本、それはモナドです。

*1:forやmatchも変数は導入できますが、ここではこれらを使っても意味がないので無視します

*2:それだけではないんですが、基本はこれです