実例によるPureScript
ウェブのための
第11章 モナドの探求
11.1 この章の目標
この章の目標は、異なるモナドから提供された副作用を合成する方法を提供するモナド変換子(monad transformers)について学ぶことです。NodeJSのコンソール上で遊ぶことができる、テキストアドベンチャーゲームを題材として扱います。ゲームの様々な副作用(ロギング、状態、および設定)がすべてモナド変換子スタックによって提供されます。
11.2 プロジェクトの準備
このモジュールのプロジェクトでは以下のBower依存関係が新たに導入されます。
purescript-maps- 不変のマップと集合のためのデータ型を提供します。purescript-sets- 不変集合のデータ型を提供する標準的なモナド変換子の実装を提供するpurescript-transformers- 標準のモナド変換子の実装を提供します。purescript-node-readline- NodeJSが提供するreadlineインターフェイスへのFFIバインディングを提供します。purescript-yargs-yargsコマンドライン引数処理ライブラリにApplicativeなインターフェイスを提供します。
また、NPMを使って yargsモジュールをインストールする必要があります。
npm install
11.3 ゲームの遊びかた
プロジェクトを実行するには、 pulp runでソースコードをビルドしてから、NodeJSにコンパイルされたJavaScriptを渡します。
デフォルトでは使い方が表示されます。
node ./dist/Main.js -p
Options:
-p, --player Player name [required]
-d, --debug Use debug mode
Missing required arguments: p
The player name is required
-pオプションを使ってプレイヤー名を指定してください。
pulp run -- -p Phil
>
プロンプトからは、 look、 inventory、 take、 use、 north、 south、 east、 westなどのコマンドを入力することができます。 --debugコマンドラインオプションが与えられたときには、ゲームの状態を出力するための debugコマンドも使えます。
ゲームは2次元の碁盤の目の上でプレイし、コマンド north、 south、 east、 westを発行することによってプレイヤーが移動します。ゲームにはアイテムの配列があり、プレイヤーの所持アイテム一覧を表したり、ゲーム盤上のその位置にあるアイテムの一覧を表すのに使われます。 takeコマンドを使うと、プレイヤーの位置にあるアイテムを拾い上げることができます。
参考までに、このゲームのひと通りの流れは次のようになります。
$ pulp run -- -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ととてもよく似ています。しかし、これらのアクションに Refや STRefに渡しているような、可変領域への参照を引数に渡さないことに注意してください。 Stateと Effモナドが提供する解決策の違いは、 Stateモナドは暗黙的な単一の状態だけを提供していることです。この状態は Stateモナドの型構築子によって隠された関数の引数として実装されており、参照は明示的には渡されないのです。
例を見てみましょう。 Stateモナドの使いかたのひとつとしては、状態を数として、現在の状態に配列の値を加算していくようなものかもしれません。状態の型 sとして Numberを選択し、配列の走査に traverse_を使って、配列の要素それぞれについて modifyを呼び出すと、これを実現することができます。
import Data.Foldable (traverse_)
import Control.Monad.State
import Control.Monad.State.Class
sumArray :: Array 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は最終的な状態だけを返し、 runStateは Tuple a s型の値として表現された返り値と状態の両方を返します。
先ほどの sumArray関数が与えられたとすると、 PSCiで次のように execStateを使うと複数の配列内の数字を合計することができます。
> :paste
… execState (do
… sumArray [1, 2, 3]
… sumArray [4, 5]
… sumArray [6]) 0
… ^D
21
演習
-
(簡単) 上の例で、
execStateをrunStateやevalStateで 置き換えると結果はどうなるでしょうか。 -
(やや難しい)
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 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 = Number
type Doc = Reader Level String
-
(簡単) 現在の字下げの深さで文字列を出力する関数
lineを書いてください。その関数は、以下の型を持っている必要があります。line :: String -> Docヒント:現在の字下げの深さを読み取るためには
ask関数を使用します。 -
(やや難しい)
local関数を使用して、コードブロックの字下げの深さを大きくする次のような関数を書いてください。indent :: Doc -> Doc -
(やや難しい)
Data.Traversableで定義されたsequence関数を使用して、文書のリストを改行で区切って連結する次のような関数を書いてください。cat :: Array Doc -> Doc -
(やや難しい)
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もしくは Array Stringでログを累積していくというものなどがありますが、 Writerモナドはこれよりもっと一般的なものです。これは累積するのに任意のモノイドの値を使うことができ、 Sumモノイドを使って、合計を追跡し続けるのに使ったり、 Anyモノイドを使って途中の Boolean値がすべて真であるかどうかを追跡するのに使うことができます。
Writer型の構築子は、 Monoid型クラスのインスタンスである型 w、および返り値の型 aという2つの型引数を取ります。
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
各手順で二つの入力を記録するために、少し関数を変更する必要があります。
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"]
演習
-
(やや難しい)
Writerモナドとpurescript-monoidパッケージのAdditive Intのモノイドを使うように、上のsumArray関数を書き換えてください。 -
(やや難しい)コラッツ関数は、自然数
nが偶数ならn / 2、nが奇数なら3 * n + 1であると定義されています。たとえば、10で始まるコラッツ数列は次のようになります。10, 5, 16, 8, 4, 2, 1, ...コラッツ関数の有限回の適用を繰り返すと、コラッツ数列は必ず最終的に
1になるということとが予想できます。数列が
1に到達するまでに何回のコラッツ関数の適用が必要かを計算する再帰的な関数を書いてください。Writerモナドを使用してコラッツ関数のそれぞれの適用の経過を記録するように、関数を変更してください。
11.7 モナド変換子
上の3つのモナド、 State、 Reader、 Writerは、いずれもいわゆるモナド変換子(monad transformers)の例となっています。対応するモナド変換子はそれぞれ StateT、 ReaderT、 WriterTと呼ばれています。
モナド変換子とは何でしょうか。さて、これまで見てきたように、モナドはPureScriptで適切なハンドラ(runState、 runReader、 runWriterなど)を使って解釈される、いろいろな種類の副作用でPureScriptコードを拡張します。使用する必要がある副作用がひとつだけなら、これで問題ありません。しかし、同時に複数の副作用を使用できると便利なことがよくあります。例えば、 Maybeと Readerを一緒に使用すると、ある大域的な設定の文脈で省略可能な結果を表現することができます。もしくは、 Eitherモナドの純粋なエラー追跡機能と、 Stateモナドが提供する変更可能な状態が同時に欲しくなるかもしれません。この問題を解決するのがモナド変換子です。
拡張可能作用の手法を使うとネイティブな作用を混在させることができるので、 Effモナドはこの問題に対する部分的な解決策を提供していることをすでに見てきたことに注意してください。モナド変換子はまた異なった解決策を提供しますが、これらの手法にはそれぞれ利点と限界があります。
モナド変換子は型だけでなく別の型構築子によってもパラメータ化される型構築子です。モナド変換子はモナドをひとつ取り、独自のいろいろな副作用を追加した別のモナドへと変換します。
例を見てみましょう。 Control.Monad.State.Transで定義された StateTは Stateのモナド変換子版です。 PSCiを使って StateTの種を見てみましょう。
> import Control.Monad.State.Trans
> :kind StateT
Type -> (Type -> Type) -> Type -> Type
とても読みにくそうに思うかもしれませんが、使い方を理解するために、 StateTにひとつ引数を与えてみましょう。
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 (Either String)モナドのアクション(get、 put、 modify)は直接使うことができますが、ラップされている内側のモナド(Either String)の作用を使うためには、これらの関数をモナド変換子まで「持ち上げ」なくてはいけません。 Control.MonadTransモジュールでは、モナド変換子であるような型構築子を捕捉する MonadTrans型クラスを次のように定義しています。
class MonadTrans t where
lift :: forall m a. Monad m => m a -> t m a
このクラスは、基礎となる任意のモナド mの計算をとり、それをラップされたモナド t mへと持ち上げる、 liftというひとつの関数だけを持っています。今回の場合、型構築子 tは StateT Stringで、 mは Either Stringモナドとなり、 liftは型 Either String aの計算を、型 State String (Either String) aの計算へと持ち上げる方法を提供することになります。これは、型 Either String aの計算を使うときは、 liftを使えばいつでも作用 StateT Stringと Either 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)
pure (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 ExceptTモナド変換子
purescript-transformersパッケージでは、 Either eモナドに対応する変換子である ExceptT eモナド変換子も定義されています。これは次のAPIを提供します。
class MonadError e m where
throwError :: forall a. e -> m a
catchError :: forall a. m a -> (e -> m a) -> m a
instance monadErrorExceptT :: 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は purescript-exceptionsパッケージの Exception作用によって提供されているものと似ています。しかし、いくつかの重要な違いがあります。
ExceptTモデルが代数的データ型を使っているのに対して、Exceptionは実際のJavaScriptの例外を使っています。ExceptTがError型クラスのどんな型のエラーでも扱うのに対して、Exception作用はJavaScriptのError型というひとつ例外の型だけを扱います。つまり、ExceptTでは新たなエラー型を自由に定義できます。
試しに ExceptTを使って Writerモナドを包んでみましょう。ここでもモナド変換子 ExceptT eのアクションは自由に使えますが、 Writerモナドの計算は liftを使って持ちあげなければなりません。
import Control.Monad.Trans
import Control.Monad.Writer
import Control.Monad.Writer.Class
import Control.Monad.Error.Class
import Control.Monad.Except.Trans
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計算を実行し、型 Write String (Either String String)の結果を残します。それから、 runWriterで内側の Writer計算を実行します。
> runWriter $ runExceptT writerAndExceptT
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モナドをそれぞれ StateT、 ReaderT、 WriterTで変換することによって実装されています。
それでは3つの副作用が組み合わされている例を見てみましょう。 Identityモナドをスタックの底にして、 StateT作用、 WriterT作用、 ExceptT作用を使います。このモナド変換子スタックは、ログの蓄積し、純粋なエラー、可変状態の副作用を提供します。
このモナド変換子スタックを使うと、ロギングの機能が追加された 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 " <> show s]
case s of
"" -> lift $ lift $ throwError ["Empty string"]
_ -> do
put (drop 1 s)
pure (take 1 s)
この計算を PSCiで試してみると、 splitが実行されるたびに状態がログに追加されることがわかります。
モナド変換子スタックに現れる順序に従って、副作用を取り除いていかなければならないことに注意してください。最初に StateT型構築子を取り除くために runStateTを使い、それから runtWriteTを使い、その後 runExceptTを使います。最後に runIdentityを使用して Identityモナドの演算を実行します。
> runParser p s = runIdentity $ 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変換子を移動すると、先ほど Writerを ExceptTに変換したときと同じように、最初のエラーまでに書かれたすべてのメッセージが含まれるようになります。
このコードの問題のひとつは、複数のモナド変換子の上まで計算を持ち上げるために、 lift関数を複数回使わなければならないということです。たとえば、 throwErrorの呼び出しは、1回目は WriteTへ、2回目は StateTへと、2回持ちあげなければなりません。小さなモナド変換子スタックならなんとかなりますが、そのうち不便だと感じるようになるでしょう。
幸いなことに、これから見るような型クラス推論によって提供されるコードの自動生成を使うと、ほとんどの「多段持ち上げ」を行うことができます。
演習
-
(簡単)
Identity関手の上のExceptTモナド変換子を使って、分母がゼロの場合はエラーを投げる、2つの数の商を求める関数safeDivideを書いてください。 -
(やや難しい) 現在の状態が接頭辞に適合するか、エラーメッセージとともに失敗する、次のような構文解析関数を書いてください。
string :: String -> Parser Stringこの構文解析器は次のように動作しなくてはなりません。
> runParser (string "abc") "abcdef" (Right (Tuple (Tuple "abc" "def") ["The state is abcdef"]))ヒント:出発点として
splitの実装を使うといいでしょう。stripPrefix関数も役に立ちます。 -
(難しい) 以前
Readerモナドを使用して書いた文書出力ライブラリを、ReaderTとWriterTモナド変換子を使用して再実装してください。文字列を出力する
lineや文字列を連結するcatを使うのではなく、WriteTモナド変換子と一緒にArray 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パッケージではモナド変換子 WriterT、 ReaderT、 ExceptTについての MonadStateのインスタンスが提供されています。実際に、 StateTがモナド変換子スタックのどこかに現れ、 StateTより上のすべてが MonadStateのインスタンスであれば、 get、 put、 modifyを直接自由に使用することができます。
実は、これまで扱ってきた ReaderT、 WriterT、 ExceptT変換子についても、同じことが成り立っています。 purescript-transformersでは、それらの操作をサポートするモナドの上に抽象化することを可能にする、主な変換子それぞれについての型クラスが定義されています。
上の split関数の場合、構築されたこのモナドスタックは型クラス MonadState、 MonadWriter、 MonadErrorそれぞれのインスタンスです。これはつまり、 liftをまったく呼び出す必要がないことを意味します!まるでモナドスタック自体に定義されていたかのように、アクション get、 put、 tell、 throwErrorをそのまま使用することができます。
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つの副作用に対応した、独自のプログラミング言語を拡張したかのようにみえます。しかしながら、内部的にはすべてはあくまで純粋な関数と普通のデータを使って実装されているのです。
11.11 Alternative型クラス
purescript-controlパッケージでは失敗しうる計算を操作するための抽象化がいくつか定義されています。そのひとつは 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は、失敗しうる計算のプロトタイプを提供する empty値、
エラーが起きたときに代替(Alternative)計算へ戻ってやり直す機能を提供する <|>演算子 という、2つの新しいコンビネータを提供しています。
Data.Listモジュールでは Alternative型クラスで型構築子を操作する2つの便利な関数を提供します。
many :: forall f a. Alternative f => Lazy (f (List a)) => f a -> f (List a)
some :: forall f a. Alternative f => Lazy (f (List a)) => f a -> f (List a)
manyコンビネータは計算をゼロ回以上繰り返し実行するために Alternative型クラスを使用しています。 someコンビネータも似ていますが、成功するために少なくとも1回の計算を必要とします。
今回の Parserモナド変換子スタックの場合は、 ExceptTコンポーネントから導かれた、明らかな方法で失敗をサポートする、 Alternativeのインスタンスが存在します。これは、構文解析器を複数回実行するために many関数と some関数を使うことができることを意味します。
> import Split
> import 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や、 Arrayつまり配列の型構築子があります。
11.12 モナド内包表記
Control.MonadPlusモジュールには MonadPlusと呼ばれる Alternative型クラスの若干の変形が定義されています。 MonadPlusはモナドと Alternativeのインスタンスの両方である型構築子を補足します。
class (Monad m, Alternative m) <= MonadZero m
class MonadZero m <= MonadPlus m
実際、 Parserモナドは MonadPlusのインスタンスです。
以前に本書中で配列内包表記を扱ったとき、不要な結果をフィルタリングするため使われる guard関数を導入しました。実際は guard関数はもっと一般的で、 MonadPlusのインスタンスであるすべてのモナドに対して使うことができます。
guard :: forall m. MonadZero m => Boolean -> m Unit
<|>演算子は失敗時のバックトラッキングをできるようにします。これがどのように役立つかを見るために、大文字だけに適合する splitコンビネータの亜種を定義してみましょう。
upper :: Parser String
upper = do
s <- split
guard $ toUpper s == s
pure s
ここで、文字列が大文字でない場合に失敗するよう guardを使用しています。このコードは前に見た配列内包表記とよく似ていることに注目してください。このように MonadPlusが使われておりモナド内包表記(monad comprehensions)を構築するために参照することがあります。
11.13 バックトラッキング
<|>演算子を使うと、失敗したときに別の代替計算へとバックトラックすることができます。これを確かめるために、小文字に一致するもう一つの構文解析器を定義してみましょう。
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\""
]))
繰り返しになりますが、これはモナド変換子がもたらす再利用性の威力を示しています。標準的な抽象化を再利用することで、バックトラック構文解析器を宣言型のスタイルでわずか数行のコードで書くことができました!
演習
-
(簡単)
string構文解析器の実装からlift関数の呼び出しを取り除いてください。新しい実装の型が整合していることを確認し、なぜそのようになるのかをよく納得しておきましょう。 -
(やや難しい)
string構文解析器とmanyコンビネータを使って、文字列"a"の連続と、それに続く文字列"b"の連続からなる文字列を認識する構文解析器を書いてください。 -
(やや難しい)
<|>演算子を使って、文字aと文字bが任意の順序で現れるような文字列を認識する構文解析器を書いてください。 -
(難しい)
Parserモナドは次のように定義されるかもしれません。type Parser = ExceptT Errors (StateT String (WriterT Log Identity))このように変更すると、構文解析関数にどのような影響を与えるでしょうか。
11.14 RWSモナド
モナド変換子のある特定の組み合わせは、 purescript-transformersパッケージ内の単一のモナド変換子として提供されるのが一般的です。 Reader、 Writer、 Stateのモナドは、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プロパティは現在プレイヤーが保有するゲームアイテムの集合です。
Mapと Setのデータ構造はキーによってソートされ、 Ord型クラスの任意の型をキーとして使用することができます。これは今回のデータ構造のキーが完全に順序付けできることを意味します。
ゲームのアクションを書くために、 Mapと Set構造がどのように使っていくのかを見ていきましょう。
ログとしては List Stringモノイドを使います。 RWSを使って Gameモナドのための型同義語を定義しておきます。
type Log = L.List 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
pure $ item `S.member` state.inventory
この関数は、現在のゲームの状態を読み取るために Monad.State型クラスで定義されている getアクションを使っており、指定した GameItemが持ち物の Setのなかに出現するかどうかを調べるために Data.Setで定義されている member関数を使っています。
他にも pickUpアクションがあります。現在の位置にゲームアイテムがある場合、プレイヤーの持ち物にそのアイテムを追加します。これには MonadWriterと MonadState型クラスのアクションを使っています。まず、現在のゲームの状態を読み取ります。
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))
ここで、 MonadStateと MonadWriterの両方について Gameモナド変換子スタックについての適切なインスタンスが存在するので、2つの計算はどちらも liftは必要ないことに注意してください。
putの引数では、レコード更新を使ってゲームの状態の itemsと inventoryフィールドを変更しています。特定のキーの値を変更するには Data.Mapの update関数を使います。このとき、 delete関数を使い指定したアイテムを集合から取り除くことで、プレイヤーの現在の位置にあるアイテムの集合を変更します。
最後に、 pickUp関数は tellを使ってユーザに次のように通知することにより、残りの場合を処理します。
_ -> tell (L.singleton "I don't see that item here.")
Readerモナドを使う例として、 debugコマンドのコードを見てみましょう。ゲームがデバッグモードで実行されている場合、このコマンドを使うとユーザは実行時にゲームの状態を調べることができます。
GameEnvironment env <- ask
if env.debugMode
then do
state <- get
tell (L.singleton (show state))
else tell (L.singleton "Not running in debug mode.")
ここでは、ゲームの設定を読み込むために askアクションを使用しています。繰り返しますが、どんな計算の liftも必要なく、同じdo記法ブロック内で MonadState、 MonadReader、 MonadWriter型クラスで定義されているアクションを使うことができることに注意してください。
debugModeフラグが設定されている場合、 tellアクションを使ってログに状態が追加されます。そうでなければ、エラーメッセージが追加されます。
Game.pursモジュールでは、 MonadState型クラス、 MonadReader型クラス、 MonadWriter型クラスでそれぞれ定義されたアクションだけを使って、同様のアクションが定義されています。
11.16 計算の実行
このゲームロジックは RWSモナドで動くため、ユーザのコマンドに応答するためには計算を実行する必要があります。
このゲームのフロントエンドは、 yargsコマンドライン構文解析ライブラリへのApplicativeなインターフェイスを提供する purescript-yargsパッケージと、対話的なコンソールベースのアプリケーションを書くことを可能にするNodeJSの readlineモジュールをラップする purescript-node-readlineパッケージという2つのパッケージで構成されています。
このゲームロジックへのインタフェースは 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
runRWSは runReader、 runWriter、 runStateを組み合わせたように見えます。これは、引数として大域的な設定および初期状態をとり、ログ、結果、最的な終状態を含むレコードを返します。
このアプリケーションのフロントエンドは、次の型シグネチャを持つ関数 runGameによって定義されます。
runGame :: forall eff . GameEnvironment
-> Eff ( exception :: EXCEPTION
, readline :: RL.READLINE
, console :: CONSOLE
| eff
) Unit
Console作用は、この関数が purescript-node-readlineパッケージを使ってコンソールを介してユーザと対話することを示しています。 runGameは関数の引数としてのゲームの設定とります。
purescript-node-readlineパッケージでは、端末からのユーザ入力を扱う Effモナドのアクションを表す LineHandler型が提供されています。対応するAPIは次のとおりです。
type LineHandler eff a = String -> Eff eff a
setLineHandler :: forall eff a. Interface
-> LineHandler (readline :: READLINE | eff) a
-> Eff (readline :: READLINE | eff) Unit
Interface型はコンソールのハンドルを表しており、コンソールと対話する関数への引数として渡されます。 createInterface関数を使用すると Interfaceを作成することができます。
runGame env = do
interface <- createConsoleInterface noCompletion
最初の手順はコンソールにプロンプトを設定することです。 interfaceハンドルを渡し、プロンプト文字列とインデントレベルを提供します。
setPrompt "> " 2 interface
今回は lineHandler関数を実装してみましょう。 lineHandlerは let宣言内の補助関数を使って次のように定義されています。
lineHandler :: GameState -> String
-> Eff ( exception :: EXCEPTION
, console :: CONSOLE
, readline :: RL.READLINE
| eff
) Unit
lineHandler currentState input = do
case runRWS (game (split " " input)) env currentState of
RWSResult state _ written -> do
for_ written log
setLineHandler interface $ lineHandler state
prompt interface
pure unit
lineHandlerでは envという名前のゲーム構成や、 interfaceという名前のコンソールハンドルを参照しています。
このハンドラは追加の最初の引数としてゲームの状態を取ります。ゲームのロジックを実行するために runRWSにゲームの状態を渡さなければならないので、これは必要となっています。
このアクションが最初に行うことは、 Data.Stringモジュールの split関数を使用して、ユーザーの入力を単語に分割することです。それから、ゲーム環境と現在のゲームの状態を渡し、 runRWSを使用して(RWSモナドで)gameアクションを実行しています。
純粋な計算であるゲームロジックを実行し、画面にすべてのログメッセージを出力して、ユーザに次のコマンドのプロンプトを表示する必要があります。 for_アクションは(List String型の)ログを走査し、コンソールにその内容を出力するために使われています。そして setLineHandlerを使って lineHandler関数を更新することで、ゲームの状態を更新します。最後に promptアクションを使ってプロンプトが再び表示しています。
runGame関数ではコンソールインターフェイスに最初のlineHandlerを設定して、最初のプロンプトを表示します。
setLineHandler interface $ lineHandler initialGameState
prompt interface
演習
-
(やや難しい) ゲームフィールド上にあるすべてのゲームアイテムをユーザの持ちものに移動する新しいコマンド
cheatを実装してください。 -
(難しい)
RWSモナドのWriterコンポーネントは、エラーメッセージと情報メッセージの2つの種類のメッセージのために使われています。このため、コードのいくつかの箇所では、エラーの場合を扱うためにcase式を使用しています。エラーメッセージを扱うのに
ExceptTモナド変換子を使うようにし、情報メッセージを扱うのにRWSを使うようにするよう、コードをリファクタリングしてください。
11.17 コマンドラインオプションの扱い
このアプリケーションの最後の部品は、コマンドラインオプションの解析と GameEnvironmentレコードを作成する役目にあります。このためには purescript-yargsパッケージを使用します。
purescript-yargsはApplicativeなコマンドラインオプション構文解析器の例です。Applicative関手を使うと、いろいろな副作用の型を表す型構築子まで任意個数の引数の関数をを持ち上げられることを思い出してください。 purescript-yargsパッケージの場合には、コマンドラインオプションからの読み取りの副作用を追加する Y関手が興味深い関手になっています。これは次のようなハンドラを提供しています。
runY :: forall a eff. YargsSetup ->
Y (Eff (exception :: EXCEPTION, console :: CONSOLE | eff) a) ->
Eff (exception :: EXCEPTION, console :: CONSOLE | eff) a
この関数の使いかたは、例で示すのが最も適しているでしょう。このアプリケーションの main関数は runYを使って次のように定義されています。
main = runY (usage "$0 -p <player name>") $ map runGame env
最初の引数は yargsライブラリを設定するために使用されます。今回の場合、使用方法のメッセージだけを提供していますが、 Node.Yargs.Setupモジュールには他にもいくつかのオプションを提供しています。
2番目の引数では、 Y型構築子まで runGame関数を持ち上げるために <$>コンビネータを使用しています。引数 envは where節で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は(型 String、 Number、 Booleanの)オプショナルな引数を取りコマンドラインオプションを定義し、 flagは型 Booleanのコマンドラインフラグを定義しています。
Applicative演算子によるこの記法を使うことで、コマンドラインインターフェイスに対してコンパクトで宣言的な仕様を与えることが可能になったことに注意してください。また、 envの定義で runGame関数に新しい引数を追加し、 <*>を使って追加の引数まで runGameを持ち上げるだけで、簡単に新しいコマンドライン引数を追加することができます。
演習
- (やや難しい)
GameEnvironmentレコードに新しい真偽値のプロパティcheatModeを追加してください。 また、yargs設定に、チートモードを有効にする新しいコマンドラインフラグ-cを追加してください。チートモードが有効になっていない場合、cheatコマンドは禁止されなければなりません。
まとめ
モナド変換子を使用したゲームの純粋な定義、コンソールを使用したフロントエンドを構築するための Effモナドなと、この章ではこれまで学んできた手法を実用的に使いました。
ユーザインターフェイスからの実装を分離したので、ゲームの別のフロントエンドを作成することも可能でしょう。例えば、 EffモナドでCanvas APIやDOMを使用して、ブラウザでゲームを描画するようなことができるでしょう。
モナド変換子によって、型システムによって作用が追跡される命令型のスタイルで、安全なコードを書くことができることを見てきました。また、型クラスは、コードの再利用を可能にするモナドによって提供される、アクション上の抽象化の強力な方法を提供します。標準的なモナド変換子を組み合わせることにより、 Alternativeや MonadPlusのような標準的な抽象化を使用して、役に立つモナドを構築することができました。
モナド変換子は、高階多相や多変数型クラスなどの高度な型システムの機能を利用することによって記述することができ、表現力の高いコードの優れた実演となっています。
次の章では、非同期なJavaScriptのコードにありがちな不満、コールバック地獄の問題に対して、モナド変換子がどのような洗練された解決策を与えるのかを見ていきます。