共通の記録場所としてのWriterモナド

 グローバル変数を使いたくなるのは値を参照するときだけではありません。プログラムの動作の記録(ログ)を取りたいときには,その保存場所としてグローバル変数が欲しくなります。あるいは,第15回で説明したtrace関数のように,unsafePerformIOを使って結果をプロンプトやファイルに直接出力したくなるかもしれません。

 そうした処理でも,グローバル変数やunsafePerformIOは必須ではありません。返り値の一部として動作のログを返したり,動作のログを含んだ新しい型を用意して返せばよいのです。ログを含む値を陽に扱いたくない場合には,StateモナドやSTモナドなどの「状態」としてログを書き込むこともできます。こうした処理は,非同期処理の問題を抱えるunsafePerformIOよりもはるかに安全です。

 ただし,StateモナドやSTモナドはログを取ることを意図したものではありません。これらのモナドでは書き込まれるデータの一貫性(consistency)は保証されないため,一貫性を持つようプログラマ自身が意識しなければなりません。そうした保障を持つ「書き込み専用」のモナドがあれば便利でしょう。それがWriterモナドです。トレースやログといった用途に使われることから,トレース・モナドやログ・モナドと呼ばれることもあります( 参考リンク)。

 WriterモナドはmtlパッケージのControl.Monad.Writer配下のモジュールで提供されています。Writeモナドには第21回で説明した正格モナド版と遅延モナド版の2種類があります。正格モナド版はControl.Monad.Writer.Strict,遅延モナド版はControl.Monad.Writer.Lazyでそれぞれ定義されています。Control.Monad.WriterモジュールではControl.Monad.Writer.Lazyモジュールの構成要素をそのまま再エクスポートするよう定義されているため,以後の説明では遅延モナド版を使います。

 Writerモナドの定義は以下の通りです。

newtype Writer w a = Writer { runWriter :: (a, w) }

~ 略 ~

instance Functor (Writer w) where
    fmap f m = Writer $ let (a, w) = runWriter m in (f a, w)

instance (Monoid w) => Monad (Writer w) where
    return a = Writer (a, mempty)
    m >>= k  = Writer $ let
        (a, w)  = runWriter m
        (b, w') = runWriter (k a)
        in (b, w `mappend` w')

 Functorクラスのインスタンスがaにだけ作用するよう定義されていることから,対(a, w)のaが値,wがログを保持するよう定義されていることがわかります。Monadクラスのインスタンスの定義では,memptyとmappendという見慣れないものが使われています。これらは型変数wの制約として付いているMonoidクラスが持つメソッドです。

 Monoidクラスは,baseパッケージのData.Monoidモジュールで以下のように定義されています。

class Monoid a where
        mempty  :: a
        -- ^ Identity of 'mappend'
        mappend :: a -> a -> a
        -- ^ An associative operation
        mconcat :: [a] -> a

        -- ^ Fold a list using the monoid.
        -- For most types, the default definition for 'mconcat' will be
        -- used, but the function is included in the class definition so
        -- that an optimized version can be provided for specific types.

        mconcat = foldr mappend mempty

 Monoidクラスは「モノイド(monoid,単位半群)」と呼ばれる構造を表現するためのものです。memptyとmappendは以下の三つの法則を満たすよう定義する必要があります(参考リンク)。

  1. mempty `mappend` x == x
  2. x `mappend` mempty == x
  3. x `mappend` (y `mappend` z) == (x `mappend` y) `mappend` z

 これらの三つの法則から,第4回で説明した,MonadPlusクラスが満たすべき以下の四つの法則を思い出すかもしれません。

  1. mzero >>= f == mzero
  2. m >>= (\x -> mzero) == mzero
  3. mzero `mplus` m == m
  4. m `mplus` mzero == m

 モノイド則は,MonadPlus則のうちの二つと対応します。モノイド則の1はMonadPlus則の3,モノイド則の2はMonadPlus則の4にそれぞれ対応します。モノイド則の3に直接対応するMonadPlus則はありませんが,インスタンスを定義する型によっては,MonadPlus則を満たすものが自然にモノイド則の3を満たすことがあります。例えば,リストに対するインスタンスの定義は,両者でほぼ同じです。

instance Monoid [a] where
        mempty  = []
        mappend = (++)

instance  MonadPlus []  where
    mzero =  []
    mplus = (++)

 MonoidとMonadPlusの最大の違いは,インスタンスになる型がモナドである必要があるかどうかという点です。MonadPlusはMonadを継承したクラスであるため,MonadPlusクラスのインスタンスになる型はMonadクラスのインスタンスである必要があります。これに対し,MonoidクラスはMonadクラスとは独立した存在であるため,MonoidクラスのインスタンスがMonadクラスのインスタンスである必要はありません。このため,MonoidはMonadPlusよりもシンプルに扱えます。

 Writerモナドに話を戻しましょう。上に示したように,returnと>>=は以下のように定義されています。

instance (Monoid w) => Monad (Writer w) where
    return a = Writer (a, mempty)
    m >>= k  = Writer $ let
        (a, w)  = runWriter m
        (b, w') = runWriter (k a)
        in (b, w `mappend` w')

 Writerモナドのreturnは,memptyを使うことで空のログを作成します。>>=は,左辺の式mで作成されたログwと右辺の式kで作成されたログw'を組み合わせて新しいログを作成します。これらのメソッドはモノイド則を満たすように定義されているため,「式をどのような順番で評価しても,Writerモナドによって作成されるログは同じものになる」という一貫性が保障されています。

 もちろん,returnで作成される空のログだけでは意味のあるログにはなりません。これでは,Writerモナドがいくら一貫性を保つための機能を持っていても絵に描いた餅です。そこで,Writerモナドではログを作成するための関数として,MonadWriterクラスのtellメソッドを用意しています。

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

instance (Monoid w) => MonadWriter w (Writer w) where
    tell   w = Writer ((), w)
    listen m = Writer $ let (a, w) = runWriter m in ((a, w), w)
    pass   m = Writer $ let ((a, f), w) = runWriter m in (a, f w)

 tellメソッドは任意の値wを基にログを作成します。こうして作成したログを>>=を通じてmappendに渡すことで,tellメソッドは一貫性を保った形でログを書き込むのです。

Prelude Control.Monad.Writer> runWriter $ tell "category"
((),"category")
Prelude Control.Monad.Writer> runWriter $ tell "semi-" >> tell "group"
((),"semi-group")
Prelude Control.Monad.Writer> runWriter $ return () >> tell "category"
((),"category")
Prelude Control.Monad.Writer> runWriter $ tell "category" >> return 0
((),"category")

 この例ではWriterモナドを使った計算の返り値とログが返っていますが,計算の返り値が必要なくログだけでいい場合には,runWriterの代わりにexecWriterを使います。

execWriter :: Writer w a -> w
execWriter m = snd (runWriter m)

Prelude Control.Monad.Writer> execWriter $ tell "category"
"category"
Prelude Control.Monad.Writer> execWriter $ tell "semi-" >> tell "group"
"semi-group"
Prelude Control.Monad.Writer> execWriter $ return () >> tell "category"
"category"
Prelude Control.Monad.Writer> execWriter $ tell "category" >> return ()
"category"

 MonadWriterクラスの残りの二つのメソッドは,ログを活用するための補助関数です。

 listenメソッドは,引数として与えた式mでのログの書き込み結果を取り出すのに使います。

Prelude Control.Monad.Writer> runWriter $ listen $ tell "group"
(((),"group"),"group")
Prelude Control.Monad.Writer> runWriter $ tell "group" >> listen (return ())
(((),""),"group")
Prelude Control.Monad.Writer> runWriter $ tell "semi-" >> listen (tell "group")
(((),"group"),"semi-group")

 ((a, w), w)を返すというlistenの定義の通り,値と式mによって書き込まれたログを対の第1要素として返しているのがわかります。

 ログをそのまま見るのではなく,何らかの加工を行ったうえで見たいこともあります。そんなときのために,ログの取得結果に関数を適用することで加工されたログを取得するlistensという関数も提供されています。

listens :: (MonadWriter w m) => (w -> b) -> m a -> m (a, b)
listens f m = do
    ~(a, w) <- listen m
    return (a, f w)

Prelude Control.Monad.Writer> runWriter $ listens show $ tell [()]
(((),"[()]"),[()])
Prelude Control.Monad.Writer> runWriter $ listens id $ tell "group"
(((),"group"),"group")
Prelude Control.Monad.Writer> runWriter $ listens ("semi-" ++) $ tell "group"
(((),"semi-group"),"group")

 ログの取得結果がlistensに渡した関数によって加工されているのがわかりますね。

 passメソッドは,式mで書き込まれるログを加工するのに使います。

Prelude Control.Monad.Writer> runWriter $ pass $ return $ ((), ("semi-" ++))
((),"semi-")
Prelude Control.Monad.Writer> runWriter $ pass $ return $ ("group", ("semi-" ++))
("group","semi-")
Prelude Control.Monad.Writer> runWriter $ pass $ tell "group" >>= \x ->  return ((), id)
((),"group")
Prelude Control.Monad.Writer> runWriter $ pass $ tell "group" >>= \x ->  return ((), ("semi-" ++))
((),"semi-group")

 この例では,対の第2要素として与えた関数によってログが加工されています。

 passは関数の渡し方が独特であるため,少し使いにくいと感じるかもしれません。この問題を解決するため,censorという補助関数が用意されています。

censor :: (MonadWriter w m) => (w -> w) -> m a -> m a
censor f m = pass $ do
    a <- m
    return (a, f)

 censorを使えば,上の例を以下のように単純化できます。

Prelude Control.Monad.Writer> runWriter $ censor ("semi-" ++) (return ())
((),"semi-")
Prelude Control.Monad.Writer> runWriter $ censor ("semi-" ++) (return "group")
("group","semi-")
Prelude Control.Monad.Writer> runWriter $ censor (id::String->String) (return "group")
("group","")
Prelude Control.Monad.Writer> runWriter $ censor ("semi-" ++) (tell "group")
((),"semi-group")

 ここまでは,Writerモナドが使用するログにリストを使う例を見てきました。ログにリストを使う場合,tellメソッドはログの書き込みを情報の追記という形で行います。では,他の型を使った場合にはどうでしょうか?

 例を挙げましょう。Data.Monoidモジュールで定義されているAll型は,論理積(AND)を求めるようにMonoidクラスのインスタンスを定義しています。

newtype All = All { getAll :: Bool }
        deriving (Eq, Ord, Read, Show, Bounded)

instance Monoid All where
        mempty = All True
        All x `mappend` All y = All (x && y)

 この結果,All型を使った場合に最終的にログとして残るものは,式の中で書き込んだすべてのログの論理積になります。

Prelude Control.Monad.Writer> execWriter $ tell (All False)
All {getAll = False}
Prelude Control.Monad.Writer> execWriter $ tell (All True)
All {getAll = True}
Prelude Control.Monad.Writer> execWriter $ tell (All True) >> tell (All False)
All {getAll = False}
Prelude Control.Monad.Writer> execWriter $ tell (All False) >> tell (All True)
All {getAll = False}
Prelude Control.Monad.Writer> execWriter $ tell (All True) >> tell (All True)
All {getAll = True}

 一方,Data.Monoidモジュールで定義されているAny型は,論理和(OR)を求めるようにMonoidクラスのインスタンスを定義しています。

newtype Any = Any { getAny :: Bool }
        deriving (Eq, Ord, Read, Show, Bounded)

instance Monoid Any where
        mempty = Any False
        Any x `mappend` Any y = Any (x || y)

 この結果,Any型を使った場合に最終的にログとして残るものは,式の中で書き込んだすべてのログの論理和になります。

Prelude Control.Monad.Writer> execWriter $ tell (Any False)
Any {getAny = False}
Prelude Control.Monad.Writer> execWriter $ tell (Any True)
Any {getAny = True}
Prelude Control.Monad.Writer> execWriter $ tell (Any True) >> tell (Any False)
Any {getAny = True}
Prelude Control.Monad.Writer> execWriter $ tell (Any False) >> tell (Any True)
Any {getAny = True}
Prelude Control.Monad.Writer> execWriter $ tell (Any True) >> tell (Any True)
Any {getAny = True}

 このように,WriterモナドではMonoidクラスのインスタンスとして定義された型を利用することで,自動的に加工されたデータをログとして残すことができます。passメソッドやcensor関数を使っていちいちログを加工するのが面倒な場合には,リスト以外のMonoidクラスのインスタンスになるデータ型を定義・利用してみるのもよいでしょう。

 2009年2月現在,Data.MonoidモジュールでMonoidクラスのインスタンスとしてすでに定義されている型には以下のようなものがあります。

Prelude Data.Monoid> :i Monoid
~ 略 ~
instance (Monoid a) => Monoid (Dual a) -- Defined in Data.Monoid
instance Monoid (Endo a) -- Defined in Data.Monoid
instance Monoid All -- Defined in Data.Monoid
instance Monoid Any -- Defined in Data.Monoid
instance (Num a) => Monoid (Sum a) -- Defined in Data.Monoid
instance (Num a) => Monoid (Product a) -- Defined in Data.Monoid
instance (Monoid a) => Monoid (Maybe a) -- Defined in Data.Monoid
instance Monoid (First a) -- Defined in Data.Monoid
instance Monoid [a] -- Defined in Data.Monoid
instance (Monoid b) => Monoid (a -> b) -- Defined in Data.Monoid
instance Monoid () -- Defined in Data.Monoid
instance (Monoid a, Monoid b) => Monoid (a, b)
  -- Defined in Data.Monoid
instance (Monoid a, Monoid b, Monoid c) => Monoid (a, b, c)
  -- Defined in Data.Monoid
instance (Monoid a, Monoid b, Monoid c, Monoid d) =>
         Monoid (a, b, c, d)
  -- Defined in Data.Monoid
instance (Monoid a, Monoid b, Monoid c, Monoid d, Monoid e) =>
         Monoid (a, b, c, d, e)
  -- Defined in Data.Monoid
instance Monoid Ordering -- Defined in Data.Monoid
instance Monoid (Last a) -- Defined in Data.Monoid