Prev: さらにモナド変換子の例 TOC: 目次 Next: さらなる探究

変換子スタックの管理


合成するモナドの数が増えると、モナド変換子のスタックを上手く管理すること がますます重要になります。

正しい順序の選択

いったん、必要となるモナドの機能を決めたら、正しい順序でモナド変換子を 適用して、望む結果を得るようにしなければなりません。たとえば、 MonadError のインスタンスであるモナドと、 MonadState のインスタンスであるモナドを合成したいということ は判っていても、StateTError モナドに適用 すべきでしょうか、ErrorTState を適用 すべきなのでしょうか。

この決定は、合成したモナドにいったいどのようなセマンティクスを求めている のかに依存します。StateTError モナドに適用 すれば、s -> Error e (a,s) という型の 状態変換子関数が得られます。ErrorTState モナドに適用すれば、s -> (Error e a,s) という型の状態変換子関数が得られます。どちらの順を選ぶかは、計算中の エラーの役割によります。エラーが状態が作れないことを表すのなら、 StateTError に適用することになるでしょう。 エラーが値が作れないことを表し、状態はちゃんと作れるということを表す というなら、ErrorTState に適用することに なるでしょう。

正しい順番を選択するには、それぞれのモナド変換子によってもたらされる変換 を理解していなければなりません。また、その変換が合成されたモナドの セマンティクスにどのように影響するかを理解している必要があります。

複数の変換子を使う例

以下の例は複数のモナド変換子を使う例です。このコードでは、 StateT モナド変換子を List モナドと共に用い、 状態のある非決定性計算を行うための合成モナドを生成します。 しかしながら、ここでは、WriterT モナド変換子を追加して 計算中にログ取りを実行します。このモナドを適用する問題は、 有名な N-queen 問題です。これは、N 個のクィーンをチェス盤に互いに きき筋にないように置くというものです。

最初に決めることは、どのような順でモナド変換子を適用するかということです。 StateT s (WriterT w [])s -> [((a,s),w)] のような型をもたらします。 WriterT w (StateT s [])s -> [((a,w),s)] のような型をもたらします。 このふたつの場合では順序による違いはほとんどありません。それで、 ここでは独断で、2つめの順序を選ぶことにします。

ここで合成するモナドは MonadState および MonadWriter の両方のインスタンスです。それゆえ、 ここでのモナド計算中に、getput それに tell をまぜて使えます。

example25.hs で使えるコード
-- 問題を記述している型
data NQueensProblem = NQP {board::Board,
                           ranks::[Rank],   files::[File],
                           asc::[Diagonal], desc::[Diagonal]}

-- 初期状態は空の盤。すべての横筋、縦筋、斜め筋が空
initialState = let fileA = map (\r->Pos A r) [1..8]
                   rank8 = map (\f->Pos f 8) [A .. H]
                   rank1 = map (\f->Pos f 1) [A .. H]
                   asc   = map Ascending (nub (fileA ++ rank1))
                   desc  = map Descending (nub (fileA ++ rank8))
               in NQP (Board []) [1..8] [A .. H] asc desc

-- この問題用の合成モナドの型
type NDS a = WriterT [String] (StateT NQueensProblem []) a

-- 最初の解を得る。初期問題状態でソルバ計算を評価して、
-- 結果のリストから最初の解、あるいは、解がなければ Nothing を返す
getSolution :: NDS a -> NQueensProblem -> Maybe (a,[String])
getSolution c i = listToMaybe (evalStateT (runWriterT c) i)

-- 盤の指定された位置にクィーンを置く
addQueen :: Position -> NDS ()
addQueen p = do (Board b) <- gets board
                rs <- gets ranks
                fs <- gets files
                as <- gets asc
                ds <- gets desc
                let b'  = (Piece Black Queen, p):b
                    rs' = delete (rank p) rs
                    fs' = delete (file p) fs
                    (a,d) = getDiags p
                    as' = delete a as
                    ds' = delete d ds
                tell ["Added Queen at " ++ (show p)]
                put (NQP (Board b') rs' fs' as' ds')

-- ある位置が許された筋にあるかどうかをテスト
inDiags :: Position -> NDS Bool
inDiags p = do let (a,d) = getDiags p
               as <- gets asc
               ds <- gets desc
               return $ (elem a as) && (elem d ds)
	       
-- 許された全ての位置にクィーンを置く
addQueens :: NDS ()
addQueens = do rs <- gets ranks
               fs <- gets files
               allowed <- filterM inDiags [Pos f r | f <- fs, r <- rs]
               tell [show (length allowed) ++ " possible choices"]
               msum (map addQueen allowed)

-- 空のチェス盤からはじめて、要求された数のクィーンを置く
-- その後、盤を取得し、ログに従って解を印字する
main :: IO ()
main = do args <- getArgs
          let n    = read (args!!0)
              cmds = replicate n addQueens
              sol  = (`getSolution` initialState) $ do sequence_ cmds
                                                       gets board
          case sol of
            Just (b,l) -> do putStr $ show b    -- show the solution
                             putStr $ unlines l -- show the log
            Nothing    -> putStrLn "No solution"

このプログラムは前のカロタンパズルを解く例と似た方法をつかっています。 しかし、この例では、guard 関数を使って一貫性を検証していま せん。代りに、許されるクィーンの位置に対応するブランチを生成するだけです。 ここでは、ログ機能を使って、各ステップ毎に、可能性のある選択の数と クィーンを置いた場所を記録します。

多段もちあげ

複数のモナド変換子を使う上でまだ微妙な問題が残っています。前の例では 全ての計算が合成されたモナドで行われていたのに気付きましたか。 それらはひとつのモナドの機能しか使っていないにもかかわらずです。 合成されたモナドの定義に不必要に結びつけられたこれらの関数のコードは 再利用性を著しく低下させます。

ここが、MonadTrans クラスの lift 関数が 使える場所です。lift 関数によって、わかりやすいく、 モジュラリティ、再利用性のある方法でコードを書くことができます。 必要に応じて計算を合成されたモナドにもちあげましょう。

次のような脆いコードを書くかわりに、

logString :: String -> StateT MyState (WriterT [String] []) Int
logString s = ...

次のようなもっとわかりやすく、柔軟性のあるコードを書いて、

logString :: (MonadWriter [String] m) => String -> m Int
logString s = ...

logString 計算を利用時に合成モナドへもちあげます。

この技法を使うためには GHC のコンパイルフラグ -fglasgow-exts あるいは、それに相当するコンパイルフラグが 必要になるでしょう。問題は上の制約中の m が型ではなく、 型構築子であり、これが標準の Haskell 98 ではサポートされていないというこ とです。

複雑な変換子スタックでもちあげを使うときには、複数の lift を 合成することに気付くでしょう。こんなふうにです lift . lift . lift $ f x 。 これを追いかけるのは難しくなります。変換子スタックが (ErrorT を混合しようとする場合など)変化する場合、もちあげ は全コードにわたって変更する必要があります。これを防ぐコツは、 もちあげを行う補助関数を分りやすい名前で宣言することです。

liftListToState = lift . lift . lift

こうすると、コードが分りやすこなり、変換子スタックが変化しても、 もちあげコードに対する衝撃が、この少数のこれらの補助関数に限定されます。

もちあげの最も難しいところは、もちあげ計算のセマンティクスを理解する ところです。なぜかというと、もちあげのセマンティクスは、内部モナドと スタックされた変換子の詳細に依存するからです。最後に、次のような例の コードで、もちあげが果す役割り違いについて理解しましょう。このプログラム は何を出力するか予想できますか。

example26.hs で使えるコード
-- この問題用の合成モナドの型
type NDS a = StateT Int (WriterT [String] []) a

{- リスト上の計算 -}

-- 数の各桁をリストとして返す
getDigits :: Int -> [Int]
getDigits n = let s = (show n)
              in map digitToInt s

{- MonadWriter の計算 -}

-- 値をログに書き込み、その値を返す
logVal :: (MonadWriter [String] m) => Int -> m Int
logVal n = do tell ["logVal: " ++ (show n)]
              return n

-- ログを記録する計算を実行し、記録したログの長さを返す
getLogLength :: (MonadWriter [[a]] m) => m b -> m Int
getLogLength c = do (_,l) <- listen $ c
                    return (length (concat l))

-- 文字列値を記録し、0 を返す
logString :: (MonadWriter [String] m) => String -> m Int
logString s = do tell ["logString: " ++ s]
                 return 0

{- WriterT [String] [] を必要とする計算 -}

-- "Fork" the computation and log each list item in a different branch.
logEach :: (Show a) => [a] -> WriterT [String] [] a
logEach xs = do x <- lift xs
                tell ["logEach: " ++ (show x)]
                return x
		
{- MonadState の計算 -}

-- 状態を指定した値だけ増加する
addVal :: (MonadState Int m) => Int -> m ()
addVal n = do x <- get
              put (x+n)

{- 合成モナドの計算 -}

-- 状態を与えられた値に設定し、その値を記録する
setVal :: Int -> NDS ()
setVal n = do x <- lift $ logVal n
              put x

-- 計算を「フォーク」する。各ブランチの状態に異る数字を付加する
-- setVal が使われているので、新しい値は同じように記録される
addDigits :: Int -> NDS ()
addDigits n = do x  <- get
                 y <- lift . lift $ getDigits n
                 setVal (x+y)

{- 同等の構成は以下のとおり
addDigits :: Int -> NDS ()
addDigits n = do x <- get
                 msum (map (\i->setVal (x+i)) (getDigits n))
-}

{- これは、すべてのもちあげロジックを一箇所にまとめ、わかりやすい名前を
   持たせるのに使える補助関数の例です。これには、変換子スタックが将来
   変化(たとえば、ErrorTを追加)しても既存のもちあげロジックへの変更は
   少数の関数の範囲にとどまるという利点があります。
-}
liftListToNDS :: [a] -> NDS a
liftListToNDS = lift . lift

-- 合成モナドの一連の計算を実行する。計算を必要に応じて他のモナドから
-- もちあげる
main :: IO ()
main = do mapM_ print $ runWriterT $ (`evalStateT` 0) $ do x <- lift $ getLogLength $ logString "hello"
                                                           addDigits x
                                                           x <- lift $ logEach [1,3,5]
                                                           lift $ logVal x
                                                           liftListToNDS $ getDigits 287

この例で、様々なもちあげがどのように働くか、再利用性を促進するかを理解 してしまえば、実世界向きのモナドプログラミングができるようになります。 あとはやるべきことは、ほんもののソフトウェアを書く技術を磨くだけです。 Happy Hacking!


Prev: さらにモナド変換子の例 TOC: 目次 Next: さらなる探究