Prev: Reader モナド TOC: Contents Next: Continuation モナド

The Writer monad

Writer モナド


概観

計算のタイプ: 計算された値にとは別にデータのストリームを生成する計算
束縛の戦略: Writer モナド値は (計算の値, ログの値) という組みです。束縛によっ て計算の値は、束縛された関数を直前の値に適用した結果で置き換え、その 計算で得られた、なにがしかのログデータを既存のログデータに追加します。
利用場面: ログあるいは他の出力を「別に」生成する計算
ゼロおよびプラス: なし
型の例: Writer [String] a

動機

「別に」出力を生成するという計算が欲しい場面はよくあることです。 計算中に本質の結果ではないですが取っておきたいデータを作るような ログやトレースがよくある例でしょう。

ログやトレースのデータを明示的に管理するのはコードを取り散らかし、 ログエントリをとりこぼすなどの微妙なバグを招きます。Writer モナドは メインの計算を取り散らかすことなく出力を管理する明快な方法を 提供します。

定義

ここで示した定義では Haskell 98 の標準にはない、複数パラメータ型クラスお よび funDeps が使われています。この Writer モナドを利用するのに、これの 詳細を完全には理解する必要はありません。

モノイドがモナドよりシンプルであるということは良いことです。モノイド はオブジェクトの集合、単一の単位元そして集合上で定義された 結合的二項演算子です。モノイドはいくつかの数学的法則に従わなければ なりません。その集合上のどのような値に演算子を適用しても 結果はその集合の値になること。演算子の一方のオペランドが単位元であれば 結果はもう一方のオペランドに等しくなること。これらの法則は、 MonadPlus のインスタンスの mzero および mplus を支配している法則であることに気づいたことと 思います。これは、ゼロおよびプラスをもつモナドは、モナドであると 同時にモノイドであるということです。

数学的なモノイドの例としては、単位元 0 をもつ自然数と加法二項演算子で あるとか、単位元 1 をもつ自然数と乗法二項演算子があります。

Haskell ではモノイドは、型と単位元と二項演算子で構成されます。 Haskell では Monoid クラスが((in Data.Monoid で)定義され モノイドの標準的な機能が提供されています。単位元は、mempty という名前であり、演算子は mappend です。

Haskell でもっともよく使われる標準モノイドはリストですが、 (a -> a) という型の関数もモノイドを構成します。

リストを Writer 用のモノイドとしてリストを使うには注意が必要です。 それは、出力が大きくなるにつれ mappend 演算に伴う パフォーマンス上のペナルティが大きくなるからです。この場合、 早いアペンド操作をサポートするしかるべきデータ構造を選択すべきです。

newtype Writer w a = Writer { runWriter :: (a,w) } 
 
instance (Monoid w) => Monad (Writer w) where 
    return a             = Writer (a,mempty) 
    (Writer (a,w)) >>= f = let (a',w') = runWriter $ f a in Writer (a',w `mappend` w')

Writer モナドは (値,ログ) という組を保持します。ここで、ログの型は モノイドでなければなりません。return 関数は単純に空ログ付き の値を返します。束縛によって、入力として現在の値を使う束縛された関数が 実行され、すべてのログ出力が既存のログに追加されます。

class (Monoid w, Monad m) => MonadWriter w m | m -> w where 
    pass   :: m (a,w -> w) -> m a 
    listen :: m a -> m (a,w) 
    tell   :: w -> m () 
 
instance (Monoid w) => MonadWriter (Writer w) where 
    pass   (Writer ((a,f),w)) = Writer (a,f w) 
    listen (Writer (a,w))     = Writer ((a,w),w) 
    tell   s                  = Writer ((),s) 
 
listens :: (MonadWriter w m) => (w -> w) -> m a -> m (a,w) 
listens f m = do (a,w) <- listen m; return (a,f w)
 
censor :: (MonadWriter w m) => (w -> w) -> m a -> m a 
censor f m = pass $ do a <- m; return (a,f)

MonadWriter クラスは Writer モナドで機能するいくつもの便利 関数を提供しています。もっとも単純でもっとも有用なのは tell です。この関数は、1つ以上のエントリをログに追加します。 listen 関数は、a 型の値を返し、出力 w を生成 する Writer を、値 (a,w) を生成し、さらに、出力 w を、生成するような Writer に変換します。 これは Writer によって作られたログを「リッスン」する計算を可能にします。

pass 関数は少し複雑です。これは、値 (a,f) および出力 w を生成する Writer を、値 a および出力 f w を生成する Writer に変換します。これはちょっと面倒 なので通常は補助関数 censor を使います。 censor 関数は、関数と Writer を引数にとり、ログが その関数によって変換される以外は同じ出力の新しい Writer を返します。

listens 関数は値のログ部分が引数であたえられた関数で 変更されている以外は listen と同じ操作をします。

この例では、ソースおよびデスティネーションホスト、それに パケットのペイロードにマッチするルールに基づくルールベースの パケットフィルタリングをおこなう非常に単純なファイアウォールを 想定します。このファイアウォールの主たる仕事はパケット フィルタリングですが、実際の動作のログも生成させたいということです。

example17.hs で使えるコード
-- これはログエントリのフォーマットです
data Entry = Log {count::Int, msg::String} deriving Eq

-- メッセージをログに追加
logMsg :: String -> Writer [Entry] ()
logMsg s = tell [Log 1 s]

-- これは一つのパケットを扱います
filterOne :: [Rule] -> Packet -> Writer [Entry] (Maybe Packet)
filterOne rules packet = do rule <- return (match rules packet)
                            case rule of
                              Nothing  -> do logMsg ("DROPPING UNMATCHED PACKET: " ++ (show packet))
                                             return Nothing
                              (Just r) -> do when (logIt r) (logMsg ("MATCH: " ++ (show r) ++ " <=> " ++ (show packet)))
                                             case r of
                                               (Rule Accept _ _) -> return (Just packet)
                                               (Rule Reject _ _) -> return Nothing

これは非常に単純ですが、連続して重複したエントリをマージしたいとしたら どうしますか。計算の前段からの出力を変更できる既存の関数というのはあり ません。しかし、「遅延ログ」というトリックを使ってそれ以前のログに マッチしない新しいエントリが得られた後にのみログを追加することができます。

example17.hs で使えるコード
-- ログの最後で同一のエントリをマージする
-- この関数は、[Entry] をログの型および結果の型として使う
-- 2 つの同一のメッセージがマージされると、その結果は、そのメッセージと
-- インクリメントされたカウントになる。2 つの異るメッセージがマージ
-- されると、最初のメッセージがログとなり、ふたつ目のメッセージは
-- 結果として返される
mergeEntries :: [Entry] -> [Entry] -> Writer [Entry] [Entry]
mergeEntries []   x    = return x
mergeEntries x    []   = return x
mergeEntries [e1] [e2] = let (Log n msg)   = e1
                             (Log n' msg') = e2
                         in if msg == msg' then
                              return [(Log (n+n') msg)]
                            else
                              do tell [e1]
                                 return [e2]

-- これは複雑な外観の関数ですが、実際にはたいへんシンプルである
-- これは関数を値のリストにマップして、Writer のリストを得た後、それぞれ
-- の Writer を実行し、結果を合成する。この関数の結果は、その値が
-- すべての Writer からの値すべてのリストで、そのログ出力は、
-- (initila をログの初期値として)、マージ演算子を個々のログエントリに
-- 畳み込んだ結果です。
groupSame :: (Monoid a) => a -> (a -> a -> Writer a a) -> [b] -> (b -> Writer a c) -> Writer a [c]
groupSame initial merge []     _  = do tell initial
                                       return []
groupSame initial merge (x:xs) fn = do (result,output) <- return (runWriter (fn x))
                                       new             <- merge initial output
                                       rest            <- groupSame new merge xs fn
                                       return (result:rest)
     
-- これはパケットのリストをフィルタし、フィルタされたパケットのリストと
-- 連続したメッセージがマージされた活動ログを生成する
filterAll :: [Rule] -> [Packet] -> Writer [Entry] [Packet]
filterAll rules packets = do tell [Log 1 "STARTING PACKET FILTER"]
                             out <- groupSame [] mergeEntries packets (filterOne rules)
                             tell [Log 1 "STOPPING PACKET FILTER"]
                             return (catMaybes out)

Prev: Reader モナド TOC: Contents Next: Continuation モナド