拡張可能作用 ― モナド変換子に取って代わるもの 4 - shiatsumat/fp-papers GitHub Wiki

4. MTL全体をシミュレートする

[§2](拡張可能作用 ― モナド変換子に取って代わるもの 2#section2) と、[§3.4](拡張可能作用 ― モナド変換子に取って代わるもの 3#section3-4) で記述したインターフェースで実演したように、私たちのフレームワークは、モナド変換子と MTL を使っても書くことのできるようなコードを表現する。私たちはすでに、ReaderState、そして例外の作用という例を示してきた。このセクションでは、私たちのフレームワークが MTL の一般的で便利な2つの使い方――任意のモナドに Reader などの作用を付け足すこと(「持ち上げ (lifting)」)と、MonadReader などの MTL の作用の「クラス」――をどのように表現するのかを示す。付属のコードには MTL のイディオムを表現するより多くの例がある。私たちはまたしても、モナド変換子を使って書かれたコードが私たちのフレームワークへと最小限の変更で容易に翻訳されるということを見る。

4.1 モナディックな作用に対する型クラス

リャンら [[18](拡張可能作用 ― モナド変換子に取って代わるもの 9#reference18)] は、それぞれのモナディックな作用に結び付けられた原始的な演算として、個別に仕立てられたメソッドを提供する型クラスを導入した。例えば、(MonadReader e m)Reader 作用を持ち、型 e の環境へのアクセスができて、原始的な Reader の計算 asklocal を提供するモナド m に対する型クラスである。MTL はこのスタイルの作用の型クラスに基づいているが、これには十分な理由がある。利点は、asklocal を使うコードが、モナド m について (MonadReader e m) の制約だけを要求して多相的であるということである。私たちのフレームワークは同様の柔軟性を提供する。更に進んで、MonadReader のような型クラスはオープンな和(と柔軟性)を隠すことによって、具体的な、単独のモナドとして実装できるのである。

型クラス (MonadReader e m)m から環境の型 e が一意に定まるという関数従属 (functional dependency) を持っている。MonadReaderReader 作用の層をひとつしか持たない。このおかげで、型注釈はより少なくて済む。例えば、local (+1) (liftM (+2) ask) を展開する中で、asklocal も同じモナドを参照している。MTL では、これらの式は同じ環境(ここでは数値型)を参照するのである。

一方、私たちの asklocal のインターフェースは、Reader 作用の数を制限しない。ゆえに、上のコードで local (+1)ask は違う型の数値(一方は Int、他方は Float など)を参照しているかもしれない。もし2つの演算が同じ環境を扱っているということを意図しているならば、型注釈を足さなければならない(上のコードの 12 の両方に Int という型注釈を付けるなどする)。短いプログラムにおいては、一般に数値などのすべての多相的なリテラルに型注釈をつけなければならない(長いプログラムはたいてい正しい型を示すだけの十分な文脈を持っている)。

ゆえに MTL の MonadReader は、より表現力が低い(モナドに対し Reader の作用を一つしか持たないように強制している点で)が、より便利でもある(型注釈が少なくて済むという点で)。[図1](拡張可能作用 ― モナド変換子に取って代わるもの 2#fig1) のインターフェースは逆のトレードオフを生み出す。しかしそれでも、拡張可能作用は一般性が小さいがより便利な MonadReader インターフェース(と、同様に MonadStateMonadError)を実装できる――単純に 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 アクションは「持ち上げ」られて(liftliftIO というのが前置される)、こうした変換が不透明になり、プログラム全体を通して体系的な変更を要求する。(更に、例外を捕捉する catch のような IO 演算は変換されたモナドでは一般に使えない可能性がある。)

拡張可能作用は同じ限界の同じ特徴を持っている。私たちは ReaderState、生成子、その他の作用を任意のモナド m に対して加えることができる。MTL と同様、アクション m a は持ち上げられなければならない。(そして m a アクションを引数として受け取る演算はアドホックな次善策を要求し、時にはそもそも使うことができない――というのがリュートら [[19](拡張可能作用 ― モナド変換子に取って代わるもの 9#reference19)] のアプローチの実状であった。)

lift するのは MTL と似ているが、別のことを意味している。演算 lift m は実行するためのアクション mLift ハンドラに送るのだ。

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) rLift の層の唯一性を保証する。私たちは [§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 を使う場合、層を増やすと必ずオーバーヘッドが増える(そしてその増加は私たちのフレームワークより大きい)。