モナドな冒険

この章の目標

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

プロジェクトの準備

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

  • ordered-collections は不変のマップと集合のためのデータ型を提供します
  • transformers は標準的なモナド変換子の実装を提供します
  • node-readlineはNodeJSが提供するreadlineインターフェイスへのFFIバインディングを提供します
  • optparseはコマンドライン引数を処理するアプリカティブ構文解析器を提供します

ゲームの遊びかた

プロジェクトを走らせるにはspago runを使います。

既定では使い方の文言が表示されます。

Monadic Adventures! A game to learn monad transformers

Usage: run.js (-p|--player <player name>) [-d|--debug]
  Play the game as <player name>

Available options:
  -p,--player <player name>
                           The player's name <String>
  -d,--debug               Use debug mode
  -h,--help                Show this help text

コマンドライン引数を与えるためには、追加の引数を直接アプリケーションに渡す-aオプション付きでspago runを呼び出すか、spago bundle-appとすればよいです。 2つ目の方法ではnodeで直接走らせられるindex.jsファイルが作られます。

例えば-pオプションを使ってプレイヤー名を与えるには次のようにします。

$ spago run -a "-p Phil"
>
$ spago bundle-app 
$ node index.js -p Phil
>

プロンプトからは、lookinventorytakeusenorthsoutheastwestなどのコマンドを入力できます。 debugコマンドもあり、--debugコマンドラインオプションを与えられていた場合に、ゲームの状態を出力できます。

ゲームは2次元の碁盤の目の上が舞台で、コマンドnorthsoutheastwestを発行することによってプレイヤーが移動します。 ゲームにはアイテムの集まりがあり、プレイヤーの所有物であったり、ゲームの盤上の特定の位置にあったりします。 takeコマンドを使うと、プレイヤーはアイテムを拾い上げられます。

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

$ spago run -a "-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!

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

Stateモナド

transformersパッケージで提供されている幾つかのモナドを眺めることから始めましょう。

最初の例はStateモナドです。 これは純粋なコードで変更可能状態をモデル化する手段を提供します。 既にEffectモナドによって提供される変更可能状態の手法について見てきました。 Stateはその代替を提供します。

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

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

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

なお、ここではこれらのAPIシグネチャはState型構築子を使った、単純化された形式で表されています。 実際のAPIは本章の後にある「型クラス」節で押さえるMonadStateが関わってきます。 ですからIDEのツールチップやPursuitで違うシグネチャを見たとしても心配しないでください。

例を見てみましょう。 Stateモナドの使い方の1つとしては、整数の配列中の値を現在の状態に加えるものが考えられます。 状態の型sとしてIntを選択し、配列の走査にtraverse_を使い、配列の各要素についてmodifyを呼び出すと、これを実現できます。

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

sumArray :: Array Int -> State Int 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を使うと次のように複数の配列内の数字を合計できます。

> :paste
… execState (do
…   sumArray [1, 2, 3]
…   sumArray [4, 5]
…   sumArray [6]) 0
… ^D
21

演習

  1. (簡単)上の例で、execStaterunStateevalStateで置き換えると結果はどうなるでしょうか。

  2. (普通)括弧からなる文字列が平衡しているのは、0個以上のより短い平衡した文字列を連結したものか、より短い平衡した文字列を一対の括弧で囲んだものかの何れかです。

    Stateモナドと traverse_関数を使用して、次のような関数を書いてください。

    testParens :: String -> Boolean
    

    これは、まだ閉じられていない開括弧の数を把握することで、括弧のStringが平衡しているかどうかを調べる関数です。 この関数は次のように動作します。

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

    手掛かり:入力の文字列を文字の配列に変換するのに、Data.String.CodeUnitsモジュールの toCharArray関数を使うと良いでしょう。

Readerモナド

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

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

Control.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 map Just newUser
    else pure 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 = Int

type Doc = Reader Level String
  1. (簡単)現在の字下げの深さで関数を書き出す関数lineを書いてください。 関数は以下の型を持ちます。

    line :: String -> Doc
    

    手掛かり:現在の字下げの深さを読み取るためには ask関数を使用します。 Data.Monoidpower関数も役に立つかもしれません。

  2. (普通)local関数を使用して次の関数を書いてください。

    indent :: Doc -> Doc
    

    この関数はコードブロックの字下げを深くします。

  3. (普通)Data.Traversableで定義された sequence関数を使用して、次の関数を書いてください。

    cat :: Array 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"
      ]
  ]

Writerモナド

Writerモナドでは、計算の返り値に加えてもう1つの値を累算できます。

よくある使い方としては型StringもしくはArray Stringでログを累算していくというものなどがありますが、Writerモナドはこれよりもっと一般的なものです。 累算するのに任意のモノイドの値を使うことができるので、Additive Intモノイドを使って整数の合計を追跡するのに使ったり、Disj Booleanモノイドを使って途中のBoolean値の何れかが真であるかどうかを追跡するのに使えます。

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

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

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

tell動作は与えられた値を現在の累算結果に付け加えます。

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

gcd :: Int -> Int -> Int
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 (Array String) Intへと返り値の型を変更することで、この関数にログ機能を追加できます。

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

gcdLog :: Int -> Int -> Writer (Array String) Int

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

    gcdLog n 0 = pure n
    gcdLog 0 m = pure m
    gcdLog n m = do
      tell ["gcdLog " <> show n <> " " <> show m]
      if n > m
        then gcdLog (n - m) m
        else gcdLog 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で改変した関数を試してみましょう。

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

> runWriter (gcdLog 21 15)
Tuple 3 ["gcdLog 21 15","gcdLog 6 15","gcdLog 6 9","gcdLog 6 3","gcdLog 3 3"]

演習

  1. (普通)Writerモナドと monoidパッケージの Additive Intモノイドを使うように、上の sumArray関数を書き換えてください。

  2. (普通)コラッツ関数は自然数 \( n \) 上で定義され、 \( n \) が偶数なら \( n / 2 \)、 \( n \) が奇数なら \( 3 n + 1 \)です。 例えば \( 10 \) で始まるコラッツ数列は以下です。

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

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

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

    Writerモナドを使用してコラッツ関数の各適用の経過を記録するように、関数を変更してください。

モナド変換子

上の3つのモナド、StateReaderWriterは、何れもいわゆるモナド変換子の例となっています。 対応する各モナド変換子はそれぞれStateTReaderTWriterTという名前です。

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

ただしEffectモナドがこの問題を部分的に解決することは既に見ました。 モナド変換子はまた違った解決策を提供しますが、これらの各手法には利点と制約があります。

モナド変換子は型と別の型構築子を引数に取る型構築子です。 モナドを1つ取り、独自の様々な副作用を追加した別のモナドへと変換します。

例を見てみましょう。Stateのモナド変換子版はControl.Monad.State.Transモジュールで定義されているStateTです。 PSCiを使って StateTの種を見てみましょう。

> import Control.Monad.State.Trans
> :kind StateT
Type -> (Type -> Type) -> Type -> Type

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

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

> :kind StateT String
(Type -> Type) -> Type -> Type

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

> :kind StateT String (Either String)
Type -> Type

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

> :kind StateT String (Either String) Number
Type

最後に種Typeの何かが残りました。 つまりこの型の値を探してみることができます。

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

外側のStateT Stringモナドの動作(getputmodify)は直接使えますが、梱包されているモナド (Either String) の作用を使うためには、これらの関数をモナド変換子まで「持ち上げ」る必要があります。 Control.Monad.TransモジュールはMonadTrans型クラスを定義しています。 これはモナド変換子であるそうした型構築子を捕捉します。

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

このクラスは単一のメンバーliftを含みます。 これは通底する任意のモナドmの計算を取り、梱包されたモナドt mへと持ち上げるものです。 今回の場合、型構築子tStateT Stringで、mEither Stringモナドとなるので、liftは型Either String aの計算を、型StateT String (Either String) aの計算へと持ち上げる方法を提供することになります。 つまり、型Either String aの計算を使う場合に毎回liftを使うのであれば、StateT StringEither Stringの作用を使えます。

例えば、次の計算は通底する状態を読み、状態が空文字列であればエラーを投げます。

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)
      pure (take 1 s)

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

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

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

> runStateT split ""
Left "Empty string"

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

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

split関数とその他沢山の動作を使えば基本的な構文解析ライブラリを構築できます。 実際、これはparsingライブラリで採用されている手法です。 これがモナド変換子の力なのです。 必要な副作用を選択し、do記法とアプリカティブコンビネータで表現力を維持しながら、様々な問題のための特注のモナドを作成できるのです。

ExceptTモナド変換子

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

class MonadError e m where
  throwError :: forall a. e -> m a
  catchError :: forall a. m a -> (e -> m a) -> m a

instance Monad m => MonadError e (ExceptT e m)

runExceptT :: forall e m a. ExceptT e m a -> m (Either e a)

MonadErrorクラスはe型のエラーを投げたり捕えたりに対応するモナドを捕捉し、ExceptT eモナド変換子のインスタンスが提供されます。 Either eモナドのLeftと同じように、throwError動作では失敗を示せます。 catchError動作ではthrowErrorを使ってエラーが投げられた後に処理を継続できます。

runExceptT制御子を使うと、型 ExceptT e m aを計算できます。

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

  • Exceptionが実際のJavaScriptの例外を使っているのに対してExceptTモデルは代数的データ型を使っています。
  • Exception作用がJavaScriptのError型という1つの例外の型だけを扱うのに対して、ExceptTError型クラスのどんな型のエラーでも扱います。つまり、 ExceptTでは新たなエラー型を自由に定義できます。

試しにExceptTを使ってWriterモナドを包んでみましょう。 ここでもモナド変換子ExceptT eの動作を直接使うことも自由にできますが、Writerモナドの計算はliftを使って持ち上げるべきです。

import Control.Monad.Except
import Control.Monad.Writer

writerAndExceptT :: ExceptT String (Writer (Array String)) String
writerAndExceptT = do
  lift $ tell ["Before the error"]
  _ <- throwError "Error!"
  lift $ tell ["After the error"]
  pure "Return value"

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

> runWriter $ runExceptT writerAndExceptT
Tuple (Left "Error!") ["Before the error"]

なお、エラーが投げられる前に書き出されるログ文言だけがログに追記されます。

モナド変換子スタック

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

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

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

このモナド変換子スタックを使うと、ロギングの機能が追加された splitアクションに作り変えられます。

type Errors = Array String

type Log = Array String

type Parser = StateT String (WriterT Log (ExceptT Errors Identity))

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

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

なお、モナド変換子スタックに現れる順序で副作用を取り除いていかなければなりません。 最初にStateT型構築子を取り除くためにrunStateTを、それからruntWriteTrunExceptTを使います。 最後にunwrapを使用してIdentityモナド中で計算します。

> runParser p s = unwrap $ runExceptT $ 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"])

これは、ExceptTモナド変換子が提供する副作用とWriterTモナド変換子が提供する副作用との関係によるものです。 これはモナド変換子スタックが構成されている順序を変更することで対処できます。 スタックの最上部にExceptT変換子を移動すると、先ほどWriterExceptTに変換したときに見たように、最初のエラーまでに書かれた全ての文言がログに含まれるようになります。

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

幸いなことに、これから見るような型クラス推論によって提供されるコードの自動生成を使うと、ほとんどの「重労働」を任せられます。

演習

  1. (簡単)Identity関手を土台とするExceptTモナド変換子を使って、2つの数の商を求める関数 safeDivideを書いてください。 この関数は分母がゼロの場合に(文字列「Divide by zero!」の)エラーを投げます。

  2. (普通)次のような構文解析関数を書いてください。

    string :: String -> Parser String
    

    これは現在の状態が接頭辞に照合するか、もしくはエラー文言とともに失敗します。

    この構文解析器は次のように動作します。

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

    手掛かり:出発点としてsplitの実装が使えます。 stripPrefix関数も役に立つかもしれません。

  3. (難しい)文書表示ライブラリを、ReaderTWriterTモナド変換子を使用して再実装してください。 以前Readerモナドを使用して書いたものです。

    文字列を出力する lineや文字列を連結する catを使うのではなく、WriteTモナド変換子と一緒に Array Stringモノイドを使い、結果へ行を追加するのに tellを使ってください。 アポストロフィ (') を付ける以外は元の実装と同じ名前を使ってください。

型クラスが助けに来たぞっ

本章の最初で扱った 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型クラスのインスタンスになっており、このクラスには他にも興味深いインスタンスが数多くあります。

特に、transformersパッケージではモナド変換子WriterTReaderTExceptTについてのMonadStateのインスタンスがあります。 通底するMonadMonadStateインスタンスを持つなら常に、これらもインスタンスを持ちます。 つまり実際には、StateTがモナド変換子スタックのどこかに現れ、StateTより上の全てがMonadStateのインスタンスであれば、liftを使う必要なくgetputmodifyを直接自由に使用できます。

当然ですが、これまで扱ってきたReaderTWriterTExceptT変換子についても、同じことが言えます。 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)
      pure (take 1 s)

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

代替

controlパッケージでは失敗しうる計算を扱う抽象化が数多く定義されています。 その1つはAlternative型クラスです。

class Functor f <= Alt f where
  alt :: 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

Alternativeは2つの新しいコンビネータを提供しています。 1つは失敗しうる計算の雛形を提供するempty値で、もう1つはエラーが起きたときに代替の計算へ戻ってやり直す機能を提供するalt関数(そしてその別名<|>)です。

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

many :: forall f a. Alternative f => Lazy (f (Array a)) => f a -> f (Array a)
some :: forall f a. Alternative f => Lazy (f (Array a)) => f a -> f (Array a)

Data.Listにも等価なmanysomeがあります。

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

Parserモナド変換子スタックの場合は、ExceptTコンポーネントによるAlternativeのインスタンスがあります。 このコンポーネントでは異なる分枝のエラーにMonoidインスタンスを使って組み合わせることによって対応しています(だからErrors型にArray Stringを選ぶ必要があったんですね)。 これは、構文解析器を複数回実行するのにmany関数とsome関数を使うことができることを意味します。

> import Data.Array (many)

> 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回適用されたことを示しています。

モナド内包表記

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

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

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

以前本書中で配列内包表記を押さえたとき、guard関数を導入しました。 これは欲しくない結果を取り除けるのに使えました。 実際にはguard関数はもっと一般的で、MonadPlusのインスタンスである任意のモナドに対して使えます。

guard :: forall m. Alternative m => Boolean -> m Unit

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

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

ここで、文字列が大文字でない場合に失敗するよう、guardを使用しています。 なお、このコードは前に見た配列内包表記とよく似ています。 このようにMonadPlusを使うことは、モナド内包表記の構築と呼ばれることがあります。

バックトラッキング

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

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

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

> 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を使うと文字列を小文字と大文字の要素に完全に分割できます。

> 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構文解析器とsomeコンビネータを使って構文解析器asFollowedByBsを書いてください。 これは文字列"a"の連続と、それに続く文字列"b"の連続からなる文字列を認識するものです。

  3. (普通)<|>演算子を使って構文解析器asOrBsを書いてください。 これは文字aと文字bが任意の順序で現れる文字列を認識します。

  4. (難しい)Parserモナドを次のようにも定義できます。

    type Parser = ExceptT Errors (StateT String (WriterT Log Identity))
    

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

RWSモナド

モナド変換子のとある特定の組み合わせは頻出なので、transformersパッケージ内の単一のモナド変換子として提供されています。 ReaderWriterStateのモナドは、Reader-Writer-Stateモナドに組み合わさり、より単純にRWSモナドともされます。 このモナドはRWSTモナド変換子という名前の、対応するモナド変換子を持ちます。

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

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

type RWS r w s = RWST r w s Identity

なお、RWSモナドは基底のモナドをIdentityに設定することで独自のモナド変換子として定義されています。 Identityは副作用を提供しないのでした。

最初の型引数rは大域的な構成の型を表します。 2つ目のwはログを蓄積するために使用するモノイドを表します。 3つ目のsは可変状態の型です。

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

type PlayerName = String

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

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

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

import Data.Map as M
import 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型は2つの新しいデータ構造を使っています。 MapSetはそれぞれ整列されたマップと整列された集合を表します。 items属性は、ゲーム平面上の座標からその位置にあるゲームアイテムの集合への対応付けです。 player属性はプレイヤーの現在の座標を格納し、inventory属性は現在プレイヤーが保有するゲームアイテムの集合を格納します。

MapSetのデータ構造はキーによって整列され、このキーにはOrd型クラスの任意の型を使えます。 つまりデータ構造中のキーは完全に順序付けされます。

ゲームの動作を書く上でMapSet構造をどのように使っていくのかを見ていきます。

ログとしては List Stringモノイドを使います。 Gameモナド用の型同義語を定義し、RWSを使って実装できます。

type Log = L.List String

type Game = RWS GameEnvironment Log GameState

ゲームロジックの実装

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

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

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

この関数は、現在のゲームの状態を読み取るためにMonadState型クラスで定義されている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 (L.singleton ("You now have the " <> show item))

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

putへの引数では、レコード更新を使ってゲームの状態のitems及びinventoryフィールドを変更しています。 また、特定のキーの値を変更するData.Mapupdate関数を使っています。 今回の場合、プレイヤーの現在の位置にあるアイテムの集合を変更するのに、delete関数を使って指定したアイテムを集合から取り除いています。 insertを使って新しいアイテムをプレイヤーの持ち物の集合に加えるときにも、inventoryは更新されます。

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

    _ -> tell (L.singleton "I don't see that item here.")

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

  GameEnvironment env <- ask
  if env.debugMode
    then do
      state :: GameState <- get
      tell (L.singleton (show state))
    else tell (L.singleton "Not running in debug mode.")

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

debugModeフラグが設定されている場合、tell動作を使うとログに状態が書き込まれます。 そうでなければ、エラー文言が追加されます。

Gameモジュールの残りの部分では同様の動作の集合が定義されています。 各動作はMonadStateMonadReaderMonadWriter型クラスにより定義された動作のみを使っています。

計算の実行

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

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

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

game :: Array String -> Game Unit

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

data RWSResult state result writer = RWSResult state result writer

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

runRWSrunReaderrunWriterrunStateを組み合わせたように見えます。 引数として大域的な構成及び初期状態を取り、ログ、結果、最終的な状態を含むデータ構造を返します。

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

runGame :: GameEnvironment -> Effect Unit

この関数は(node-readlineconsoleパッケージを使って)コンソールを介してユーザとやり取りします。 runGameは関数の引数としてのゲームの設定を取ります。

node-readlineパッケージではLineHandler型が提供されています。 これは端末からのユーザ入力を扱うEffectモナドの動作を表します。 対応するAPIは次の通りです。

type LineHandler a = String -> Effect a

foreign import setLineHandler
  :: forall a
   . Interface
  -> LineHandler a
  -> Effect Unit

Interface型はコンソールの制御子を表しており、コンソールとやり取りする関数への引数として渡されます。 createConsoleInterface関数を使用するとInterfaceを作成できます。

import Node.ReadLine as RL

runGame env = do
  interface <- RL.createConsoleInterface RL.noCompletion

最初の工程はコンソールにプロンプトを設定することです。 interface制御対象を渡し、プロンプト文字列と字下げレベルを与えます。

  RL.setPrompt "> " interface

今回は行制御関数を実装することに関心があります。 ここでの行制御はlet宣言内の補助関数を使って次のように定義されています。

    lineHandler :: GameState -> String -> Effect Unit
    lineHandler currentState input = do
      case runRWS (game (split (wrap " ") input)) env currentState of
        RWSResult state _ written -> do
          for_ written log
          RL.setLineHandler (lineHandler state) $ interface
      RL.prompt interface
      pure unit

let束縛がenvという名前のゲーム構成やinterfaceという名前のコンソール制御対象を包み込んでいます。

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

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

純粋な計算であるゲームロジックを実行するには、画面に全てのログ文言を出力して、ユーザに次のコマンドのためのプロンプトを表示する必要があります。 for_動作が(List String型の)ログを走査し、コンソールにその内容を出力するために使われています。 最後にsetLineHandlerを使って行制御関数を更新することでゲームの状態を更新し、prompt動作を使ってプロンプトを再び表示しています。

runGame関数は最終的にコンソールインターフェイスに最初の行制御子を取り付けて、初期プロンプトを表示します。

  RL.setLineHandler (lineHandler initialGameState) interface
  RL.prompt interface

演習

  1. (普通)ゲームの格子上にある全てのゲームアイテムをユーザの持ちものに移動する新しいコマンド cheatを実装してください。 関数cheat :: Game UnitGameモジュールに作り、この関数をgameから使ってください。

  2. (難しい)RWSモナドの Writerコンポーネントは、エラー文言とお知らせ文言の2つの種類の文言のために使われています。 このため、コードの幾つかの箇所では、エラーの場合を扱うためにcase式を使用しています。

    コードをリファクタリングしてください。 エラー文言を扱うのにExceptTモナド変換子を使い、お知らせ文言を扱うのにRWSを使います。 補足:この演習にはテストがありません。

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

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

optparseアプリカティブなコマンドラインオプション構文解析器の一例です。 アプリカティブ関手を使うと、いろいろな副作用の型を表す型構築子まで任意個数の引数の関数を持ち上げられることを思い出してください。 optparseパッケージの場合には、コマンドラインオプションからの読み取りの副作用を追加するParser関手(optparseのモジュールOptions.Applicativeからインポートされたもの。Splitモジュールで定義したParserと混同しないように)が興味深い関手になっています。 これは次のような制御子を提供しています。

customExecParser :: forall a. ParserPrefs -> ParserInfo a -> Effect a

実例を見るのが一番です。 このアプリケーションの main関数はcustomExecParserを使って次のように定義されています。

main = OP.customExecParser prefs argParser >>= runGame 

最初の引数はoptparseライブラリを設定するために使用されます。 今回の場合、アプリケーションが引数なしで走らされたときは、(「missing argument」エラーを表示する代わりに)OP.prefs OP.showHelpOnEmptyを使って使用方法の文言を表示するように設定していますが、Options.Applicative.Builderモジュールには他にも幾つかの選択肢を提供しています。

2つ目の引数は解析プログラムの完全な説明です。

  argParser :: OP.ParserInfo GameEnvironment
  argParser = OP.info (env <**> OP.helper) parserOptions

  parserOptions = fold 
    [ OP.fullDesc
    , OP.progDesc "Play the game as <player name>"
    , OP.header "Monadic Adventures! A game to learn monad transformers" 
    ]

ここでOP.infoは、Parserをヘルプ文言の書式方法のためのオプションの集合と組み合わせます。 env <**> OP.helperenvと名付けられた任意のコマンドライン引数Parserを取り、自動的に--helpオプションを加えます。 ヘルプ文言用のオプションは型がInfoModであり、これはモノイドなので、fold関数を使って複数のオプションを一緒に追加できます。

解析器の面白い部分はGameEnvironmentの構築にあります。

  env :: OP.Parser GameEnvironment
  env = gameEnvironment <$> player <*> debug

  player :: OP.Parser String
  player = OP.strOption $ fold 
    [ OP.long "player"
    , OP.short 'p'
    , OP.metavar "<player name>"
    , OP.help "The player's name <String>"
    ]

  debug :: OP.Parser Boolean
  debug = OP.switch $ fold 
    [ OP.long "debug"
    , OP.short 'd'
    , OP.help "Use debug mode"
    ]

playerdebugは両方ともParserなので、アプリカティブ演算子<$><*>を使ってgameEnvironment関数を持ち上げられます。 この関数はParser上で型PlayerName -> Boolean -> GameEnvironmentを持ちます。 OP.strOptionは文字列値を期待するコマンドラインオプションを構築し、一緒に畳み込まれたModの集まりを介して構成されています。 OP.flagは似たような動作をしますが、関連付けられた値は期待しません。 optparseは多様なコマンドライン解析器を構築するために使える様々な修飾子について、大部のドキュメントを提供しています。

アプリカティブ演算子による記法を使うことで、コマンドラインインターフェイスの簡潔で宣言的な仕様を与えられた点に注目です。 加えて、新しいコマンドライン引数を追加するのは単純で、runGameに新しい関数引数を追加し、envの定義中で<*>を使って追加の引数までrunGameを持ち上げるだけでできます。

演習

  1. (普通)GameEnvironmentレコードに新しい真偽値のプロパティcheatModeを追加してください。 また、optparseの構成に、チートモードを有効にする新しいコマンドラインフラグ-cを追加してください。 チートモードが有効になっていない場合、前の演習のcheatコマンドは禁止されます。

まとめ

この章ではこれまで学んできた技術を実践的に実演しました。 モナド変換子を使用したゲームの純粋な仕様の構築、コンソールを使用したフロントエンドを構築するためのEffectモナドがそれです。

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

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

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