Twitterで流れてきたListReaderコンピュテーション式の解説

先日、Twitterid:n7shi さんが面白いコードを投下していた。

これに対しての私のreplyが以下。

これらのコンピュテーション式はちょっとわかりづらいのでちょっとした解説を試みる。

7shiさん版ListReaderBuilder

7shiさんのコードに少し手を加えたものが以下のコードである。 元コードとの挙動に差はない。

type ListReaderBuilder() =
  member __.Bind(_, f) = function
    | x::xs -> f x xs
    | [] -> ()
  member __.Zero() = fun _ -> ()
let listReader = ListReaderBuilder()
 
let test = listReader {
  let! a = () in printfn "%d" a
  let! a = () in printfn "%d" a
}
 
test [1;2;3]
printfn "---"
test [4]

このコードの出力は以下のようになる。

1
2
---
4

このビルダーで使われる変換規則は以下の通り。

  1. T(let! p = e in ce, V, C, q) = T(ce, V ⊕ var(p), λv.C(b.Bind(src(e),fun p -> v), q)
  2. T(do e in ce, V, C, q) = T(ce, V, v.C(e; v), q)
  3. T(e;, V, C, q) = C(e;b.Zero())

testを展開すると以下のようになるだろう(実際はもっと異なるかもしれないが、私はコンパイラではないのでわからない)。

// val test: int list -> unit
let test =
  // let! a = () in ... の展開
  listReader.Bind(
    (),
    fun a ->
      // do printfn "%d" a in ... の展開
      printfn "%d" a
      // let! a = () in ... の展開
      listReader.Bind(
        (),
        fun a ->
          // printfn "%d" a; の展開
          printfn "%d" a
          listReader.Zero()
      )
  )
  • Bindメソッドのシグネチャ'T * ('U -> 'U list -> unit) -> ('U list -> unit)
  • Zeroメソッドのシグネチャunit -> ('a -> unit) ((Bindの型パラメータと異なることを強調したかったのであえて'a表記にしている))
    • つまり、分解され残ったlistはZeroで返されるλ式によって捨てられる
    • testでいうと、2回しかBindを呼んでいないため、残った[3]がZeroで返るλ式に渡される
  • printfn部分でaはintに固定される

ことを考えれば、型に問題はないことに気づくだろう。

考察

面白いコードだが、個人的に気になったことがあった。

let! a = () in ...がなんだか冗長に見えるのだ。 do! ...のほうが短くて見やすい気がする。

いじってみた版

type ListReaderBuilder() =
  member __.Bind(g, f) = function
    | x::xs -> f (g x) xs
    | [] -> ()
  member __.Return(_) = fun _ -> ()
let listReader = ListReaderBuilder()
 
let test = listReader {
  do! printfn "%d"
  do! printfn "%d"
}

このビルダーで使われる変換規則は以下の通り。

  1. T(let! p = e in ce, V, C, q) = T(ce, V ⊕ var(p), λv.C(b.Bind(src(e),fun p -> v), q)
  2. T(do! e in ce, V, C, q) = T(let! () = e in ce, V, C, q)
  3. T(do! e;, V, C, q) = T(let! () = src(e) in b.Return(), V, C, q)

同じようにtestを展開してみる。

// val test: int list -> unit
let test =
  // do! printfn "%d" in ... の展開
  listReader.Bind(
    (printfn "%d"),
    fun () ->
      // do! printfn "%d"; の展開
      listReader.Bind(
        (printfn "%d"),
        fun () -> listReader.Return(())
      )
  )
  • Bindメソッドのシグネチャ('T -> 'U) * ('U -> 'T list -> unit) -> ('T list -> unit)
  • Returnメソッドのシグネチャ'a -> ('b -> unit)
    • 最終的に残ったリストはReturnが返すλ式で捨てられる
  • g xprintfn "%d" xが実行され、その戻り値unitfに渡る

こちらも型は問題ないことがわかるだろう。

やりたいことができるか試す

その後の7shiさんとのやり取りで、以下のようなことがやりたかったというコメントを頂いた。

不揃いなデータをコンピュテーション式で処理 - Qiita

改変後のコードでも同様の挙動にできるか試してみよう。

ビルダーとaddPersonシグネチャの順序、コンピュテーション式を書き換える。

// 変更したコードのみ載せています
type ListReaderBuilder() =
  member __.Bind(g, f) = function
    | x::xs -> f (g x) xs
    | [] -> ()
  member __.Return(_) = fun _ -> ()

...

let addPerson data name = if name <> "" then persons.[name] <- data

...

        row |> listReader {
            let! company = id
            do! addPerson (company, "会長")
            do! addPerson (company, "社長")
            do! addPerson (company, "副社長")
            do! addPerson (company, "専務")
        }

...

最初の値は他の場所で使いたいのでidで取得する。 残りはprintfnの時と同じ要領だ。

実際に試してみると同じ結果を返した。

おわりに

結果は同じでも、ビルダーの定義次第でコンピュテーション式の書き方が変わる。 他言語のマクロのように自由ではない限られた変換で何が表現できるのか、疑問は尽きない。