• BPnet
  • ビジネス
  • IT
  • テクノロジー
  • 医療
  • 建設・不動産
  • TRENDY
  • WOMAN
  • ショッピング
  • 転職
  • ナショジオ
  • 日経電子版
  • PR

  • PR

  • PR

  • PR

  • PR

本物のプログラマはHaskellを使う

第30回 状態を扱う複数のモナドを合成したRWSモナド

2009/04/08 ITpro

 この連載ではこれまで,状態を扱う様々なモナドを説明してきました。第6回ではStateモナド,第20回ではSTモナド,前回はReaderモナドとWriterモナドを取り上げました。

 状態を扱うこうしたモナドを複数組み合わせて利用したい場合もあります。まず思いつく方法は,モナド変換子を使って複数のモナドを合成することでしょう。ただし,モナド変換子を使う方法がいつでも最適だとは限りません。モナド変換子には利点もあれば欠点もあります。モナド変換子以外に,複数のモナドの能力を併せ持つ新しいモナドを定義する,という方法もあります。

 そこで今回は,モナド変換子を使う方法と,モナド変換子の代わりに新しいモナドを定義・利用する方法を比べてみます。複数のモナドの能力を利用するための二つの異なる方法を知ることで,それぞれの長所と短所が見えてきます。

モナド変換子を使うモナド合成の欠点

 まず,モナド変換子を使ってモナドを合成する方法をおさらいしましょう。ReaderTが定義されているControl.Monad.Readerモジュールでは,Writerモナドの関数を定義しているMonadWriterクラスとStateモナドの関数を定義しているMonadStateクラスに対するインスタンスが用意されています。

-- Needs -fallow-undecidable-instances
instance (MonadState s m) => MonadState s (ReaderT r m) where
    get = lift get
    put = lift . put

-- This instance needs -fallow-undecidable-instances, because
-- it does not satisfy the coverage condition
instance (MonadWriter w m) => MonadWriter w (ReaderT r m) where
    tell     = lift . tell
    listen m = ReaderT $ \w -> listen (runReaderT m w)
    pass   m = ReaderT $ \w -> pass   (runReaderT m w)

 同様に,StateTが定義されているControl.Monad.StateモジュールではReaderモナドの関数を定義しているMonadRearderクラスとMonadWriterクラスに対するインスタンス,WriterTが定義されているControl.Monad.Writerモジュールの中ではMonadReaderクラスとMonadStateクラスに対するインスタンスが用意されています。

-- This instance needs -fallow-undecidable-instances, because
-- it does not satisfy the coverage condition
instance (Monoid w, MonadReader r m) => MonadReader r (WriterT w m) where
    ask       = lift ask
    local f m = WriterT $ local f (runWriterT m)

-- Needs -fallow-undecidable-instances
instance (Monoid w, MonadState s m) => MonadState s (WriterT w m) where
    get = lift get
    put = lift . put

-- Needs -fallow-undecidable-instances
instance (MonadReader r m) => MonadReader r (StateT s m) where
    ask       = lift ask
    local f m = StateT $ \s -> local f (runStateT m s)

-- Needs -fallow-undecidable-instances
instance (MonadWriter w m) => MonadWriter w (StateT s m) where
    tell     = lift . tell
    listen m = StateT $ \s -> do
        ~((a, s'), w) <- listen (runStateT m s)
        return ((a, w), s')
    pass   m = StateT $ \s -> pass $ do
        ~((a, f), s') <- runStateT m s
        return ((a, s'), f)

 これらの定義の中のコメントで要求されている「-fallow-undecidable-instances」は,型クラスのインスタンス宣言に対する制限を緩和するためのGHCのオプションです。このオプションはGHC 6.10.1からは非推奨になっています。実際には,「-X*」オプションを使うか,LANGUAGE指示文で「UndecidableInstances」を指定してください(参考リンク)。Hugsでは,第7回の補足や第20回で説明したように,-98オプションを指定して非互換モードで処理系を起動することで,制限を同様に取り除くことができます(参考リンク)。

 ここで取り除いているのはどんな制限で,その制限は何のために存在しているのでしょうか? それを知るには,MonadReader,MonadWriter,MonadStateの三つのクラスを振り返る必要があります。

class (Monad m) => MonadReader r m | m -> r where
    ask   :: m r
    local :: (r -> r) -> m a -> m a

class (Monoid w, Monad m) => MonadWriter w m | m -> w where
    tell   :: w -> m ()
    listen :: m a -> m (a, w)
    pass   :: m (a, w -> w) -> m a

class (Monad m) => MonadState s m | m -> s where
        get :: m s
        put :: s -> m ()

 いずれも「|の右側に記述されている関数従属性によって,多引数型クラスの残りの型変数がモナドmの型から決定される」という定義になっています。しかし,例えばReaderTモナド変換子のインスタンス定義では,型変数mを具体化した「ReaderT r m」に,MonadStateクラスの型変数sやMonadWriterクラスの型変数wは含まれていません。

instance (MonadState s m) => MonadState s (ReaderT r m) where
~ 略 ~

instance (MonadWriter w m) => MonadWriter w (ReaderT r m) where
~ 略 ~

 したがって,これらのインスタンス定義だけでは,MonadStateクラスの型変数sやMonadWriterクラスの型変数wの型は確定しません。

 さいわいなことに,MonadStateクラスのインスタンス宣言には「MonadState s m」,MonadWriterクラスのインスタンス宣言には「MonadWriter w m」という文脈が付いています。これらの文脈をたどることで,型を確定できる可能性があります。そこでHaskellの型システムは,「ReaderT m」モナドの合成元であるモナドmに対するインスタンス宣言を使い,「ReaderT m」モナドのインスタンスを,MonadStateクラスの型変数sやMonadWriterクラスの型変数wの型を確定できるものに簡約しようと試みます。

 例えば,StateモナドとReaderモナドを合成したReaderT Stateモナドでは,合成元のStateモナドのMonadStateクラスに対するインスタンス定義を参照します。

instance MonadState s (State s) where
~ 略 ~

 Stateモナドでのインスタンス定義は「instance MonadState s (State s)」であるため,「instance (MonadState s m) => MonadState s (ReaderT r m)」は型変数mにState sを代入した「instance MonadState s (ReaderT r (State s))」に簡約できます。簡約後の「ReaderT r (State s)」は型変数sを含むため,「ReaderT Stateモナド」では関数従属性によってMonadStateクラスの型変数sを確定できます。同様に,「ReaderT (StateT m)」モナドではMonadStateクラスの型変数s,「ReaderT r Writerモナド」や「ReaderT (WriterT m)」モナドではMonadWriterクラスの型変数wを確定できます。

 「ReadrT (nT ... State)」といったモナドでは,文脈を1回たどるだけでは関数従属性によって型変数を確定できないこともあります。その場合,文脈を再帰的にたどっていくことで,型変数を確定できるインスタンスを探すことになります。

 このように関数従属性による制約を満たさないようなインスタンス宣言を認めることは,Reader,Writer,Stateの各モナド間の合成を可能にするために必要不可欠です。しかし,このようなインスタンス宣言はときに有害です。先に述べたように,インスタンスが関数従属性による制約を満たさない場合には,文脈をたどって制約を満たすインスタンスを探すことになります。探索の終了条件は,制約を満たすようなインスタンスを発見するか,それ以上文脈をたどれなくなるか,のどちらかです。このため,インスタンスの定義によっては,インスタンスの探索がループ構造になってしまい,永遠に型推論が終了しなくなる可能性があります(参考リンク)。

 こうした問題を回避するため,通常のHaskell処理系では「対応範囲条件(Converage Condition)」という規則に基づいて関数従属性「a -> b」による制約が満たされるかどうかを検査しています。もし,インスタンス宣言のbの中にaで制約されないような不確定の型変数が出てくる場合,そのようなインスタンス宣言をエラーにすることで無限ループの発生を防ぎます(参考リンク)。

 しかし,この制限は裏を返せばReader,Writer,Stateモナド間で合成を行うことが不可能になることを意味します。「関数従属性による制約を満たさないようなインスタンス宣言」とは「対応範囲条件を満たさないインスタンス宣言」のことだからです。例を見てみましょう。先に説明したように,ReaderTモナド変換子ではMonadWriterクラスのインスタンスは「instance (MonadState s m) => MonadState s (ReaderT r m)」と定義されているため,MonadStateクラスの関数従属性「m -> s」に対応するものは「ReaderT r m -> w」です。左辺の型変数「s」は右辺の「ReaderT r m」に出てこないため,対応範囲条件は満たされません。同様にReader,Writer,Stateモナドの合成に必要な他のインスタンス宣言も対応範囲条件を満たさないものになっています。

 このように,対応範囲条件という制限はReader,Writer,Stateモナド間で合成を行う場合に邪魔になるため,オプションで無効にしていたのです。

 オプションを使った制限の解除は,モナドの合成を行うには必要不可欠なものですが,それは同時にインスタンスの探索におけるループ構造の排除をユーザー自身の手にゆだねることを意味します。Reader,Writer,Stateの各モナドは,それぞれの間で合成を行っても特に問題が生じないよう定義されています。しかし,プログラマが独自に定義するモナドで問題が起こらない保証はありません。あるいは,制限の解除がモジュール全体に及ぶことで,モナドの合成とは関係ない個所のループ構造が問題を引き起こす可能性もあります。

 モナド変換子に対するインスタンスを定義する際のこうした問題を回避したければ,モナド変換子を使って合成を行うのではなく,複数のモナドの能力を併せ持つモナドを新たに定義して利用すべきです。モナド変換子によるモナド合成には一定の危険性があることを,くれぐれも忘れないでください。

 それでは,Reader,Writer,State,それぞれのモナドを実際に合成してみましょう。インポートを一つにまとめるため,StateMonadsモジュールを定義して利用しています。

module StateMonads where
import Control.Monad.Reader
import Control.Monad.Writer
import Control.Monad.State

*StateMonads> runReader (runWriterT (ask >>= tell)) "group"
((),"group")
*StateMonads> runReader (runWriterT (local ("semi-" ++) ask >>= tell)) "group"
((),"semi-group")
*StateMonads> runReader (runWriterT (listen $ ask >>= tell)) "group"
(((),"group"),"group")
*StateMonads> runReader (runWriterT (listens ("semi-" ++) $ ask >>= tell)) "group"
(((),"semi-group"),"group")
*StateMonads> runReader (runWriterT (censor ("semi-" ++) $ ask >>= tell)) "group"
((),"semi-group")
*StateMonads> runState (runWriterT (get >>= tell)) "group"
(((),"group"),"group")
*StateMonads> runState (runWriterT (get >> tell (Any True))) "group"
(((),Any {getAny = True}),"group")
*StateMonads> runReader (runStateT (runWriterT (ask >>= tell >> put (Any True))) (Any False)) "group"
(((),"group"),Any {getAny = True})
*StateMonads> runState (runReaderT (runWriterT (ask >>= tell >> put (Any True))) "group") (Any False)
(((),"group"),Any {getAny = True})
*StateMonads> runReader (runWriterT (runStateT (ask >>= tell >> put (Any True)) (Any False))) "group"
(((),Any {getAny = True}),"group")
*StateMonads> runWriter $ runReaderT (runStateT (ask >>= tell >> put (Any True)) (Any False)) "group"
(((),Any {getAny = True}),"group")
*StateMonads> runWriter $ runReaderT (ask >>= tell >> return 0) "group"
(0,"group")

 元になったそれぞれのモナドの機能を組み合わせて利用できていることを確認できました。

 ただし,こうした合成したモナドには,先ほど述べた危険性とは別に,使い勝手が悪いという問題もあります。

 第1に,上に挙げた例からわかるように,それぞれのモナドが参照する値を引数として与える場所が,モナドを合成する順番によって変わってきます。こうした「値をどこで与えればよいのかすぐにわからない」点は使いにくさにつながります。

 第2に,三つのモナドをどのように合成したかによって,使用できる関数が変わってきます。mtlパッケージのモナド型mはモナド変換子型mTと第15回で説明した恒等モナドと合成した「mT Indentity」ではないため,mに対する関数は型の違うmTに対して利用できないからです。この結果,例えばReaderモナドを合成元として使った場合にはRaeder型に対する関数であるwithReaderを使い,ReaderTモナドを合成元として使った場合にはReaderT型に対する関数であるwithReaderTを使うといった区別が必要になります。この問題を回避したければ,モナドを直接使う代わりに,ユーザー自身で「ReaderT Identity」のようにモナド変換子をIdentityに適用して合成したモナドを利用する必要があります

*StateMonads> runReader (withReader ("semi-" ++) (runWriterT (ask >>= tell))) "group"
((),"semi-group")
*StateMonads> runReader (runWriterT ((lift . withReader ("semi-" ++)) ask >>= tell)) "group"
((),"semi-group")
*StateMonads> runWriter $ runReaderT (withReaderT ("semi-" ++) ask >>= tell) "group"
((),"semi-group")

 第3に,これら三つのモナドを合成する順序が特に決まっていないうえ,withReader*とwithState*,execWriter*とexecState*といったように,共通する接頭辞と似たような意味を持つ関数が複数存在します。この結果,withReaderTとwithStateTのように共通する接頭辞を持つ関数を混ぜて利用する場合には,それぞれの関数をどのような意図で使っているのかがわかりにくくなります。下に挙げるような短い例であれば,使用している関数をモナドの合成順序から区別できるため,ソースコードの意味を理解するのはそれほど難しくありません。しかし,モナドの合成順序に一貫性のないような長いソースコードにこれらの関数が埋め込まれていると,意味が極端にわかりにくくなります。

*StateMonads> execWriter $ runReaderT (ask >>= tell >> return 0) "group"
"group"
*StateMonads> execWriter $ runReaderT (withReaderT ("semi-" ++) ask >>= tell >> return 0) "group"
"semi-group"
*StateMonads> runWriter $ runStateT (withStateT ("semi-" ++) get >>= tell >> return 0) "group"
((0,"semi-group"),"semi-group")
*StateMonads> runWriter $ evalStateT (get >>= tell >> return 0) "category"
(0,"category")
*StateMonads> runWriter $ execStateT (get >>= tell >> return 0) "category"
("category","category")

 モナド変換子により合成したモナドを使うと,こうした三つの要因により,ソースコードがわかりにくくなってしまいます。実際には,ソースコードの意図を明確にするために補助関数を適宜,定義すべきでしょう。

 もう少し使い勝手のよい方法はないものでしょうか? そのために用意されているのが,次に説明する「RWSモナド」です。

あなたにお薦め

連載新着

連載目次を見る

今のおすすめ記事

  • 【FPGAって何?】

    実はCPUもGPUも内蔵、いまどきのFPGAを知る

     2回に渡ってFPGAについて説明してきた。最終回であるこの3回目では、実際のFPGAの種類や製品展開をもう少し細かく紹介する。以前は、多数のメーカーがFPGAないしそれに類したものを手がけていた。ただベンダーの買収や製品ラインの統合、売却なども相まって、現在ベンダーはそれほど多くない。

ITpro SPECIALPR

What’s New!

経営

アプリケーション/DB/ミドルウエア

クラウド

運用管理

設計/開発

サーバー/ストレージ

クライアント/OA機器

ネットワーク/通信サービス

セキュリティ

もっと見る