M.Hiroi's Home Page

お気楽 F# プログラミング超入門

コンピュテーション式とモナド


Copyright (C) 2022 Makoto Hiroi
All rights reserved.

はじめに

F# でモナドを扱う場合、「コンピュテーション式 (Computation Expressions, 計算式)」を使うと便利です。コンピュータ (F#) ではいろいろな計算が行われます。計算方法は逐次処理だけではなく並行 (並列) 処理がありますし、正格評価や遅延評価、リストモナドのような非決定性計算もあります。このような計算方法を抽象化するための機能がコンピュテーション式です。

F# には以下に示す組み込みのコンピュテーション式が用意されています。

「シーケンス」と「遅延評価 (lazy 式)」は拙作のページで取り上げました。このほかにも、F# では自分でコンピュテーション式を定義することができます。今回はモナドを例題にコンピュテーション式の基本的な使い方について簡単に説明します。

●コンピュテーション式の定義

コンピュテーション式はクラスから生成します。クラスを定義するとき、その中で特殊なメソッドを定義すると、そのクラスはコンピュテーション式を生成するクラスとして扱われます。これを「ビルダークラス」といいます。

モナドの場合、基本的な動作は以下に示す 2 つのメソッドを定義するだけです。

型はジェネリックで表記されているので難しくみえますが、Bind の型はモナドそのものです。最初の引数 M<'T> はデータを格納する型 (モナド) をジェネリックで表しています。もう一つの引数の型は 'T -> M<'U> なので、値を同じモナドに格納して返すことが分かります。Return は値をモナドに包んで返します。値を x とすると、option であれば Some x に、Result であれば Ok x に、List であれば [x] となります。

このほかに、メソッド Zero と ReturnFrom を定義しておくと便利です。

ReturnFrom は引数 (モナド) をそのまま返します。Zero は if 式で else が省略されたときに呼び出されます。option, Reuslt, List でいえば失敗を表すデータ None, Error, [] を返します。

●コンピュテーション式の構文

コンピュテーション式 cexpr の中では、以下に示す特別な構文を使用することができます。

cexpr { let! ... }
cexpr { do! ... }
cexpr { yield ... }
cexpr { yield! ... }
cexpr { return ... }
cexpr { return! ... }
cexpr { match! ... }

モナドで使用するのは let!, do!, return, return! です。let! と do! は Bind で指定した関数を呼び出します。関数を bind func expr とすると、let! と do! はおおむね次のように動作します。

1. cexpr {
     let! x = expr
     ...
   }
   => expr |> bind (fun x ->  ...)

2. cexpr {
     do! expr
     ...
   }
   => expr |> bind (fun _ ->  ...)

let! と do! は式 expr を評価します。その結果はモナドになります。let! はそこから値を取り出して変数にセットします。do! は取り出した値を捨て去ります。そして、それ以降に記述されている式 ... を順番に実行します。つまり、それ以降で let! や do! を記述すれば、各処理を連結していくことができるわけです。

値を返すときは return または return! を使います。return はメソッド Return で指定した処理を、return! はメソッド ReturnFrom で指定した処理を行います。つまり、return は引数をモナドに包んで返し、return! は引数をそのまま返します。

●モナドの実装

それでは実際に、コンピュテーション式を使って option, Result, List のモナドを作ってみましょう。

リスト : コンピュテーション式によるモナドの実装 (compexpr.fsx)

// option
type OptionMonadBuilder() =
  member this.Bind(m, f) = Option.bind f m
  member this.Return(x) = Some x

let optionMonad = OptionMonadBuilder()

// Result
type ResultMonadBuilder() =
  member this.Bind(m, f) = Result.bind f m
  member this.Return(x) = Ok x

let resultMonad = ResultMonadBuilder()

// List
type ListMonadBuilder() =
  member this.Bind(m, f) = List.collect f m
  member this.Return(x) = [x]
  member this.ReturnFrom(x) = x
  member this.Zero() = []

let listMonad = ListMonadBuilder()

let guard = function
  true ->  [()]
| false ->  []
> #load "compexpr.fsx";;
[読み込み中 /home/mhiroi/fsharp/compexpr.fsx]
namespace FSI_0002.Compexpr
  type OptionMonadBuilder =
    new: unit -> OptionMonadBuilder
    member Bind: m: 'b option * f: ('b -> 'c option) -> 'c option
    member Return: x: 'a -> 'a option
  val optionMonad: OptionMonadBuilder
  type ResultMonadBuilder =
    new: unit -> ResultMonadBuilder
    member Bind: m: Result<'c,'d> * f: ('c -> Result<'e,'d>) -> Result<'e,'d>
    member Return: x: 'a -> Result<'a,'b>
  val resultMonad: ResultMonadBuilder
  type ListMonadBuilder =
    new: unit -> ListMonadBuilder
    member Bind: m: 'd list * f: ('d -> 'e list) -> 'e list
    member Return: x: 'c -> 'c list
    member ReturnFrom: x: 'b -> 'b
    member Zero: unit -> 'a list
  val listMonad: ListMonadBuilder
  val guard: _arg1: bool -> unit list

Bind はモジュールに用意されている関数 Option.bind, Result.bind, List.collect を使えば簡単です。あとは、ビルダークラスを評価して、その結果を変数にセットします。変数名がコンピュテーション式の名前になります。あとは 名前 { ... } でコンピュテーション式を使用することができます。

●簡単な実行例

それでは実行してみましょう。

> open Compexpr;;
> let add x y =
-   optionMonad {
-     let! a = x
-     let! b = y
-     return (a + b)
-   };;
val add: x: int option ->  y: int option ->  int option

> add (Some 10) (Some 20);;
val it: int option = Some 30

> add (Some 10) None;;
val it: int option = None

> add None (Some 20);;
val it: int option = None

> let resultAdd x y =
-   resultMonad {
-     let! a = x
-     let! b = y
-     return (a + b)
-   };;
val resultAdd: x: Result<int,'a> ->  y: Result<int,'a> ->  Result<int,'a>
> resultAdd ((Ok 10):Result<int,string>) ((Ok 20):Result<int,string>);;
val it: Result<int,string> = Ok 30

> resultAdd ((Ok 10):Result<int,string>) ((Error "oops"):Result<int,string>);;
val it: Result<int,string> = Error "oops"

> resultAdd ((Error "oops"):Result<int,string>) ((Ok 20):Result<int,string>);;
val it: Result<int,string> = Error "oops"

関数 add と resultAdd は、引数に option, Result を受け取り、その値を足し算します。どちらも let! で引数から値を取り出し、加算した値を return で返します。引数が None や Error であれば、None や Error を返します。モナドとして正常に動作していますね。

> listMonad {
-   let! x = [1..4]
-   let! y = [11..14]
-   return (x, y)
- };;
val it: (int * int) list =
  [(1, 11); (1, 12); (1, 13); (1, 14); (2, 11); (2, 12); (2, 13); (2, 14);
   (3, 11); (3, 12); (3, 13); (3, 14); (4, 11); (4, 12); (4, 13); (4, 14)]

> listMonad {
-   let! x = [1..4]
-   let! y = [11..14]
-   do! guard(x + y > 15)
-   return (x, y)
- };;
val it: (int * int) list =
  [(2, 14); (3, 13); (3, 14); (4, 12); (4, 13); (4, 14)]

> listMonad {
-   let! x = [1..4]
-   let! y = [11..14]
-   if x + y > 15 then return (x, y)
- };;
val it: (int * int) list =
  [(2, 14); (3, 13); (3, 14); (4, 12); (4, 13); (4, 14)]

リストモナドの場合、コンピュテーション式でも内包表記のように使うことができます。do! を使ってガード (関数 guard) を呼び出すこともできます。今回は Zero() を定義してあるので、同様なことを if で行うこともできます。

●順列の生成

もちろん、リストモナドで順列を生成することも簡単です。

リスト : 順列の生成

let permMonad n (xs: 'a list) =
  let rec iter m ys =
    listMonad {
      if m = n then return (List.rev ys)
      else
        let! x = xs
        do! guard(not(List.contains x ys))
        return! (iter (m + 1) (x::ys))
    }
  iter 0 []
> permMonad 3 [1..3];;
val it: int list list =
  [[1; 2; 3]; [1; 3; 2]; [2; 1; 3]; [2; 3; 1]; [3; 1; 2]; [3; 2; 1]]

> permMonad 3 [1..4];;
val it: int list list =
  [[1; 2; 3]; [1; 2; 4]; [1; 3; 2]; [1; 3; 4]; [1; 4; 2]; [1; 4; 3]; [2; 1; 3];
   [2; 1; 4]; [2; 3; 1]; [2; 3; 4]; [2; 4; 1]; [2; 4; 3]; [3; 1; 2]; [3; 1; 4];
   [3; 2; 1]; [3; 2; 4]; [3; 4; 1]; [3; 4; 2]; [4; 1; 2]; [4; 1; 3]; [4; 2; 1];
   [4; 2; 3]; [4; 3; 1]; [4; 3; 2]]

局所関数 iter の返り値はモナドなので、return! で値を返すことに注意してください。

●論理パズル

最後に簡単な論理パズルを解いてみましょう。

[問題]

3人の友達が、あるプログラミング競技会で1位、2位、3位になった。この3人は、名前も、好きなスポーツも、国籍も異なる。Michael はバスケットが好きで、アメリカ人よりも上位であった。イスラエル人の Simon はテニスをする者よりも上位であった。クリケットをするものが1位であった。誰がオーストラリア人か? Richard はどのようなスポーツをするか?

簡単な論理パズルなので、プログラムを作る前に考えてみてください。

●型の定義

最初に必要となる型を定義しましょう。

リスト : 型の定義

type Nation = US | IL | AU
type Sports = Basket | Cricket | Tennis
type Name   = Michael | Simon | Richard
type Person = {name: Name; rank: int; nation: Nation; sports: Sports}

このデータをリストモナドで作成します。次のリストを見てください。

リスト : データの生成

let makePerson name =
  listMonad {
    let! r = [1; 2; 3]
    let! n = [US; IL; AU]
    let! s = [Basket; Cricket; Tennis]
    return {name = name; rank = r; nation = n; sports = s}
  }

let! で順位 (1, 2, 3)、国籍 (US, IL, AU)、スポーツ (Basket, Cricket, Tennis) の中から要素を一つ選びます。あとは、レコード Person を生成して return で返すだけです。

●補助関数の作成

次は問題を解くための補助関数を作ります。

リスト : 補助関数の定義

// 国籍を探す
let findNation n xs =
  List.find (fun p -> p.nation = n) xs

// スポーツを探す
let findSports s xs =
  List.find (fun p -> p.sports = s) xs

// 名前以外で重複した要素があるか
let isDuplicate x y =
  x.rank = y.rank || x.nation = y.nation || x.sports = y.sports

// 同じ要素があるか
let check a b c =
  isDuplicate a b || isDuplicate a c || isDuplicate b c

findNation は引数のリスト中から国籍が引数 n と等しい要素を返します。findSports は好きなスポーツが引数 s と等しい要素を返します。isDuplicate は引数 a と b に重複した要素があれば true を返します。要素が全て異なる場合は false を返します。関数 check は isDuplicate を呼び出して、引数 a, b, c に重複した要素があれば true を返します。

●論理パズルの解法

論理パズルの解法プログラムは次のようになります。

リスト : 論理パズルの解法

let solver () =
  listMonad {
    let! m = makePerson(Michael)
    let! s = makePerson(Simon)
    let! r = makePerson(Richard)
    do! guard(not(check m s r))
    do! guard(m.sports = Basket)
    do! guard(m.nation <> US)
    do! guard(s.nation = IL)
    do! guard(m.rank < (findNation US [m; s; r]).rank)
    do! guard(s.rank < (findSports Tennis [m; s; r]).rank)
    do! guard((findSports Cricket [m; s; r]).rank = 1)
    return (m, s, r)
  }

最初に makePerson でデータを作成し、let! で要素を一つ選んで局所変数 m, s, r にセットします。そして、check で順位、国籍、スポーツで要素が重複していないかチェックします。あとは問題の条件を guard でチェックしていくだけです。

  1. Michael の好きなスポーツはバスケットである。
  2. Michael の国籍はアメリカではない。
  3. Simon の国籍はイスラエルである。
  4. Michael は国籍がアメリカの人よりも上位である。
  5. Simon はテニスが好きな人よりも上位である。
  6. クリケットが好きな人が1位である。

最後に、return で見つけた解をタプルに格納して返します。とても簡単ですね。実行結果は次のようになります。

> solver();;
val it: (Person * Person * Person) list =
  [({ name = Michael
      rank = 2
      nation = AU
      sports = Basket }, { name = Simon
                           rank = 1
                           nation = IL
                           sports = Cricket }, { name = Richard
                                                 rank = 3
                                                 nation = US
                                                 sports = Tennis })]

解は 1 通りで、1位が Simon, 2位が Michael, 3位が Richard になります。ちなみに、最後の条件がない場合は 2 通りの解が出力されます。興味のある方は試してみてください。


●プログラムリスト

//
// compexpr.fsx : コンピュテーション式によるモナドの実装
//
//                Copyright (C) 2022 Makoto Hiroi
//

// option
type OptionMonadBuilder() =
  member this.Bind(m, f) = Option.bind f m
  member this.Return(x) = Some x

let optionMonad = OptionMonadBuilder()

// Result
type ResultMonadBuilder() =
  member this.Bind(m, f) = Result.bind f m
  member this.Return(x) = Ok x

let resultMonad = ResultMonadBuilder()

// List
type ListMonadBuilder() =
  member this.Bind(m, f) = List.collect f m
  member this.Return(x) = [x]
  member this.ReturnFrom(x) = x
  member this.Zero() = []

let listMonad = ListMonadBuilder()

// ガード
let guard = function
  true -> [()]
| false -> []

// 順列の生成
let permMonad n (xs: 'a list) =
  let rec iter m ys =
    listMonad {
      if m = n then return (List.rev ys)
      else
        let! x = xs
        do! guard(not(List.contains x ys))
        return! (iter (m + 1) (x::ys))
    }
  iter 0 []

// 論理パズル
type Nation = US | IL | AU
type Sports = Basket | Cricket | Tennis
type Name   = Michael | Simon | Richard
type Person = {name: Name; rank: int; nation: Nation; sports: Sports}

let makePerson name =
  listMonad {
    let! r = [1; 2; 3]
    let! n = [US; IL; AU]
    let! s = [Basket; Cricket; Tennis]
    return {name = name; rank = r; nation = n; sports = s}
  }

// 国籍を探す
let findNation n xs =
  List.find (fun p -> p.nation = n) xs

// スポーツを探す
let findSports s xs =
  List.find (fun p -> p.sports = s) xs

// 名前以外で重複した要素があるか
let isDuplicate x y =
  x.rank = y.rank || x.nation = y.nation || x.sports = y.sports

// 同じ要素があるか
let check a b c =
  isDuplicate a b || isDuplicate a c || isDuplicate b c

let solver () =
  listMonad {
    let! m = makePerson(Michael)
    let! s = makePerson(Simon)
    let! r = makePerson(Richard)
    do! guard(not(check m s r))
    do! guard(m.sports = Basket)
    do! guard(m.nation <> US)
    do! guard(s.nation = IL)
    do! guard(m.rank < (findNation US [m; s; r]).rank)
    do! guard(s.rank < (findSports Tennis [m; s; r]).rank)
    do! guard((findSports Cricket [m;s;r]).rank = 1)
    return (m, s, r)
  }

初版 2022 年 6 月 4 日