拡張可能作用 ― モナド変換子に取って代わるもの 4 - shiatsumat/fp-papers GitHub Wiki
4. MTL全体をシミュレートする
[§2](拡張可能作用 ― モナド変換子に取って代わるもの 2#section2) と、[§3.4](拡張可能作用 ― モナド変換子に取って代わるもの 3#section3-4) で記述したインターフェースで実演したように、私たちのフレームワークは、モナド変換子と MTL を使っても書くことのできるようなコードを表現する。私たちはすでに、Reader
、State
、そして例外の作用という例を示してきた。このセクションでは、私たちのフレームワークが MTL の一般的で便利な2つの使い方――任意のモナドに Reader
などの作用を付け足すこと(「持ち上げ (lifting)」)と、MonadReader
などの MTL の作用の「クラス」――をどのように表現するのかを示す。付属のコードには MTL のイディオムを表現するより多くの例がある。私たちはまたしても、モナド変換子を使って書かれたコードが私たちのフレームワークへと最小限の変更で容易に翻訳されるということを見る。
4.1 モナディックな作用に対する型クラス
リャンら [[18](拡張可能作用 ― モナド変換子に取って代わるもの 9#reference18)] は、それぞれのモナディックな作用に結び付けられた原始的な演算として、個別に仕立てられたメソッドを提供する型クラスを導入した。例えば、(MonadReader e m)
は Reader
作用を持ち、型 e
の環境へのアクセスができて、原始的な Reader
の計算 ask
と local
を提供するモナド m
に対する型クラスである。MTL はこのスタイルの作用の型クラスに基づいているが、これには十分な理由がある。利点は、ask
と local
を使うコードが、モナド m
について (MonadReader e m)
の制約だけを要求して多相的であるということである。私たちのフレームワークは同様の柔軟性を提供する。更に進んで、MonadReader
のような型クラスはオープンな和(と柔軟性)を隠すことによって、具体的な、単独のモナドとして実装できるのである。
型クラス (MonadReader e m)
は m
から環境の型 e
が一意に定まるという関数従属 (functional dependency) を持っている。MonadReader
は Reader
作用の層をひとつしか持たない。このおかげで、型注釈はより少なくて済む。例えば、local (+1) (liftM (+2) ask)
を展開する中で、ask
も local
も同じモナドを参照している。MTL では、これらの式は同じ環境(ここでは数値型)を参照するのである。
一方、私たちの ask
と local
のインターフェースは、Reader
作用の数を制限しない。ゆえに、上のコードで local (+1)
と ask
は違う型の数値(一方は Int
、他方は Float
など)を参照しているかもしれない。もし2つの演算が同じ環境を扱っているということを意図しているならば、型注釈を足さなければならない(上のコードの 1
と 2
の両方に Int
という型注釈を付けるなどする)。短いプログラムにおいては、一般に数値などのすべての多相的なリテラルに型注釈をつけなければならない(長いプログラムはたいてい正しい型を示すだけの十分な文脈を持っている)。
ゆえに MTL の MonadReader
は、より表現力が低い(モナドに対し Reader
の作用を一つしか持たないように強制している点で)が、より便利でもある(型注釈が少なくて済むという点で)。[図1](拡張可能作用 ― モナド変換子に取って代わるもの 2#fig1) のインターフェースは逆のトレードオフを生み出す。しかしそれでも、拡張可能作用は一般性が小さいがより便利な MonadReader
インターフェース(と、同様に MonadState
と MonadError
)を実装できる――単純に Eff
ライブラリをインポートして、Eff r
モナドを MonadReader
のインスタンスとして定義するだけである。
import qualified Eff as E
instance (MemberU Reader (Reader e) r, Typeable e) =>
MonadReader e (Eff r) where
ask = E.ask
local = E.local
ここで、制約 MemberU Reader (Reader e) r
は、作用のオープンな和 r
が持っている Reader
作用が環境の型が e
のものだけであることを強制している。ゆえに、私たちのフレームワークのユーザーは2つの Reader
インターフェースのどちらを使うかという選択肢がある。付属するコードの中のファイル [ExtMTL.hs
](拡張可能作用 ― モナド変換子に取って代わるもの 付録#appendixe) では、私たちのフレームワークにおける MTL のモナド型クラスへのこのアプローチについての例をたくさん実演している。それらの例は MTL のものとまったく同じ見た目と感触であり、[Eff.hs
](拡張可能作用 ― モナド変換子に取って代わるもの 付録#appendixa) の例よりも型注釈が少なくて済んでいる。
4.2 任意のモナドの持ち上げ
モナド変換子の、価値があり普及している特徴は、任意のモナド(ユーザーが開発したものでも IO
, ST
, STM
のような組み込みのものでもよい)に対して作用を付け足せることである。例えば、MTL を使っていれば、ReaderT Int IO
は IO の演算を Int
の環境へのアクセスと組み合わせるモナドである。このような変換されたモナドにおける IO アクションは「持ち上げ」られて(lift
か liftIO
というのが前置される)、こうした変換が不透明になり、プログラム全体を通して体系的な変更を要求する。(更に、例外を捕捉する catch
のような IO 演算は変換されたモナドでは一般に使えない可能性がある。)
拡張可能作用は同じ限界の同じ特徴を持っている。私たちは Reader
、State
、生成子、その他の作用を任意のモナド m
に対して加えることができる。MTL と同様、アクション m a
は持ち上げられなければならない。(そして m a
アクションを引数として受け取る演算はアドホックな次善策を要求し、時にはそもそも使うことができない――というのがリュートら [[19](拡張可能作用 ― モナド変換子に取って代わるもの 9#reference19)] のアプローチの実状であった。)
lift
するのは MTL と似ているが、別のことを意味している。演算 lift m
は実行するためのアクション m
を Lift
ハンドラに送るのだ。
data Lift m v = forall a. Lift (m a) (a -> v)
lift :: (Typeable1 m, MemberU2 Lift (Lift m) r) =>
m a -> Eff r a
lift m = send (inj . Lift m)
このハンドラはリクエストを受け取り、アクションを展開・実行し、結果を送り返す。
runLift :: (Monad m, Typeable1 m) =>
Eff (Lift m :> Void) w -> m w
runLift m = loop (admin m) where
loop (Val x) = return x
loop (E u) = case prj u of
Just (Lift m k) -> m >>= loop . k
-- Nothing は起こりえない
ゆえに、私たちのアプローチでは、モナド m
の作用は、私たちのフレームワークの他の作用と同じように扱われる。しかし、重要な違いがある――任意のモナドは一般には合成しないので、Lift
の層は最高でも一つに限られるのである。runLift
は、その型により、Lift m
を持ち他の作用を持たない計算の「終了ハンドラ」になる。lift
の制約 MemberU2 Lift (Lift m) r
は Lift
の層の唯一性を保証する。私たちは [§2](拡張可能作用 ― モナド変換子に取って代わるもの 2#section2) で持ち上げの例を見た。
4.3 効率性
モナド変換子のそれぞれの層はオーバーヘッドを新たに生み出す。例えば、MTL で設計された、Int
の環境と Float
の状態を組み合わせるモナド ReaderT Int (StateT Float Identity)
は、脱糖衣化すると Int -> (Float -> (Identity a, Float))
という型を持つ。ゆえに、それぞれの return
はクロージャを2つ作らねばならず、それぞれの bind
がそれらのクロージャに適用されなければならない。2つの層の順序を変えると型が変わるが、2つのクロージャーのオーバーヘッドは依然として残る。しかしおそらく、計算全体のごく一部しか環境と状態にアクセスしないだろう。それにもかかわらず、計算全体がクロージャを作って適用するオーバーヘッドを払わなければならないのだ。皆が少数者しか求めていないものに対して対価を払うのだ。変換する層がより多く積み重なるとオーバーヘッドはさらに増える。私たちの拡張可能作用のフレームワークは、ハンドラの鎖全体に作用のリクエストを伝える。オーバーヘッドはリクエスタとハンドラの間のほかのハンドラの数に依るが、ハンドラの総数には依らない。ベンチマーク [Benchmarks.hs
](拡張可能作用 ― モナド変換子に取って代わるもの 付録#appendixg) は、ハンドラを加えると最悪の場合オーバーヘッドが増えるが、最高の場合パフォーマンスに影響しないということを裏付ける。MTL を使う場合、層を増やすと必ずオーバーヘッドが増える(そしてその増加は私たちのフレームワークより大きい)。