LoginSignup
9
5

More than 3 years have passed since last update.

具体例から学ぶモナド再入門

Last updated at Posted at 2020-06-23

具体例から学ぶモナド再入門

 Haskellで使うモナドはほんの数種類しかありません。それも3, 4種類に分けられ、同じ種類のモナドは性質も使い方も似ているため、モナドを学ぶならどうせ数種類しかないこれらの具体例を理解するのが近道です。

 実際、ライブラリで定義されているモナドや自分自身で作るモナドは大体Stateモナドか何かのnewtypeですし、GeneralizedNewtyoeDeriving拡張のおかげでモナドのインスタンスを自力で書く機会もほとんどありません(本当か?)。

 そこで、一般的な数種類のモナドの紹介をして、それにモナド再入門というタイトルをつけることにしました。

この記事を読むのに必要な知識

  • Haskellの基本的な文法
  • モナドの型クラス宣言を見たことがある(return>>=の存在を知っている)

モナドの基礎

 モナドを扱う上で重要な概念は、アクション実行の2つです。

  • アクション
    • モナドの型を持つ値のこと。IO ()IO StringList Intなど
    • 引数を与えるとアクションになる関数もたくさんある
      • putStr :: String -> IO ()は、引数を与えれば putStr "Hello" :: IO ()というアクションになる
  • 実行
    • モナドを外すこと。モナドは大抵ただのnewtypeなので、ベースになっている型とモナドを行き来させることができる
    • 通常run*という名前を持つ

4種類のモナド

 モナドを大きく4つに分類しました。この分類は一般的なものではなく私が考えたものですので、この分類から漏れているモナドもありますし、他の人は違うように分類するかもしれません。

  • List系
    • 値を内包するモナド
    • 他の言語にも登場するため、初心者にも馴染み深い
  • State系
    • 状態を持つモナド
    • 分かりやすい鬼門
  • Cont系
    • 継続モナド
    • これを学ぶのはListやStateの後で良い
  • Free系
    • 自由モナド
    • 謎に流行ったことがある

Listモナド

 任意の数の値を内包するモナドです。

亜種:

  • Maybe
  • Identity
  • Either

 MaybeとIdentityは、Listの保持する値の数を制限したものです。
 EitherはMaybeの一般化ですね。

代表的なアクション:

  • Nothing
  • [], repeat, ..
  • Left

特徴

 初めて学ぶモナドはIOよりもこちらではないでしょうか?
 この類のモナドは下のような特徴を持ちます。

  • コンストラクタが複数あるので、run*ができない(Identity除く)
  • モナドトランスフォーマー(MaybeT, ListTなど)の使用があまり一般的ではない
  • Haskellを知らない人でも>>=の挙動に馴染みがある。JavaではflatMapという名前でStreamやOptionalが持っている

 Identityを除いて、これらモナドはしばしば例外処理に使われます。すなわち、例外処理の観点では、アクションが例外を投げることに相当します。
 Identityは何もしないモナドで、モナドトランスフォーマーの文脈でよく使われます。

使用例

 flatMapっぽい使い方の例。

>>> print $ do
>>>     a <- [1, 2, 3]
>>>     b <- [4, 5, 6]
>>>     return $ a * b
[4,5,6,8,10,12,12,15,18]

Stateモナド

 状態を持つモナドです。

亜種:

  • IO
  • Reader
  • Writer
  • ->
  • Kleisli

 Stateは情報を内包するモナドで、アクションによって情報を読み書きすることができます。IOは状態として「世界」を持たせたStateモナドで、これはアクションによって「世界」を更新しています。
 Readerは読み込み限定、Writerは追記限定のStateモナドという認識でいいと思います。
 関数->も実はモナドであり、引数を状態のように引き回せます。Kleisliは初めて聞くかもしれませんが、この子は->の親戚みたいなものです。->がHask圏の射を表すのに対して、Kleisliはクライスリ圏の射を表します。

代表的なアクション:

  • get, put, modify
  • putStrLn s, getLine, ...
  • ask, tell
  • (*2), (+3), ...

特徴

 いずれも関数をnewtypeしたものです。
 mは任意のモナドを表す型変数。Identityは何もしないモナドなので、このmにIdentityを入れると下のmのないバージョンを得られます。

  • StateT: s -> m (a, s)
    • State: s -> (a, s)
  • ReaderT: r -> m a
    • Reader: r -> a
  • WriterT: w -> m (a, w)
    • Writer: w -> (a, w)
  • ->: a -> b
  • Kleisli: a -> m b

 StateTとWriterTは同型(直観的には同じ型、もう少し正確にすると情報を落とさずに相互に変換できる型)ですが異なるアクションを持ちます。WriterTのメソッドtellは状態に対する追記をすることしかできません。
 ReaderTとKleisliは同型で、Readerと->も同型。これらはモナドとしては非常に似た振る舞いをします。

 ところで、関数をnewtypeしたものということは、これらのモナドは小粋な名前のついただけの関数ということです。
 関数のモナド的振る舞いというのはどういうものでしょうか?

 例えば、引数にHandlerを受け取るような関数があります。

hPutChar :: Handle -> Char -> IO ()
hPutStr :: Handle -> String -> IO ()
hPutStrLn :: Handle -> String -> IO ()

 型をよく見ると、出力する文字や文字列を先に与えれば、どれもHandle -> IO ()の形にできます。
 ReaderTはr -> m aのnewtypeなので、この型にはReaderTをかぶせたりrunReaderTで元の型に戻したりできます。
 では実際にReaderTをかぶせるとどうなるでしょうか。mtlのモジュールを使っています。

import Control.Monad.Reader

type Foo = ReaderT Handle IO

putCharM :: Char -> Foo ()
putCharM = ReaderT . flip hPutChar
-- (2つは省略)

 見やすさのために、型シノニムでReaderT Handle IOFooと名付けました。
 関数Handle -> IO ()がアクションFoo ()になり、引数からはHandleが消えました。もちろんrunReaderTで元の型に戻ります。
 do文を使って、これらのアクションを繋げてみましょう。

doSomething :: Foo ()
doSomething = do
    putCharM "H"
    putStrM "ello, "
    putStrLnM "monad!"

 まるでHandleのない標準出入力用の関数のような使い心地になりました。
 ところで、このdoSomethingrunReaderTを使えば元の型に戻ります。

doSomething' :: Handle -> IO ()
doSomething' = runReaderT doSomething

 Handleを渡してあげればdoSomethingはIO ()という見慣れたアクションになります。ここで渡したHandleは、ReaderTの下敷きになったhPutCharなどに渡されます。

import System.IO

main :: IO ()
main = do
    handle <- openFile "output.txt" WriteMode
    runReaderT doSomething handle
    hClose handle

Contモナド

 継続モナドです。

代表的なアクション:

  • Cont
  • callCC
  • reset, shift

特徴

 ListやStateに比べると、少し難解なモナドです。
 ContTは(a -> m r) -> m rという型をモナドのインスタンスにしたもので、これも関数型をnewtypeしたものですね。

 (a -> m r) -> m rという型は、リソース管理を要するwith*関数に多く見られます。代表的なものはwithFile :: FilePath -> IOMode -> (Handle -> IO r) -> IO rでしょう。
 この一見奇妙な型は、リソースの獲得/解放をワンセットにしようと思うと生まれます。例えば、使用後は間違いなく閉じなければならないMyHandleがあったとしたら、下のような関数を提供したくなります。

import Control.Exception (finally)

withMyHandle :: (MyHandle -> IO r) -> IO r
withMyHandle processHandle = do
    handle <- openMyhandle
    processHandle handle
        `finally`
        closeMyHandle handle

 この(MyHandle -> IO r) -> IO rという型が、まさにContTモナドであるところの(a -> m r) -> m rです。

 ファイルハンドルを間違いなく閉じてくれるwithFileを使って、ファイルをコピーするアクションを書いてみましょう。
 withFileを使えば、たとえどこで例外が発生してもファイルハンドルの閉じ忘れが起こりません。そのありがたみは、特に複数のファイルハンドラを同時に開く時に強く感じられます。

import System.IO

copyFile :: ContT () IO ()
copyFile = do
    hSrc <- ContT $ withFile "srcFile.txt" ReadMode
    hDst <- ContT $ withFile "dstFile.txt" WriteMode
    src <- liftIO $ hGetContents hSrc
    liftIO $ hPutStr hDst src

 finallyがネストしているのに、インデントは平らなままです。
 もちろん、runContTすれば元の継続渡しスタイルに戻ります。returnで継続渡しスタイルも解除してやれば、綺麗さっぱりIO ()が現れます。

copyFile' :: (() -> IO ()) -> IO ()
copyFile' = runContT copyFIle

copyFile'' :: IO ()
copyFile'' = copyFile' return

 詳しくは、継続モナドについてという記事を投稿しているので、そちらを読んでいただけると幸いです。

Freeモナド

 モナドを作るための枠組みです。

亜種:

  • Operational

特徴

 モナドを作るための枠組みであり、特徴はベースとなる構造によって与えられます。
 発展的なものなので、詳しい解説はせず、紹介だけさらっと行います。

 Freeは任意の関手からモナドを生み出すことができます。そして、任意の代数的データ型から関手を生み出せるCoyonedaとFreeを組み合わせ、どんな型からでもモナドを作り出せるようにしたものがOperationalです。
 モナドのアクションとは、モナドの型を持つ値のことであると説明しました。つまり、モナドのコンストラクタは全てアクションを生む何かだということです。
 そこで、コンストラクタでアクションを宣言することを考えます。

{-# LANGUAGE GADTs #-}

data ConsoleInst a where
    GetContents :: ConsoleInst String
    PutStr :: String -> ConsoleInst ()

 Operationalモナドを使って、これをモナドにします。また、上で宣言したコンストラクタを実際にそのモナドのアクションにします。
 実装にはmonad-skeletonを採用しました。

import Control.Monad.Skeleton

type Console = Skeleton ConsoleInst

getContents' :: Console String
getContents' = bone GetContents

putStr' :: String -> Console ()
putStr' = bone . PutStr

 次に、モナドの実行を作成します。ここでどのアクションが何をするのかを定義します。
 ここでは、GetContentsgetContentsを、PutStrputStrを実行するようにしました。
 見慣れないコードに面食らうかもしれませんが、実際はただのボイラープレートです。

runConsoleIO :: Console a -> IO a
runConsoleIO c = case debone c of
    Return a -> return a
    GetContents :>>= k -> getContents >>= runConsoleIO . k
    PutStr s :>>= k -> putStr s >>= runConsoleIO . k

 もう一つ、今度はピュアなバージョンの実行を作成してみましょう。

unConsolePure :: Monad m => Console a -> ReaderT String (WriterT String m) a
unConsolePure c = case debone c of
    Return a -> return a
    GetContents :>>= k -> ask >>= unConsolePure . k
    PutStr s :>>= k -> lift (tell s) >>= unConsolePure . k

runConsolePure :: Monad m => Console a -> String -> m (a, String)
runConsolePure = fmap runWriterT . runReaderT . unConsolePure

 同じモナドを実行するのに、異なる複数の結果を得ることができています。インターフェースのようなある種の抽象化ができているということですね。
 このモナドの実行時に各アクションの処理を定義する性質のおかげで、普通は悩みの種であるモックの作成も簡単です。

まとめ

 ListとStateさえ学べば、Haskellを普通に使うには困らないと思います。
 Contをさらっと使えればスマートに、FreeやOperationalをゴリゴリ使えれば変態になれます。

9
5
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
9
5