Oleg, Sabry and Swords らによる Extensible Effects: An Alternative to Monad Transformers の論文を読んだメモ的な何かです。モナド変換子に関する簡単な現状確認から入ってはいますが、想定読者層は日常的にモナドやモナド変換子を用いたプログラムを書いている人達です。
どちらかというと自分向けのメモの性格が強いので、詳しい部分は論文を参照してみてください。
背景:モナド変換子とその問題
Haskell
を中心に、関数型言語では副作用のある函数を合成するための手段としてモナドが広く用いられている。モナドは非常に強力な抽象化で、およそ副作用と呼べるものはモナドを使って定式化することが出来た。例えば、大域的な環境
r
を持った計算は Reader r
モナドを使うし、操作のログを残すような計算は Writer [String]
、失敗する恐れのある計算は
Maybe
、外界との入出力全般を使うのなら
IO
を使う、といった具合に。
他方、複数の種類の副作用を持つようなモナドを作りたいと思うときがある。必要な機能を持ったモナドを一から書いても良いが、モナドを自前で定義するのは面倒だし、既に用意されているモナドを組合せることが出来れば便利だ。そこで、Haskell
ではモナド変換子と呼ばれる手法を使って、既存のモナドを合成して得ることが
Haskell
では一般になっている。つまり、既存の「ピュア」なモナドに対して、他のモナドに対して垂直に合成できるような変換子を用意してやって、それを積み上げて新しいモナドを作るというわけだ。例えば、「大域的な環境を参照しつつ、操作のログを残しながら、ユーザとの入出力を行なう失敗するかもしれない計算」であれば
MaybeT (WriterT [String] (ReaderT r IO))
というような感じに。
こうしたモナド変換子を用いた方法は一般的に用いられているが、次のような欠点があった:
- 合成するモナドが増えるたびに、計算の効率が落ちる
- モナドを合成する順番によって、計算の意味が変わってくる
- 下位のモナドの処理を行うのに、一々
lift
を行う必要がある - モナドの合成順は固定されてい、途中で入れ替えることができない
それぞれを手短かに説明しよう。1.
合成するモナドが増える度に、計算の効率が落ちるというのは、モナド変換子は別種の種類のモナドの組合せであるので、例えば
return
が呼ばれたり、>>=
で合成したりする度にそれぞれの層に対する処理が発生してしまうという事だ。一つ一つの計算ステップで必要になるモナドの処理は限られていても、毎回全階層での処理が発生してしまうので、一般にモナドスタックが深くなる程効率が落ちてしまう。これを回避するには、最終的に自前でモナドを定義したりしなくてはいけなくなり、余り嬉しくない。
2.
モナドを合成する順番によって、計算の意味が変わってくるというのはどういうことか。例えば、StateT Int Maybe
という計算と MaybeT (State Int)
という計算を比較してみよう。
action :: (MonadPlus m, MonadState Int m) => m b
= modify (+1) >> mzero
action
> runState (runMaybeT (modify (+1) >> mzero)) 10
ghciNothing,11)
(
> runMaybeT (runStateT (modify (+1) >> mzero) 10)
ghciNothing
最初の実行例では、MaybeT (State Int)
の順で合成されている1。一番下のモナドは State Int
なので、計算が失敗してもその直前の内部状態は保存されている。それに対し、二番目の例は
StateT Int Maybe
の順になっている。一番下のモナドは MaybeT IO
なので、計算が失敗するとその前までの内部状態は全て破棄されてしまう。上のように、元のアクションが
polymorphic な形で定義されていれば、実行する際に run
する順番を選んで適切な意味に変えることも出来るが、何か具体的なアプリケーションをモナドで作ろうとする時は、まずモナドスタックを構成して
newtype
で包むというのが一般的な手順であるように思うし、その場合は最初に合成順について考えなくてはいけないということになる。
3. 下位のモナドの処理を行うのに、一々 lift
を行う必要がある
について。例えば、mtl
では ask
とか mzero
とかは MonadReader
や
MonadPlus
といった形でクラスを使って一般的に定義されてはいる。しかし、それは各モナド変換子に対して、例えば下のレイヤーに
MonadReader
なり MonadPlus
なりのインスタンスが居れば lift
してそれを使う、というような形になっている。だから、表面上はなくても、lift
は常に本質的にモナド変換子に張り付いている。また、こういった型クラスがないような場合は、やはり
lift
を連発する必要がある。例えば、MaybeT IO
の下で IO
を実行するには、やっぱり lift
なり liftIO
を使ってやる必要がある。更に、二つ以上下のモナドの機能を使うには lift
を多重に使ってやる必要があり、これは中々辛いものだ。
4.
モナドの合成順は固定されてい、途中で入れ替えることができない
というのは、上の問題と関連していて、下の層のモナドの副作用を使うには
lift
で潜る必要があり、なおかつその順番を跨ぐような処理は出来ない、ということだ。これについては、のちほど論文で言及されている例を通じて詳しく説明しよう。
さて、ここまでモナド変換子の抱える問題について整理してきた。今回 Oleg らが紹介している Extensible Effects の手法(以下 EE と呼ぶ)は、これらの問題を一挙に解決することを目論んだものだ。
Extensible Effects の仕組み
上の問題に対して、Oleg らの手法がどういった解決を与えているのかを見てみよう。
モナドの合成と効率性:モナドスタック = 委譲関係
一番目。モナド合成による効率性の低下問題について。EE
がモナド変換子と根本的に異なるのが、全ての計算を一つのモナドの中で行うことだ。この中心的な役割を果すモナドは、原著では
Eff
モナドと呼ばれている。
一つのモナドの中で行うといっても、一つの超万能なモナドがあってその中で操作をするという訳ではない。それではモナドの旨味がなくなってしまう。ではどうするのか?EE
の基本的な考え方は、副作用とはクライアントとハンドラの相互作用だ
という物だ。一つ一つのアクションは、それを処理出来るハンドラへのリクエストとして見る事が出来る。例えば、ask
は大域環境を持つ計算を司るハンドラへのリクエストと見れるし、IO
計算なんかは正に外部との入出力を行うハンドラへの命令と見ることが出来る。
そこで EE では、Eff
モナドの型パラメタとして、「実行に必要な副作用ハンドラの一覧」をタグとして持たせている。そして、そのリクエストを処理するハンドラが今までの
runStateT
や runMaybeT
に当たる。ハンドラは自分の処理できるコマンドを全て処理して、処理し終えた印としてタグを取り除く。これを繰り返していって、最終的にピュアな計算か、IO
や ST
のような基盤モナドの計算までに辿り着いたら、run
や runLift
で結果を取り出す。これが EE
の基本的な戦略だ。
ちょっとわかりづらいかもしれない。これは、(著者の五年前くらいで止まっている知識を総動員すれば)OOP
でいう Chain of Responsibility パターンを Haskell
で実現したものになっている。EE
は、モナド変換子のモナドスタックを、委譲関係のチェーンとして捉え直したものとも云うことが出来るのだ。例えば、StateT Int Maybe
なら State Int :> Try :> Void
という委譲関係のチェーンだし、WriterT [String] (ReaderT Int IO)
なら Writer [String] :> Reader Int :> Lift IO
というチェーンになる(:>
は左から右に委譲関係がある、という風に読めばよい)。
直観的には、runReader
なり
runState
なりを噛ませる度に、それぞれのハンドラが処理出来る命令を「処理」して、出来ないものはそのまま残しておくことになる。下位のハンドラへ処理を流すのに、結局ハンドラの数だけ使ってしまいそうな気がするが、EE
ではこれを継続渡し形式を使ったコルーチンとして実現しているため、結果的にはハンドラ数に依存しない定数時間で処理が可能になるそうだ2。
副作用の合成順:Open Union
次に2.
モナドを合成する順番によって、計算の意味が変わってくる
について。上の説明だけでは、「モナドスタックを一つのモナドの中に押し込めただけで、結局階層になってるんじゃないの?」という突っ込みが成立しうる。確かに、runReader
などを呼んで実際に計算を実行する際には階層は確定していることになる。しかし、EE
がモナド変換子と大きく異なるのは、Open Union
という考え方を用いていることだ。
どういうことか?それを説明するために、各函数のシグネチャを見てみよう。
-- | Computation with the global environment (corresponds to Reader)
ask :: (Typeable e, Member (Reader e) r) => Eff r e
local :: (Typeable e, Member (Reader e) r) => (e -> e) -> Eff r a -> Eff r a
runReader :: Typeable e => Eff (Reader e :> r) w -> e -> Eff r w
-- | computation which may fail (corresponds to MaybeT)
failure :: Member Try r => Eff r a
recover :: Member Try r => Eff r a -> Eff r a -> Eff r a
runTry :: Eff (Try :> r) a -> Eff r (Maybe a)
-- | Computation with underlying monad
lift :: (Monad m, Typeable1 m, MemberU2 Lift (Lift m) r) => m a -> Eff r a
runLift :: (Typeable1 m, Monad m) => Eff (Lift m :> Void) a -> m a
-- | evaluate @Eff@ to pure value.
run :: Eff Void a -> a
この函数を見ると、runHoge
系の函数以外は、Member Hoge r
という型制約を使って記述されていることがわかる。これは、「r
の中の何処かに Hoge
という副作用のタグが含まれいてる」という意味の制約だ。:>
は型レベルのリストと見做すことが出来るので、その中に Hoge
が入っているかどうか?という判定だと思えばよい。このように、ask
や failure
などの「副作用を持った」函数は単にそのチェーンの中に必要な副作用が含まれていることしか要求しないのだ。これは、モナド変換子を使う際に
MonadReader
や
MonadPlus
クラスを使ったやり方と似ていると云えば似ているが、このような仕組みにしたことで、3.
一々 lift
を行う必要がある
という問題が解決されている。モナドの階層は、各函数のレベルで見ればあくまでもフラットで対等なものなので、いちいち
lift
を呼ぶ必要がないのだ。もっとも、最終的に IO
や STM
、ST
といった値を計算するようなモナドを合成したい場合はあるが、そのときは
liftIO
と同じ要領で上の lift
を呼ぶことになる。だが、こうした基底モナドの命令を呼び出す場合を別にすれば、合成された他の副作用を呼ぶときに一々
lift
をつける必要がなくなる。
どういう事か?例えば、余りよい設計とは云えないが、ReaderT Int (Reader String) a
のような例を考えてみよう。EE で対応するのは Eff (Reader Int :> Reader String :> Void) a
という型になる。例えば、従来のモナド変換子を用いた方法では、次のように書くことになる:
action :: ReaderT Int (Reader String) (Int, String)
= do
action <- asks (*2)
int <- lift (asks reverse)
str return (int, str)
つまり、「一個下」の文字列の環境を取り出すのに、ここでは lift
を使う必要があった。だが、EE
ではこれは次のように簡単に書ける:
action :: (Member (Reader Int) r, Member (Reader String) r) => Eff r (Int, String)
= do
action <- asks (*2)
int <- asks reverse
str return (int :: Int , str :: String)
面倒な lift
が消えて、どちらも asks
だけになっている!ただ、値を返す際にそれぞれの値に型注釈をしている。これは、asks
の型の曖昧性をなくすためで、int
や str
がどの Reader
に向けての命令なのかをハッキリさせるためだ。このような簡単なプログラムだと注釈を書く必要があったが、現実的なもっと長いプログラムであれば、こういった型は推論により確定できるようになるだろうから、余り問題にはならない。それに、型の注釈は簡単に書けるが、lift
は階層の数だけ書かなくてはいけなくて、ReaderT Int (ReaderT String (ReaderT (Int, Bool) (ReaderT Env IO))) a
みたいな型があったら、いったいどこの値を取り出すのに何回 lift
を書けばいいのかパッとみよくわからないだろう。EE
では、そういったことを考える必要性はない。
ここで、あるていど使っているひとは疑問に思うかもしれないことがある。それは、「ReaderT Int (Reader Int) a
のように同じ型の状態を持つ Reader
が重なってるような状況をどう表現するの?」ということだ。これには、お馴染の
newtype
ハックを使えばよい。そもそも、こういった型の設計はプログラムとして余りたちのいいものではないし、それぞれの環境の意味をハッキリさせる意味でも、それぞれを
newtype
で包むべきだ。
newtype Page = Page Int deriving (Show, Eq, Ord, Num, Integral)
newtype Line = Line Int deriving (Show, Eq, Ord, Num, Integral)
lineLen :: Int
= 40
lineLen
linePerPage :: Int
= 40
linePerPage
currentPos :: (Member (Reader Page) r, Member (Reader Line) r) => Eff r Int
= do
currentPos Page p <- ask
Line l <- ask
return (p * linePerPage * lineLen + lineLen * l)
こうすれば、 lift
も要らないし、パターンマッチの所で型が確定するので、型注釈も要らない。
また、上のコードを見て気付くひとは気付くかもしれないが、多少の柔軟性を犠牲にすれば、Eff
型を mtl
の MonadReader
や
MonadState
のインスタンスにすることも簡単に出来る。こうした準備をしておけば、mtl
の API を使って実質的に EE を使ったプログラムが掛ける。また、mtl から EE
への移行を本格的にしようと思っても、コードに殆んど手を入れる必要はない。型注釈の所を書き換えて、lift
を取り除いたりすれば大抵の場合ちゃんと動くようになる。EE
はモナド変換子とは本質的には異なる実装ではあるが、ユーザの側では殆んど変更なしで採り入れることが出来るのだ。
この lift
と関連して、モナド変換子のアプローチにはもう一つ限界がある。それは、セマンティクスが柔軟ではないということだ。その例を見てみよう(以下、論文からの例)。
問題設定は簡単だ。ここでは、非決定計算とエラー処理とを組み合わせることにしよう。非決定的にリストの計算を進めて、途中で 5 より大きな数が出て来たらそこで処理を中断することにしよう:
newtype TooBig = TooBig Int deriving (Show, Eq, Ord)
instance Error TooBig
choice :: MonadPlus m => [a] -> m a
= msum . map return
choice
ex2 :: MonadError TooBig m => m Int -> m Int
= do
ex2 m <- m
v if (v > 5)
then throwError (TooBig v)
else return v
> runIdentity (runErrorT (runListT (ex2 (choice [5,7,1]))))
ghciLeft (TooBig 7)
> runIdentity (runErrorT (runListT (ex2 (choice [5,4,1]))))
ghciRight [5,4,1]
ところで、ここでお上の都合で「やっぱり 7 以下の数は大きくないんじゃね?」ということになったとする。そこで、例外をキャッチして、 7 以下だったら処理を再開するようにしてみよう。
exRec :: MonadError TooBig m => m Int -> m Int
= catchError m handler
exRec m where
TooBig n) | n <= 7 = return n
handler (= throwError e
handler e
> runIdentity (runErrorT (runListT (exRec (ex2 (choice [5,4,1])))))
ghciRight [5,4,1]
> runIdentity (runErrorT (runListT (exRec (ex2 (choice [5,7,1])))))
ghciRight [7]
おや……?最後の計算では、[5,7,1]
が返って欲しいのに [7]
しか返ってきていない。なんでだろう!
これは、この函数の型が ListT (ErrorT TooBig Identity) [Int]
であって、ErrorT
がより底の方にあるからだ。つまり、例外 TooBig 7
が飛んだ時点で、上に重ねられていた非決定計算としての ListT
での演算は忘れ去られてしまうのだ!
どうしよう……と悩んで、じゃあ ErrorT TooBig
を二重に張り巡らせたらどうだろう?という気分になったとしよう:
ErrorT TooBig (ListT (ErrorT TooBig Identity)) a
一旦非決定計算上の各ブランチで例外を見てあげて、それからそれを下の基盤に近いほうのエラーモナドに伝播してやろうと云う戦略をとる訳だ:
runErrorRelay :: MonadError e m => ErrorT e m a -> m a
= runErrorT m >>= check
runErrorRelay m where
Right x) = return x
check (Left e) = throwError e
check (
> runIdentity (runErrorT (runListT (runErrorRelay (exRec (ex2 (choice [5, 7, 1]))))))
ghciRight [5]
えーーーこれどういうことなの……。何で 5 だけ残るの……。
よぉく考えてみよう。ex2
のコードを見ると、一番最初に v <- m
としてアクションの値を取り出している。でも、今このコードでの m
は「ErrorT TooBig (ListT (ErrorT TooBig Identity)) Int
」
という型の、一番左の ErrorT TooBig
に包まれた値なのだ。ここで使われている MonadPlus
のインスタンスは、ListT
ではなく、ErrorT a
に対するインスタンスなのだ。これを本来非決定計算の文脈に持っていきたいのだから、ここで
lift
を使わなくてはいけないことがわかる:
ex1 :: Monad m => m Int -> ErrorT TooBig m Int
= do
ex1 m <- lift m
v if v > 5 then throwError (TooBig v) else return v
> runIdentity (runErrorT (runListT (runErrorRelay (exRec (ex2 (choice [5, 7, 1]))))))
ghciRight [5,7,1]
や、やっと動いた……。
これは、モナド変換子が本質的に抱える限界を表している例だ。では、これを EE を使って書いてみよう。
ex2 :: Member (Exc TooBig) r => Eff r Int →Eff r Int
= do
ex2 m <- m
v if v > 5 then throwError (TooBig v) else return v
runErrBig :: Eff (Exc TooBig :> r) a -> Eff r (Either TooBig a)
= runError m
runErrBig m
exRec :: Member (Exc TooBig) r => Eff r Int -> Eff r Int
= catchError m handler
exRec m where
TooBig n) | n <= 7 = return n
handler (= throwError e
handler e
> run (runErrBig (makeChoice (exRec (ex2 (choose [5,7,1])))))
ghciRight [5,7,1]
lift
なんて書く必要もないし、また型の部分以外は、mtl
で書いたプログラムと殆んど変わっていないことがわかるだろう。このように、EE
は継続渡しを基本として、最終的に一つのモナドとして実行されるフラットな設計になっているので、直観的にモナドの効果を組み合わせることが出来るのだ。
階層を超えた副作用
ここまでの例で、「単純に階層構造を委譲関係と捉えたもの」という訳ではないことがわかったという。更に、EE では階層を超えた副作用のハンドリングが出来る。つまり、モナド変換子の4. モナドの合成順は固定されてい、途中で入れ替えることができない という限界を乗り越えることが出来るのだ。
この解説を書くつもりでいたのだが、段々面倒になってきたので詳細は論文を参照して欲しい。論文では、大域環境とコルーチン的な副作用を合わせもったプログラムを書こうとしている。まず、全体でひとつの環境を共有した状態でスタートするが、各スレッドの中で環境が変更されたら、その環境は以後外から切り離されるような仕組みを作りたいとする。詳しい説明は面倒だしちゃんと理解出来ていないのでここでは書かないが、実は、モナド変換子を使った方法ではこのようなプログラムは書けない。理由は論文を参照してほしい。しかし、EE を使うとこのような処理も書けるようになるらしい。
おわりに
よくわかっていない部分もあった(特に最後の例)が、駆け足で Extensible Effects について解説してきた。改めて特徴をまとめると次のようになる:
- 変換子を重ねるのではなく、一つのモナドの中でハンドラを組み合わせて記述する
- ある種の Server-Client モデルや Chain of Responsibility パターンと見做せる
- 副作用を幾つ組み合わせてもオーバーヘッドは生じない
- 面倒な
lift
は不要 - 単純な一本鎖ではなく、階層を跨いだ処理も可能(?)
- mtl の API の上に載せることも出来、あるいは mtl からも型を変えるだけで簡単に移行出来る
- 内部的には継続渡しに基づいたコルーチンとして実装されている
読んでいて気になった点は以下の通り(私の理解不足もあるだろうので、こうじゃないの?というのがあれば教えて頂けると幸い):
- 函数が
Typeable
インスタンスを要求する -
EE は委譲の判断をする際に内部的に
gcast
を用いており、これを使うのにTypeable
のインスタンスが必要になる。大抵の型はderiving
なりを使ってやれば簡単にTypeable
に出来るが、必ずしもそうは行かないものもあり、また型制約部分が汚なくなるような気がするので、余り嬉しくないような気がする。 - ハンドラは継続渡しで書く必要がある
- 既存の物を組み合わせてプログラムを組む場合は良いが、自分で新たな副作用を追加したいと思ったときに、継続渡し形式でプログラムを書くのは慣れていないとちょっと手間がかかるのではないだろうか。
MonadPlus
やApplicative
スタイルとの兼ね合い-
モナド変換子の場合、
Applicative
やMonadPlus
のインスタンスは、上の例でも出て来たように先頭のモナド変換子が決定する。しかし、Eff
モナドの場合はその辺りのセマンティクスはどのように決定されるのだろうか?セマンティクスが柔軟だと書いてあるが今一よくわからない。多分、上の例を見る限り、一番最初にrun
したもののセマンティクスが選ばれる?余りありそうもないが、例えば上で「失敗」とされた挙動をして欲しいような場合はどう書けばいいのだろう。 - 階層を跨いだ実行(interleave)の意味がイマイチよくわからない
- どういうことなんだろう……。
- 既存のモナド変換子との兼ね合い
-
Conduit
であるとか、Parsec
であるとかとの連携の仕方?このEff
の上に載せられるだろうか。 - 理論的な取り扱い
- EE の手法は、応用上は確かにわかりやすい(継続渡しを書くことを除けば)が、理論的にはどのように取り扱われるのだろうか?より詳しく、数学的にどのように定式化されるのだろうか?その辺りがよくわからなかった。個人的に、こうしたものには数学的にしっかりとした基礎付けが欲しく思う(この辺りは異論も色々あるだろうとは思う)。
個人的な感触としては、EE はかなり頑張っていて中々良い代替案になりそうな気がする。ただ、モナド変換子の方がわかりやすいような局面もまだあるように思うし、これが救世主かと云われるとそうでないような気がする。理論的な背景がもう少し欲しくもあるし。いずれにせよ、一つの新しい方法で、それなりに面白く使い易そうなものであることは確かだし、何回か使ってみて感触を確かめてみたい。継続ベースなだけあって、例外処理や限定継続との相性は良さそうだ。
モナド変換子や合成に関する取り組みは他にも幾つかあるようで、たとえば mmroph や effects といったものもあるようなので、これらも時間があったら調べて比較してみたいと思う。