実例によるPureScript

ウェブのための関数型プログラミング

Phil Freeman, "PureScript by Example - Functional Programming for the Web"

11 モナドの探求

11.1 この章の目標

この章の目標は、異なるモナドから提供された副作用を合成する方法を提供するモナド変換子(monad transformers)について学ぶことです。NodeJSのコンソール上で遊ぶことができる、テキストアドベンチャーゲームを題材として扱います。ゲームの様々な副作用(ロギング、状態、および設定)がすべてモナド変換子スタックによって提供されます。

11.2 プロジェクトの準備

このモジュールのプロジェクトでは以下のBower依存関係が新たに導入されます。

11.3 ゲームの遊びかた

プロジェクトを実行するには、gruntでソースコードをビルドしてから、NodeJSにコンパイルされたJavaScriptを渡します。

$ node dist/Main.js

デフォルトでは使い方が表示されます。

node ./dist/Main.js -p <player name>

Options:
  -p, --player  Player name  [required]
  -d, --debug   Use debug mode

Missing required arguments: p
The player name is required

-pオプションを使ってプレイヤー名を提供してください。

node dist/Main.js -p Phil
> 

プロンプトからは、 lookinventorytakeusenorthsoutheastwestなどのコマンドを入力することができます。--debugコマンドラインオプションが与えられたときには、ゲームの状態を出力するためのdebugコマンドも使えます。

ゲームは2次元の碁盤の目の上でプレイし、コマンド northsoutheastwestを発行することによってプレイヤーが移動します。ゲームにはアイテムのコレクションがあり、プレイヤーの所持アイテム一覧を表したり、ゲーム盤上のその位置にあるアイテムの一覧を表すのに使われます。takeコマンドを使うと、プレイヤーの位置にあるアイテムを拾い上げることができます。

参考までに、このゲームのひと通りの流れは次のようになります。

$ node dist/Main.js -p Phil

> look
You are at (0, 0)
You are in a dark forest. You see a path to the north.
You can see the Matches.

> take Matches
You now have the Matches

> north
> look
You are at (0, 1)
You are in a clearing.
You can see the Candle.

> take Candle
You now have the Candle

> inventory
You have the Candle.
You have the Matches.

> use Matches
You light the candle.
Congratulations, Phil!
You win!

このゲームはとても単純ですが、この章の目的はpurescript-transformersパッケージを使用してこのようなのゲームを素早く開発できるようにするライブラリを構築することです。

11.4 Stateモナド

purescript-transformersパッケージで提供されるモナドをいくつか見てみましょう。

最初の例は、純粋な変更可能状態を提供するStateモナドです。すでに Effモナド、すなわちRef作用とST作用によって提供された変更可能な状態という2つのアプローチについては見てきました。Stateは第3の選択肢を提供しますが、これはEffモナドを使用して実装されているわけではありません。

State型構築子は、状態の型s、および返り値の型aという2種類の引数を取ります。「Stateモナド」というように説明はしていますが、実際にはMonad型クラスのインスタンスが用意されているのはStateに対してではなく、任意の型sについてのState s型構築子に対してです。

Control.Monad.Stateモジュールは以下のAPIを提供しています。

get    :: forall s.             State s s
put    :: forall s. s        -> State s Unit
modify :: forall s. (s -> s) -> State s Unit

これはRef作用やST作用が提供するAPIととてもよく似ています。しかし、これらのアクションにRefValSTRefに渡しているような、可変領域への参照を引数に渡さないことに注意してください。StateEffモナドが提供する解決策の違いは、Stateモナドは暗黙的な単一の状態だけを提供していることです。この状態はStateモナドの型構築子によって隠された関数の引数として実装されており、参照は明示的には渡されないのです。

例を見てみましょう。Stateモナドの使いかたのひとつとしては、状態を数として、現在の状態に配列の値を加算していくようなものかもしれません。状態の型 sとしてNumberを選択し、配列の走査にtraverse_を使って、配列の要素それぞれについてmodifyを呼び出すと、これを実現することができます。

import Data.Foldable (traverse_)
import Control.Monad.State
import Control.Monad.State.Class

sumArray :: [Number] -> State Number Unit
sumArray = traverse_ $ \n -> modify (\sum -> sum + n)

Control.Monad.StateモジュールはStateモナドでの計算を実行するための次の3つの関数を提供します。

evalState :: forall s a. State s a -> s -> a
execState :: forall s a. State s a -> s -> s
runState  :: forall s a. State s a -> s -> Tuple a s

3つの関数はそれぞれ初期値の型sと計算の型State s aを引数にとります。evalStateは返り値だけを返し、execStateは最終的な状態だけを返し、runStateTuple a s型の値として表現された返り値と状態の両方を返します。

先ほどのsumArray関数が与えられたとすると、psciで次のようにexecStateを使うと複数の配列内の数字を合計することができます。

> execState (do
    sumArray [1, 2, 3]
    sumArray [4, 5]
    sumArray [6]
  ) 0
  
21

演習

  1. (簡単) 上の例で、execStaterunStateevalStateで 置き換えると結果はどうなるでしょうか。
  2. (やや難しい) Stateモナドとtraverse_関数を使用して、次のような関数を書いてください。

    testParens :: String -> Boolean

    これはStringが括弧の対応が正しく付けられているかどうかを調べる関数です。この関数は次のように動作しなくてはなりません。

    > testParens ""
    true
    
    > testParens "(()(())())"
    true
    
    > testParens ")"
    false
    
    > testParens "(()()"
    false

    ヒント: 入力の文字列を文字の配列に変換するのに、Data.Stringモジュールのsplit関数を使うといいかもしれません。

11.5 Readerモナド

purescript-transformersパッケージではReaderというモナドも提供されています。このモナドは大域的な設定を読み取る機能を提供します。Stateモナドがひとつの可変状態を読み書きする機能を提供するのに対し、Readerモナドはデータの読み取りの機能だけを提供します。

Reader型構築子は、構成の型を表す型r、および戻り値の型aの2つの型引数を取ります。

Contro.Monad.Readerモジュールは以下のAPIを提供します。

ask   :: forall r. Reader r r
local :: forall r a. (r -> r) -> Reader r a -> Reader r a

askアクションは現在の設定を読み取るために使い、localアクションは局所的に設定を変更して計算を実行するために使います。

たとえば、権限で制御されたアプリケーションを開発しており、現在の利用者の権限オブジェクトを保持するのにReaderモナドを使いたいとしましょう。型rを次のようなAPIを備えた型Permissionとして選択します。

hasPermission :: String -> Permissions -> Boolean
addPermission :: String -> Permissions -> Permissions

利用者が特定の権限を持っているかどうかを確認したいときは、askを使って現在の権限オブジェクトを取得すればいつでも調べることができます。たとえば、管理者だけが新しい利用者の作成を許可されているとしましょう。

createUser :: Reader Permissions (Maybe User)
createUser = do
  permissions <- ask
  if hasPermission "admin" permissions
    then Just <$> newUser
    else return Nothing

localアクションを使うと、計算の実行中に`Permissionsオブジェクトを局所的に変更し、ユーザーの権限を昇格させることもできます。

runAsAdmin :: forall a. Reader Permissions a -> Reader Permissions a
runAsAdmin = local (addPermission "admin")

こうすると、利用者がadmin権限を持っていなかった場合であっても、新しい利用者を作成する関数を書くことができます。

createUserAsAdmin :: Reader Permissions (Maybe User)
createUserAsAdmin = runAsAdmin createUser

Readerモナドの計算を実行するには、大域的な設定を与えるrunReader関数を使います。

runReader :: forall r a. Reader r a -> r -> a

演習

以下の演習では、Readerモナドを使って、字下げのついた文書を出力するための小さなライブラリを作っていきます。「大域的な設定」は、現在の字下げの深さを示す数になります。

type Level = Number
    
type Doc = Reader Level String
  1. (簡単) 現在の字下げの深さで文字列を出力する関数 lineを書いてください。その関数は、以下の型を持っている必要があります。

    line :: String -> Doc

    ヒント:現在の字下げの深さを読み取るためにはask関数を使用します。

  2. (やや難しい) local関数を使用して、コードブロックの字下げの深さを大きくする次のような関数を書いてください。

    indent :: Doc -> Doc
  3. (やや難しい) Data.Traversableで定義されたsequence関数を使用して、文書のリストを改行で区切って連結する次のような関数を書いてください。

    cat :: [Doc] -> Doc
  4. (やや難しい) runReader関数を使用して、文書を文字列として出力する次のような関数を書いてください。

    render :: Doc -> String

    これで、このライブラリを次のように使うと、簡単な文書を書くことができるはずです。

    render $ cat 
      [ line "Here is some indented text:"
      , indent $ cat 
          [ line "I am indented"
          , line "So am I"
          , indent $ line "I am even more indented"
          ]
      ]

11.6 Writerモナド

Writerモナドは、計算の返り値に加えて、もうひとつの値を累積していく機能を提供します。

よくある使い方としては型Stringもしくは[String]でログを累積していくというものなどがありますが、Writerモナドはこれよりもっと一般的なものです。これは累積するのに任意のモノイドの値を使うことができ、Sumモノイドを使って、合計を追跡し続けるのに使ったり、Anyモノイドを使って途中のBoolean値がすべて真であるかどうかを追跡するのに使うことができます。

Writer型の構築子は、Monoid型クラスのインスタンスである型w、および返り値の型aという2つの型引数を取ります。

WriterのAPIで重要なのはtell関数です。

tell :: forall w a. (Monoid w) => w -> Writer w Unit

tellアクションは、与えられた値を現在の累積結果に加算します。

例として、[String]モノイドを使用して、既存の関数にログ機能を追加してみましょう。最大公約数関数の以前の実装を考えてみます。

gcd :: Number -> Number -> Number
gcd n 0 = n
gcd 0 m = m
gcd n m = if n > m 
            then gcd (n - m) m 
            else gcd n (m - n)

Writer [String] Numberに返り値の型を変更することで、この関数にログ機能を追加することができます。

import Control.Monad.Writer
import Control.Monad.Writer.Class

gcdLog :: Number -> Number -> Writer [String] Number

各手順で二つの入力を記録するために、少し関数を変更する必要があります。

gcd n 0 = return n
gcd 0 m = return m
gcd n m = do
  tell ["gcd " ++ show n ++ " " ++ show m]
  if n > m 
    then gcd (n - m) m 
    else gcd n (m - n)

Writerモナドの計算を実行するには、execWriter関数とrunWriter関数のいずれかを使います。

execWriter :: forall w a. Writer w a -> w
runWriter  :: forall w a. Writer w a -> Tuple a w

ちょうどStateモナドの場合と同じように、execWriterが累積されたログだけを返すのに対して、runWriterは累積されたログと結果の両方を返します。

psciで修正された関数を試してみましょう。

> :i Data.Tuple
> :i Data.Monoid
> :i Control.Monad.Writer
> :i Control.Monad.Writer.Class

> runWriter (gcd 21 15)

Tuple 3 ["gcd 21 15","gcd 6 15","gcd 6 9","gcd 6 3","gcd 3 3"]

演習

  1. (やや難しい) Writerモナドとpurescript-monoidsパッケージのSumのモノイドを使うように、上のsumArray関数を書き換えてください。

  2. (やや難しい)コラッツ関数は、自然数nが偶数ならn / 2nが奇数なら3 * n + 1であると定義されています。たとえば、10で始まるコラッツ数列は次のようになります。

    10, 5, 16, 8, 4, 2, 1, ...

    コラッツ関数の有限回の適用を繰り返すと、コラッツ数列は必ず最終的に1になるということとが予想できます。

    数列が1に到達するまでに何回のコラッツ関数の適用が必要かを計算する再帰的な関数を書いてください。

    コラッツ関数のそれぞれの適用のログを記録するためにWriterモナドを使用するように関数を変更してください。

11.7 モナド変換子

上の3つのモナド、StateReaderWriterは、いずれもいわゆるモナド変換子(monad transformers)の例となっています。対応するモナド変換子はそれぞれ StateTReaderTWriterTと呼ばれています。

モナド変換子とは何でしょうか。さて、これまで見てきたように、モナドはPureScriptで適切なハンドラ(runStaterunReaderrunWriterなど)を使って解釈される、いろいろな種類の副作用でPureScriptコードを拡張します。使用する必要がある副作用がひとつだけなら、これで問題ありません。しかし、同時に複数の副作用を使用できると便利なことがよくあります。例えば、MaybeReaderを一緒に使用すると、ある大域的な設定の文脈で省略可能な結果を表現することができます。もしくは、Eitherモナドの純粋なエラー追跡機能と、Stateモナドが提供する変更可能な状態が同時に欲しくなるかもしれません。この問題を解決するのがモナド変換子です。

拡張可能作用の手法を使うとネイティブな作用を混在させることができるので、Effモナドはこの問題に対する部分的な解決策を提供していることをすでに見てきたことに注意してください。モナド変換子はまた異なった解決策を提供しますが、これらの手法にはそれぞれ利点と限界があります。

モナド変換子は型だけでなく別の型構築子によってもパラメータ化される型構築子です。モナド変換子はモナドをひとつ取り、独自のいろいろな副作用を追加した別のモナドへと変換します。

例を見てみましょう。Control.Monad.State.Transで定義されたStateTStateのモナド変換子版です。psciを使ってStateTの種を見てみましょう。

> :i Control.Monad.State.Trans
> :k StateT
* -> (* -> *) -> * -> *

とても読みにくそうに思うかもしれませんが、使い方を理解するために、StateTにひとつ引数を与えてみましょう。

Stateの場合、最初の型引数は使いたい状態の型です。それでは型Stringを与えてみましょう。

> :k StateT String
(* -> *) -> * -> *

次の引数は種* -> *の型構築子です。これはStateTの機能を追加したい元のモナドを表します。例として、Either Stringモナドを選んでみます。

> :k StateT String (Either String)
* -> *

型構築子が残りました。最後の引数は戻り値の型を表しており、たとえばそれをNumberにすることができます。

> :k StateT String (Either String) Number
*

最後に、種*の何かが残りましたが、この型の値を探してみましょう。

構築したモナドStateT String (Either String)は、エラーで失敗する可能性があり、変更可能な状態を使える計算を表しています。

外側のStateT Stringモナドのアクション(getputmodify)は直接使うことができますが、ラップされている内側のモナド(Either String)の作用を使うためには、これらの関数をモナド変換子まで「持ち上げ」なくてはいけません。Control.MonadTransモジュールでは、モナド変換子であるような型構築子を捕捉するMonadTrans型クラスを次のように定義しています。

class MonadTrans t where
  lift :: forall m a. (Monad m) => m a -> t m a

このクラスは、基礎となる任意のモナドmの計算をとり、それをラップされたモナドt mへと持ち上げる、liftというひとつの関数だけを持っています。今回の場合、型構築子tStateT Stringで、mEither Stringモナドとなり、liftは型Either String aの計算を、型State String (Either String) aの計算へと持ち上げる方法を提供することになります。これは、型Either String aの計算を使うときは、lfftを使えばいつでも作用StateT StringEither Stringを隣り合わせに使うことができることを意味します。

たとえば、次の計算はStateTモナド変換子で導入されている状態を読み込み、状態が空の文字列である場合はエラーを投げます。

import Data.String (drop, take)

split :: StateT String (Either String) String
split = do
  s <- get
  case s of
    "" -> lift $ Left "Empty string"
    _ -> do
      put (drop 1 s)
      return (take 1 s)

状態が空でなければ、この計算はputを使って状態をdrop 1 s(最初の文字を取り除いたs)へと更新し、take 1 s(sの最初の文字)を返します。

それではpsciでこれを試してみましょう。

> runStateT split "test"
Right (Tuple "t" "est")

> runStateT split ""
Left "Empty string"

これはStateTを使わなくても実装できるので、さほど驚くようなことではありません。しかし、モナドとして扱っているので、do記法やApplicativeコンビネータを使って、小さな計算から大きな計算を構築していくことができます。例えば、2回splitを適用すると、文字列から最初の2文字を読むことができます。

> runStateT ((++) <$> split <*> split) "test"
Right (Tuple ("te") ("st"))

他にもアクションを幾つか用意すれば、split関数を使って、基本的な構文解析ライブラリを構築することができます。これは実際にpurescript-parsingライブラリで採用されている手法です。これがモナド変換子の力なのです。必要な副作用を選択して、do記法とApplicativeコンビネータで表現力を維持しながら、様々な問題のための特注のモナドを作成することができるのです。

11.8 ErrorTモナド変換子

purescript-transformersパッケージでは、Either eモナドに対応する変換子であるErrorT eモナド変換子も定義されています。これは次のAPIを提供します。

class Error a where
  noMsg :: a
  strMsg :: String -> a

throwError :: forall m a. (Error e) => 
                            e -> ErrorT e m a
catchError :: forall m a. (Error e) => 
                            ErrorT e m a -> 
                            (e -> ErrorT e m a) -> 
                            ErrorT e m a
                            
runErrorT :: forall e m a. ErrorT e m a -> m (Either e a)

ちょうどEither eモナドと同じように、throwErrorアクションは失敗を示すために使われます。

catchErrorアクションを使うと、throwErrorでエラーが投げられたあとでも処理を継続することができるようになります。

runErrorTハンドラを使うと、型ErrorT e m aの計算を実行することができます。

このAPIはpurescript-exceptionsパッケージのException作用によって提供されているものと似ています。しかし、いくつかの重要な違いがあります。

試しにErrorTを使ってWriterモナドを包んでみましょう。ここでもモナド変換子ErrorT eのアクションは自由に使えますが、Writerモナドの計算はliftを使って持ちあげなければなりません。  

import Control.Monad.Trans
import Control.Monad.Writer
import Control.Monad.Writer.Class
import Control.Monad.Error
import Control.Monad.Error.Class

writerAndErrorT :: ErrorT String (Writer [String]) String
writerAndErrorT = do
  tell ["Before the error"]
  throwError "Error!"
  tell ["After the error"]
  return "Return value"

psciでこの関数を試すと、ログの蓄積とエラーの送出という2つの作用がどのように相互作用しているのかを見ることができます。まず、runErrorTを使って外側のErrorT計算を実行し、型Write String (Either String String)の結果を残します。それから、runWriterで内側のWriter計算を実行します。

> runWriter $ runErrorT writerAndErrorT
Tuple (Left "Error!") ["Before the error"]

実際に追加されるログは、エラーが投げられる前に書かれたログメッセージだけであることにも注目してください。

11.9 モナド変換子スタック

これまで見てきたように、モナド変換子を使うと既存のモナドの上に新しいモナドを構築することができます。任意のモナド変換子t1と任意のモナドmについて、その適用t1 mもまたモナドになります。これはふたつめのモナド変換子t2を先ほどの結果t1 mに適用すると、第3のモナド t2 (t1 m)を作れることを意味しています。このように、構成するモナドによって提供された副作用を組み合わせる、モナド変換子のスタックを構築することができます。

実際には、基本となるモナドmは、ネイティブの副作用が必要ならEffモナド、さもなくばControl.Monad.Identityモジュールで定義されているIdentityモナドになります。Identityモナドは何の新しい副作用も追加しませんから、Identityモナドの変換は、モナド変換子の作用だけを提供します。実際に、Stateモナド、Readerモナド、Writerモナドは、IdentityモナドをそれぞれStateTReaderTWriterTで変換することによって実装されています。

それでは3つの副作用が組み合わされている例を見てみましょう。Identityモナドをスタックの底にして、StateT作用、 WriterT作用、ErrorT作用を使います。このモナド変換子スタックは、ログの蓄積し、純粋なエラー、可変状態の副作用を提供します。

このモナド変圧器スタックを使うと、ロギングの機能が追加されたsplitアクションを作ることができます。

type Parser = StateT String (WriterT [String] (ErrorT String Identity))

split :: Parser String
split = do
  s <- get
  lift $ tell ["The state is " ++ show s]
  case s of
    "" -> lift $ lift $ throwError "Empty string"
    _ -> do
      put (drop 1 s)
      return (take 1 s)

この計算をpsciで試してみると、splitが実行されるたびに状態がログに追加されることがわかります。

モナド変換子スタックに現れる順序に従って、副作用を取り除いていかなければならないことに注意してください。最初にStateT型構築子を取り除くためにrunStateTを使い、それからruntWriteTを使い、その後runErrorTを使います。最後にrunIdentityを使用してIdentityモナドの演算を実行します。

> let runParser p s = runIdentity $ runErrorT $ runWriterT $ runStateT p s

> runParser split "test"
  
Right (Tuple (Tuple "t" "est") ["The state is test"])

> runParser ((++) <$> split <*> split) "test"
  
Right (Tuple (Tuple "te" "st") ["The state is test", "The state is est"])

しかしながら解析が失敗した場合は、状態が空であるためログはまったく出力されません。

runParser split ""
> Left "Empty string"

これは、ErrorTモナド変換子が提供する副作用が、WriterTモナド変換子が提供する副作用に影響を受けるためです。これはモナド変換子スタックが構成されている順序を変更することで解決することができます。スタックの最上部にErrorT変換子を移動すると、先ほどWriterErrorTに変換したときと同じように、最初のエラーまでに書かれたすべてのメッセージが含まれるようになります。

このコードの問題のひとつは、複数のモナド変換子の上まで計算を持ち上げるために、lift関数を複数回使わなければならないということです。たとえば、throwErrorの呼び出しは、1回めはWriteTへ、2回めはStateTへと、2回持ちあげなければなりません。小さなモナド変換子スタックならなんとかなりますが、そのうち不便だと感じるようになるでしょう。

幸いなことに、これから見るような型クラス推論によって提供されるコードの自動生成を使うと、ほとんどの「多段持ち上げ」を行うことができます。

演習

  1. (簡単) Identity関手の上のErrorTモナド変換子を使って、分母がゼロの場合はエラーを投​​げる、2つの数の商を求める関数 safeDivideを書いてください。

  2. (やや難しい) 現在の状態が接頭辞に適合するか、エラーメッセージとともに失敗する、次のような構文解析関数を書いてください。

    string :: String -> Parser String

    この構文解析器は次のように動作しなくてはなりません。

    > runParser (string "abc") "abcdef"
    
    Right (Tuple (Tuple "abc" "def") ["The state is abcdef"])

    ヒント:出発点としてsplitの実装を使うといいでしょう。

  3. (難しい) 以前Readerモナドを使用して書いた文書出力ライブラリを、ReaderTWriterTモナド変圧器を使用して再実装してください。

    文字列を出力するlineや文字列を連結するcatを使うのではなく、WriteTモナド変換子と一緒に[String]モノイドを使い、結果へ行を追加するのにtellを使ってください。

11.10 救済のための型クラス

章の最初で扱ったStateモナドを見てみると、Stateモナドのアクションには次のような型が与えられていました。

get    :: forall s.             State s s
put    :: forall s. s        -> State s Unit
modify :: forall s. (s -> s) -> State s Unit

Control.Monad.State.Classモジュールで与えられている型は、実際には次のようにもっと一般的です。

get    :: forall m s. (MonadState s m) =>             m s
put    :: forall m s. (MonadState s m) => s        -> m Unit
modify :: forall m s. (MonadState s m) => (s -> s) -> m Unit

Control.Monad.State.Classモジュールには「純粋な変更可能な状態を提供するモナド」への抽象化を可能にするMonadState(多変数)型クラスが定義されています。予想できると思いますが、State s型構築子はMonadState s型クラスのインスタンスになっており、このクラスには他にも興味深いインスタンスが数多くあります。

特に、purescript-transformersパッケージではモナド変換子WriterTReaderTErrorTについてのMonadStateのインスタンスが提供されています。実際に、StateTがモナド変換子スタックのどこかに現れ、StateTより上のすべてがMonadStateのインスタンスであれば、getputmodifyを直接自由に使用することができます。

実は、これまで扱ってきたReaderTWriterTErrorT変換子についても、同じことが成り立っています。purescript-transformersでは、それらの操作をサポートするモナドの上に抽象化することを可能にする、主な変換子それぞれについての型クラスが定義されています。

上のsplit関数の場合、構築されたこのモナドスタックは型クラスMonadStateMonadWriterMonadErrorそれぞれのインスタンスです。これはつまり、liftをまったく呼び出す必要がないことを意味します!まるでモナドスタック自体に定義されていたかのように、アクション getputtellthrowErrorをそのまま使用することができます。

split :: Parser String
split = do
  s <- get
  tell ["The state is " ++ show s]
  case s of
    "" -> throwError "Empty string"
    _ -> do
      put (drop 1 s)
      return (take 1 s)

この計算はまるで、可変状態、ロギング、エラー処理という3つの副作用に対応した、独自のプログラミング言語を拡張したかのようにみえます。しかしながら、内部的にはすべてはあくまで純粋な関数と普通のデータを使って実装されているのです。

11.11 Alternative型クラス

purescript-controlパッケージでは失敗しうる計算を操作するための抽象化がいくつか定義されています。そのひとつは Alternative型クラスです。

class (Functor f) <= Alt f where
  (<|>) :: forall a. f a -> f a -> f a

class (Alt f) <= Plus f where
  empty :: forall a. f a
  
class (Applicative f, Plus f) <= Alternative f where

Alternative は、失敗しうる計算のプロトタイプを提供する empty値、 エラーが起きたときに代替(Alternative)計算へ戻ってやり直す機能を提供する<|>演算子 という、2つの新しいコンビネータを提供しています。

Control.AlternativeモジュールではAlternative型クラスで型構築子を操作する2つの便利な関数を提供します。

many :: forall f a. (Alternative f, Lazy1 f) => f a -> f [a]
some :: forall f a. (Alternative f, Lazy1 f) => f a -> f [a]

manyコンビネータは計算をゼロ回以上繰り返し実行するためにAlternative型クラスを使用しています。someコンビネータも似ていますが、成功するために少なくとも1回の計算を必要とします。

今回のParserモナド変換子スタックの場合は、ErrorTコンポーネントから導かれた、明らかな方法で失敗をサポートする、Alternativeのインスタンスが存在します。これは、構文解析器を複数回実行するために many関数とsome関数を使うことができることを意味します。

> :i Split
> :i Control.Alternative

> runParser (many split) "test"
  
Right (Tuple (Tuple ["t", "e", "s", "t"] "") 
             [ "The state is \"test\""
             , "The state is \"est\""
             , "The state is \"st\""
             , "The state is \"t\""
             ])

ここで、入力文字列"test"は、1文字の文字列4つの配列を返すように、繰り返し分割されています。残った状態は空文字列で、ログはsplitコンビネータが4回適用されたことを示しています。

Alternative型構築子の他の例としては、Maybeや、[]つまり配列の型構築子があります。

11.12 モナド内包表記

Control.MonadPlusモジュールにはMonadPlusと呼ばれるAlternative型クラスの若干の変形が定義されています。MonadPlusはモナドとAlternativeのインスタンスの両方である型構築子を補足します。

class (Monad m, Alternative m) <= MonadPlus m

実際、ParserモナドはMonadPlusのインスタンスです。

以前に本書中で配列の内包表記を扱ったとき、不要な結果をフィルタリングするため使われるguard関数を導入しました。実際はguard関数はもっと一般的で、MonadPlusのインスタンスであるすべてのモナドに対して使うことができます。

guard :: forall m. (MonadPlus m) => Boolean -> m Unit

<|>演算子は失敗時のバックトラッキングをできるようにします。これがどのように役立つかを見るために、大文字だけに適合するsplitコンビネータの亜種を定義してみましょう。

upper :: Parser String
upper = do
  s <- split
  guard $ toUpper s == s
  return s

ここで、文字列が大文字でない場合に失敗するよう guardを使用しています。このコードは前に見た配列内包表記とよく似ていることに注目してください。このようにMonadPlusが使われておりモナド内包表記(monad comprehensions)を構築するために参照することがあります。

11.13 バックトラッキング

<|>演算子を使うと、失敗したときに別の代替計算へとバックトラックすることができます。これを確かめるために、小文字に一致するもう一つの構文解析器を定義してみましょう。

lower :: Parser String
lower = do
  s <- split
  guard $ toLower s == s
  return s

これにより、まずもし最初の文字が大文字なら複数の大文字に適合し、さもなくばもし最初の文字が小文字なら複数の小文字に適合する、という構文解析器を定義することができます。

> let upperOrLower = some upper <|> some lower

この構文解析器は、大文字と小文字が切り替わるまで、文字に適合し続けます。

> runParser upperOrLower "abcDEF"

Right (Tuple (Tuple ["a","b","c"] ("DEF")) 
             [ "The state is \"abcDEF\"",
             , "The state is \"bcDEF\""
             , "The state is \"cDEF\""
             ])

manyを使うと、文字列を小文字と大文字の要素に完全に分割することもできます。

> let components = many upperOrLower

> runParser components "abCDeFgh"
  
Right (Tuple (Tuple [["a","b"],["C","D"],["e"],["F"],["g","h"]] "") 
             [ "The state is \"abCDeFgh\""
             , "The state is \"bCDeFgh\""
             , "The state is \"CDeFgh\""
             , "The state is \"DeFgh\""
             , "The state is \"eFgh\""
             , "The state is \"Fgh\""
             , "The state is \"gh\""
             , "The state is \"h\""
             ])

繰り返しになりますが、これはモナド変換子がもたらす再利用性の威力を示しています。標準的な抽象化を再利用することで、バックトラック構文解析器を宣言型のスタイルでわずか数行のコードで書くことができました!

演習

  1. (簡単) string構文解析器の実装からlift関数の呼び出しを取り除いてください。新しい実装の型が整合していることを確認し、そうでなることをよく納得しておきましょう。

  2. (やや難しい) string構文解析器とmanyコンビネータを使って、文字列"a"の連続と、それに続く文字列"b"の連続からなる文字列を認識する構文解析器を書いてください。

  3. (やや難しい) <|>演算子を使って、文字aと文字bが任意の順序で現れるような文字列を認識する構文解析器を書いてください。

  4. (難しい) Parserモナドは次のように定義されるかもしれません。

    type Parser = ErrorT String (StateT String (WriterT [String] Identity))

    このように変更すると、構文解析関数にどのような影響を与えるでしょうか。

11.14 RWSモナド

モナド変換子のある特定の組み合わせは、purescript-transformersパッケージ内の単一のモナド変換子として提供されるのが一般的です。ReaderWriterStateのモナドは、Reader-Writer-Stateモナド(RWSモナド)へと結合されます。このモナドは RWSTモナド変換子と呼ばれる、対応するモナド変換子を持っています。

ここではRWSモナドを使ってテキストアドベンチャーゲームの処理を設計していきます。

RWSモナドは(戻り値の型に加えて)3つの型変数で定義されています。

type RWS r w s = RWST r w s Identity

副作用を提供しないIdentityにベースモナドを設定することで、RWSモナドが独自のモナド変換子の観点から定義されていることに注意してください。

第1型引数rは大域的な設定の型を表します。第2型引数wはログを蓄積するために使用するモノイド、第3型引数sは可変状態の型を表しています。

このゲームの場合には、大域的な設定はData.GameEnvironmentモジュールのGameEnvironmentと呼ばれる型で定義されています。

type PlayerName = String

newtype GameEnvironment = GameEnvironment
  { playerName    :: PlayerName
  , debugMode     :: Boolean
  }

GameEnvironmentでは、プレイヤー名と、ゲームがデバッグモードで動作しているか否かを示すフラグが定義されています。これらのオプションは、モナド変換子を実行するときにコマンドラインから設定されます。

可変状態はData.GameStateモジュールのGameStateと呼ばれる型で定義されています。

import qualified Data.Map as M
import qualified Data.Set as S

newtype GameState = GameState
  { items       :: M.Map Coords (S.Set GameItem)
  , player      :: Coords
  , inventory   :: S.Set GameItem
  }

Coordsデータ型は2次元平面の点を表し、GameItemデータ型はゲーム内のアイテムです。

data GameItem = Candle | Matches

GameState型はソートされたマップを表すMapとソートされた集合を表すSetという2つの新しいデータ構造を使っています。itemsプロパティは、そのゲーム平面上の座標と、ゲームアイテムの集合へのマッピングになっています。playerプロパティはプレイヤーの現在の座標を格納しており、inventoryプロパティは現在プレイヤーが保有するゲームアイテムの集合です。

MapSetのデータ構造は平衡2-3木を使って実装されており、Ord型クラス内の任意の型をキーとして使用することができます。これは今回のデータ構造のキーが完全に順序付けできることを意味します。

ゲームのアクションを書くために、MapSet構造がどのように使っていくのかを見ていきましょう。

ログとしては[String]モノイドを使います。RWSを使ってGameモナドのための型同義語を定義しておきます。

type Log = [String]

type Game = RWS GameEnvironment Log GameState

11.15 ゲームロジックの実装

今回は、Readerモナド、Writerモナド、Stateモナドのアクションを再利用し、Gameモナドで定義されている単純なアクションを組み合わせてゲームを構築していきます。このアプリケーションの最上位では、Gameモナドで純粋な計算を実行しており、Effモナドはコンソールにテキストを出力するような追跡可能な副作用へと結果を変換するために使っています。

このゲームで最も簡単なアクションのひとつはhasアクションです。このアクションはプレイヤーの持ち物に特定のゲームアイテムが含まれているかどうかを調べます。これは次のように定義されます。

has :: GameItem -> Game Boolean
has item = do
  GameState state <- get
  return $ item `S.member` state.inventory

この関数は、現在のゲームの状態を読み取るためにMonad.State型クラスで定義されているgetアクションを使っており、指定したGameItemが持ち物のSetのなかに出現するかどうかを調べるためにData.Setで定義されているmember関数を使っています。

他にもpickUpアクションがあります。現在の位置にゲームアイテムがある場合、プレイヤーの持ち物にそのアイテムを追加します。これにはMonadWriterMonadState型クラスのアクションを使っています。まず、現在のゲームの状態を読み取ります。

pickUp :: GameItem -> Game Unit
pickUp item = do
  GameState state <- get

次にpickUpは現在の位置にあるアイテムの集合を検索します。これはData.Mapで定義された lookup関数を使って行います。

  case state.player `M.lookup` state.items of

lookup関数はMaybe型構築子で示されたオプショナルな結果を返します。lookup関数は、キーがマップにない場合はNothingを返し、それ以外の場合はJust構築子で対応する値を返します。

関心があるのは、指定されたゲームアイテムが対応するアイテムの集合に含まれている場合です。member関数を使うとこれを調べることができます。

    Just items | item `S.member` items -> do

この場合、putを使ってゲームの状態を更新し、tellを使ってログにメッセージを追加します。

      let newItems = M.update (Just <<< S.delete item) state.player state.items
          newInventory = S.insert item state.inventory
      put $ GameState state { items     = newItems
                            , inventory = newInventory
                            }
      tell ["You now have the " ++ show item]

ここで、MonadStateMonadWriterの両方についてGameモナド変換子スタックについての適切なインスタンスが存在するので、2つの計算はどちらもliftは必要ないことに注意してください。

putの引数では、レコード更新を使ってゲームの状態のitemsinventoryフィールドを変更しています。特定のキーの値を変更するにはData.Mapupdate関数を使います。このとき、delete関数を使い指定したアイテムを集合から取り除くことで、 プレイヤーの現在の位置にあるアイテムの集合を変更します。

最後に、pickUp関数はtellを使ってユーザに次のように通知することにより、残りの場合を処理します。

    _ -> tell ["I don't see that item here."]

Readerモナドを使う例として、debugコマンドのコードを見てみましょう。ゲームがデバッグモードで実行されている場合、このコマンドを使うとユーザは実行時にゲームの状態を調べることができます。

  GameEnvironment env <- ask
  if env.debugMode
    then do
      state <- get
      tell [show state]
    else tell ["Not running in debug mode."] 

ここでは、ゲームの設定を読み込むためにaskアクションを使用しています。繰り返しますが、どんな計算のliftも必要なく、同じdo記法ブロック内でMonadStateMonadReaderMonadWriter型クラスで定義されているアクションを使うことができることに注意してください。

debugModeフラグが設定されている場合、tellアクションを使ってログに状態が追加されます。そうでなければ、エラーメッセージが追加されます。

Game.pursモジュールでは、MonadState型クラス、MonadReader型クラス、MonadWriter型クラスでそれぞれ定義されたアクションだけを使って、同様のアクションが定義されています。

11.16 計算の実行

このゲームロジックはRWSモナドで動くため、ユーザのコマンドに応答するためには計算を実行する必要があります。

このゲームのフロントエンドは、yargsコマンドライン構文解析ライブラリへのApplicativeなインターフェイスを提供するpurescript-yargsパッケージと、対話的なコンソールベースのアプリケーションを書くことを可能にするNodeJSのreadlineモジュールをラップするpurescript-node-readlineパッケージという2つのパッケージで構成されています。

このゲームロジックへのインタフェースはGameモジュール内の関数gameによって提供されます。

game :: [String] -> Game Unit

この計算を実行するには、ユーザが入力した単語のリストを文字列の配列として渡してから、runRWSを使ってRWSの計算を実行します。

type See s a w = { log :: w, result :: a, state :: s }

runRWS :: forall r w s a. RWS r w s a -> r -> s -> See s a w

runRWSrunReaderrunWriterrunStateを組み合わせたように見えます。これは、引数として大域的な設定および初期状態をとり、ログ、結果、最的な終状態を含むレコードを返します。

このアプリケーションのフロントエンドは、次の型シグネチャを持つ関数 runGameによって定義されます。

runGame :: GameEnvironment -> Eff (console :: Console, trace :: Trace) Unit

Console作用は、この関数がpurescript-node-readlineパッケージを使ってコンソールを介してユーザと対話することを示しています。runGameは関数の引数としてのゲームの設定とります。

purescript-node-readlineパッケージでは、端末からのユーザ入力を扱うEffモナドのアクションを表すLineHandler型が提供されています。対応するAPIは次のとおりです。

type LineHandler eff = String -> Eff eff Unit

setLineHandler :: forall eff. LineHandler eff -> 
                              Interface -> 
                              Eff (console :: Console | eff) Interface

Interface型はコンソールのハンドルを表しており、コンソールと対話する関数への引数として渡されます。createInterface関数を使用するとInterfaceを作成することができます。

runGame env = do
  interface <- createInterface process.stdin process.stdout noCompletion

最初の手順はコンソールにプロンプトを設定することです。interfaceハンドルを渡し、プロンプト文字列とインデントレベルを提供します。

setPrompt "> " 2 interface

今回の場合、ラインハンドラ関数を実装することに関心があります。ラインハンドラはlet宣言内の補助関数を使って次のように定義されています。

lineHandler :: GameState -> String -> Eff (console :: Console, trace :: Trace) Unit
lineHandler currentState input = do
  let result = runRWS (game (split " " input)) env currentState
  foreachE result.log trace
  setLineHandler (lineHandler result.state) interface
  prompt interface
  return unit

lineHandlerではenvという名前のゲーム構成や、interfaceという名前のコンソールハンドルを参照しています。

このハンドラは追加の最初の引数としてゲームの状態を取ります。ゲームのロジックを実行するためにrunRWSにゲームの状態を渡さなければならないので、これは必要となっています。

このアクションが最初に行うことは、Data.Stringモジュールのsplit関数を使用して、ユーザーの入力を単語に分割することです。それから、ゲーム環境と現在のゲームの状態を渡し、runRWSを使用して(RWSモナドで)gameアクションを実行しています。

純粋な計算であるゲームロジックを実行し、画面にすべてのログメッセージを出力して、ユーザに次のコマンドのプロンプトを表示する必要があります。foreachEアクションは([String]型の)ログを走査し、コンソールにその内容を出力するために使われています。そしてsetLineHandlerを使ってラインハンドラ関数を更新することで、ゲームの状態を更新します。最後にpromptアクションを使ってプロンプトが再び表示しています。

runGame関数ではコンソールインターフェイスに最初のラインハンドラを設定して、最初のプロンプトを表示します。

  setLineHandler (lineHandler initialGameState) interface
  prompt interface

演習

  1. (やや難しい) ゲームフィールド上にあるすべてのゲームアイテムをユーザの持ちものに移動する新しいコマンドcheatを実装してください。

  2. (難しい) 今のところWriteTモナド変換子は、エラーメッセージと情報メッセージの2つの種類のメッセージのために使われています。このため、コードのいくつかの箇所では、エラーの場合を扱うためにcase式を使用しています。

    エラーメッセージを扱うのにErrorTモナド変換子を使い、情報メッセージを扱うのにWriteTを使うように、コードをリファクタリングしてください。

11.17 コマンドラインオプションの扱い

このアプリケーションの最後の部品は、コマンドラインオプションの解析とGameEnvironmentレコードを作成する役目にあります。このためにはpurescript-yargsパッケージを使用します。

purescript-yargsApplicativeなコマンドラインオプション構文解析器の例です。Applicative関手を使うと、いろいろな副作用の型を表す型構築子まで任意個数の引数の関数をを持ち上げられることを思い出してください。purescript-yargsパッケージの場合には、コマンドラインオプションからの読み取りの副作用を追加するY関手が興味深い関手になっています。これは次のようなハンドラを提供しています。

runY :: forall a eff. YargsSetup -> 
                      Y (Eff eff a) -> 
                      Eff (console :: Console, err :: Exception | eff) a

この関数の使いかたは、例で示すのが最も適しているでしょう。このアプリケーションのmain関数はrunYを使って次のように定義されています。

main = runY (usage "$0 -p <player name>") $ runGame <$> env

最初の引数はyargsライブラリを設定するために使用されます。今回の場合、使用方法のメッセージだけを提供していますが、Node.Yargs.Setupモジュールには他にもいくつかのオプションを提供しています。

2番目の引数では、Y型構築子までrunGame関数を持ち上げるために<$>コンビネータを使用しています。引数envwhere節でApplicative演算子<$><*>を使って構築されています。

  where
  env :: Y GameEnvironment
  env = gameEnvironment
          <$> yarg "p" ["player"] 
                   (Just "Player name") 
                   (Right "The player name is required") 
                   false
          <*> flag "d" ["debug"]
                   (Just "Use debug mode")

PlayerName -> Boolean -> GameEnvironmentという型を持つこのgameEnvironment関数は、Yまで持ち上げられています。このふたつの引数は、コマンドラインオプションからプレイヤー名とデバッグフラグを読み取る方法を指定しています。最初の引数は-pもしくは--playerオプションで指定されるプレイヤー名オプションについて記述しており、2つ目の引数は-dもしくは--debugオプションで指定されるデバッグモードフラグについて記述しています。

これは Node.Yargs.Applicativeモジュールで定義されているふたつの基本的な関数について示しています。yargは(型StringNumberBooleanの)オプショナルな引数を取りコマンドラインオプションを定義し、flagは型Booleanのコマンドラインフラグを定義しています。

Applicative演算子によるこの記法を使うことで、コマンドラインインターフェイスに対してコンパクトで宣言的な仕様を与えることが可能になったことに注意してください。また、envの定義でrunGame関数に新しい引数を追加し、<*>を使って追加の引数までrunGameを持ち上げるだけで、簡単に新しいコマンドライン引数を追加することができます。

演習

  1. (やや難しい) GameEnvironmentレコードに新しい真偽値のプロパティcheatModeを追加してください。 また、yargs設定に、チートモードを有効にする新しいコマンドラインフラグ-cを追加してください。チートモードが有効になっていない場合、cheatコマンドは禁止されなければなりません。

11.18 まとめ

モナド変換子を使用したゲームの純粋な定義、コンソールを使用したフロントエンドを構築するためのEffモナドなと、この章ではこれまで学んできた手法を実用的に使いました。

ユーザインターフェースからの実装を分離したので、ゲームの別のフロントエンドを作成することも可能でしょう。例えば、EffモナドでCanvas APIやDOMを使用して、ブラウザでゲームを描画するようなことができるでしょう。

モナド変換子によって、型システムによって作用が追跡される命令型のスタイルで、安全なコードを書くことができることを見てきました。また、型クラスは、コードの再利用を可能にするモナドによって提供される、アクション上の抽象化の強力な方法を提供します。標準的なモナド変換子を組み合わせることにより、AlternativeMonadPlusのような標準的な抽象化を使用して、役に立つモナドを構築することができました。

モナド変換子は、高階多相や多変数型クラスなどの高度な型システムの機能を利用することによって記述することができ、表現力の高いコードの優れた実演となっています。

次の章では、非同期なJavaScriptのコードにありがちな不満、コールバック地獄の問題に対して、モナド変換子がどのような洗練された解決策を与えるのかを見ていきます。