8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

タプルはモナド

Last updated at Posted at 2021-07-09

先日 GHCi で :i (,) したら Monad のインスタンス定義があることに気づいた。

instance Monoid a => Monad ((,) a) -- Defined in ‘GHC.Base’

ドキュメントによるとどうやら base-4.9.0.0 から定義されているらしい。そうだよね。昔はなかった気がするもんね。

でもタプルにモナドインスタンスの定義があるからといって特に驚くべきものというわけでもない。このモナドはもうみんな知っているはずだ。 Writer である。

newtype Writer w a = Writer { runWriter :: (a, w) } 
instance (Monoid w) => Monad (Writer w)

モナド副作用としてログ値を隠し持つためにタプルの片方を使おうというわけだ。 Writer の定義では fst 側が値、 snd 側がログ値だが、タプルそのものに定義されているものはこれが逆で、 fst 側がログ値、 snd 側が値となっているらしい。

というわけで fst 側にログ値を置けばそれがモナド裏に隠し持たれ、最後に全部 mappend されてくっついたものが得られる。ちょっと使ってみよう。

main :: IO ()
main = print $ do
  (["log1"], ())
  x <- (["log2"], 2)
  y <- (["log3"], 3)
  return $ x + y
(["log1","log2","log3"],5)

<- の右にタプルそのものが現れるので do 構文としてはちょっと珍しい形になるのが面白い。とはいうものの見た目の話ならリストモナドも似たようなものか。Writer でいうところの tell log(log, ()) にあたる。 return value(mempty, value) である。

もう少し実用的に使えそうな例も考えてみよう。例えば ByteString を先頭から少しずつ切り取っていくことを考える。最初の 2 bytes は固定の値が入っていて、次の 2 bytes はバージョン番号で、その次の 4 bytes はファイルサイズで…、なんてのはありそうな話だろう1。愚直に書くとこんな感じになる。

import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy as LB
import qualified Data.ByteString.Lazy.Char8 as C

parseFile :: ByteString -> ([ByteString], ByteString)
parseFile bs = ([a, b, c], bs''')
  where
    (a, bs') = LB.splitAt 2 bs
    (b, bs'') = LB.splitAt 2 bs'
    (c, bs''') = LB.splitAt 4 bs''

main :: IO ()
main = print . parseFile $ C.pack ['A'..'Z']
(["AB","CD","EFGH"],"IJKLMNOPQRSTUVWXYZ")

ううむ。なんだろう、この bs''' てのは。別に bs1 bs2 ... でも良いのだけど、どちらにせよとても野暮ったい感じがする。この例のように三つくらいならまだ良いけど、数が増えていくたびにつらい気持ちになるはずだ。

そこでタプル自身がモナドであることを利用してこれをちょっと書き換えてやる。

-- 他の import と main は前の例と同じ
import Control.Arrow (first)

parseFile :: ByteString -> ([ByteString], ByteString)
parseFile bs =
  split 2 bs
    >>= split 2
    >>= split 4
  where
    split n = first pure . LB.splitAt n

ふむふむ。これで野暮ったい変数名を付けていく必要がなくなって多少マシになった。とはいえこれでも数が増えれば似たような行がずっと並ぶわけで、それももうちょっとどうにかしたいよね。

うーん。この >>= が連続するパターンはどこかで見たことがあるぞ。そうだ、これは foldM だ。

-- 他の import と main は前の例と同じ
import Control.Monad (foldM)

parseFile :: ByteString -> ([ByteString], ByteString)
parseFile bs = foldM split bs [2, 2, 4]
  where
    split b n = first pure $ LB.splitAt n b

これで同じ結果が得られる。コードとしては大分すっきりしたのではなかろうか2

  1. もっとも、この用途なら binary パッケージの Data.Binary.Get を使うのがおすすめだ。

  2. ところで Writer のログ値にリストを使うと、ログが多くなるにつれて mappend の効率が悪くなっていくという残念な性質がある。必要に応じて気を付けたい。

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?