🎃

順を追って理解する MaybeT モナドの使い方

2021/03/10に公開

モナド変換子の1つである MaybeT について、順を追って説明してみたいと思います。

想定読者

  • モナドや Applicative スタイルを理解している。
  • モナド変換子について学び始めたので具体例を知りたい。

Maybeモナドの復習

2つの文字列を受け取り、両方とも数値の場合はそれらを足した数、そうでない場合はNothingを返す関数 add は以下のように実装できます。

import Text.Read (readMaybe)

readInt :: String -> Maybe Int 
readInt = readMaybe

-- |
-- >>> add "1" "2"
-- Just 3
-- >>> add "1" "x"
-- Nothing
--
add :: String -> String -> Maybe Int 
add sa sb = do
    a <- readInt sa
    b <- readInt sb
    return $ a + b

Maybe モナドにより失敗を上手に扱えているのが分かります。

なお、do 記法を使わずに書くと以下のようになります。

add :: String -> String -> Maybe Int 
add sa sb = 
    readInt sa >>= \a ->
    readInt sb >>= \b ->
    return $ a + b

また、Applicative スタイルでは以下のように書けます(おそらく今回のケースではこれが最もシンプルでしょう)。

add :: String -> String -> Maybe Int 
add sa sb = (+) <$> readInt sa <*> readInt sb

IO から読み取った文字列を対象にする

さて、今度は文字列をコンソールから入力するようにした addIO 関数を考えてみます。

文字列の読み込みは getLine :: IO String が使えるので、それに fmap readLine を適用する形で以下のように書きたいところですが、残念ながらコンパイルエラーになります。

addIO :: IO (Maybe Int)
addIO = do
    a <- fmap readInt getLine
    b <- fmap readInt getLine
    return $ a + b -- コンパイルエラー

IO を扱う必要があるので IO モナドを利用することになりますが、同時に Maybe モナドを利用できないためです。

addIO :: IO (Maybe Int) -- IOモナドを利用している
addIO = do
    a <- fmap readInt getLine -- a :: Maybe Int
    b <- fmap readInt getLine -- b :: Maybe Int
    return $ a + b -- `Maybe Int` 同士は足し合わせられない

Maybe モナドを利用した場合は abInt 型になるため問題ありませんが、IO モナドを利用しているため Maybe Int型になってしまうのが原因です。

愚直な対処方法

これに対処する方法としてはいくつかの方法が考えられます。

関数を用意する

最も愚直な方法は、Maybe Int 同士を足し合わせる関数を用意してしまうことです。

addIO :: IO (Maybe Int)
addIO = do
    ma <- fmap readInt getLine
    mb <- fmap readInt getLine
    return $ f ma mb
        where
            f :: Maybe Int -> Maybe Int -> Maybe Int
            f ma mb = do -- Maybe モナドを利用
                a <- ma
                b <- mb
                return (a + b)

新たに定義した関数 f は IO モナドの do 式から独立しているため、Maybe モナドを利用した処理を書くことが可能になっています。なんだか大げさではありますが、とりあえず動きます。

Applicative スタイルで処理する

関数を用意する代わりに return の箇所で、Applicative スタイルを利用することもできます。

addIO :: IO (Maybe Int)
addIO = do
    ma <- fmap readInt getLine
    mb <- fmap readInt getLine
    return $ (+) <$> ma <*> mb

これも関数を定義するやり方と考え方は同じで、do 式とは独立した return の部分で処理してしまおうという考え方です。

MaybeT モナド

ここで本題ですが、Maybe モナドと別のモナド(IOモナドとか)を合体させた新しいモナドを作ってしまおうという考え方があります。

それがいわゆる「モナド変換子」と呼ばれるもので、transformers パッケージに各種モナド変換子が用意されています。Stack を利用している場合には package.yaml に追記しておきましょう。

package.yaml
 dependencies:
 - base >= 4.7 && < 5
+- transformers

Maybe モナドと別のモナドを合体させることができる MaybeT モナドを利用すると以下のように書くことができます。

import Control.Monad.Trans.Maybe ( MaybeT(..) )

addIO :: MaybeT IO Int
addIO = do
    a <- MaybeT $ fmap readInt getLine -- a :: Int
    b <- MaybeT $ fmap readInt getLine -- b :: Int
    return $ a + b

IO 処理を伴いつつも、Maybe モナドの機能を同時に利用できているのが分かります。

解説

順に見ていくと、まず MaybeT IO というモナドが利用されています。

addIO :: MaybeT IO Int

これは MaybeTIO モナドを包み込んでいるようなイメージです。そして、モナドとして扱う値が Int なので MaybeT IO Int という型になっているわけですね。

次に MaybeTfmap readInt getLine :: IO (Maybe Int) を包んでいるのが分かります。

a <- MaybeT $ fmap readInt getLine -- a :: Int

MaybeTm (Maybe a) -> MaybeT m a と定義されており、ここでは IO (Maybe Int) から MaybeT IO Int に変換しているのが分かります。この do 式で扱っているのは Maybe IO Int 型なので、IO モナドをラップしているわけですね。

そして <- で取り出した値は Int になっているため、return (a + b) と通常の Maybe モナドと同じように計算できているのが分かります。

return $ a + b

do 記法を使わずに書くと

理解のために do 記法を使わずに書くと以下のようになります。

addIO :: MaybeT IO Int
addIO = 
    MaybeT (fmap readInt getLine) >>= \a -> 
    MaybeT (fmap readInt getLine) >>= \b -> 
    return $ a + b

MaybeTIO モナドを包み込みつつ、Maybe モナドと同等の振る舞いがされていることが分かりやすいのではないでしょうか。

lift 関数

なお、 IO (Maybe a) ではなく、通常の IO a 型を MaybeT モナドに持ち上げるために lift という関数が利用できます。

import Control.Monad.Trans.Class ( lift )

addIO :: MaybeT IO Int
addIO = do
    lift $ putStrLn "add was called." -- `IO ()`を`MaybeT IO ()`に持ち上げ
    a <- MaybeT $ fmap readInt getLine
    b <- MaybeT $ fmap readInt getLine
    return $ a + b

lift(MonadTrans t, Monad m) => m a -> t m a と定義されており、ここでは IO ()MaybeT IO () に変換することができています。

これも do 記法を利用せずに書くと、MaybeT に持ち上げて処理されているのが分かりやすいかと思います。

addIO :: MaybeT IO Int
addIO = 
    lift (putStrLn "add was called.") >>
    MaybeT (fmap readInt getLine) >>= \a -> 
    MaybeT (fmap readInt getLine) >>= \b -> 
    return $ a + b

addIO 関数を利用する

さて、このように定義できた addIO 関数ですが、型が MaybeT IO Int となっており、このままでは IO モナドから利用できません。

main :: IO ()
main = do
    result <- addIO -- コンパイルエラー
    print result

runMaybeT 関数を利用して、外側にある MaybeT を剥がして IO a に戻すことができます。

main :: IO ()
main = do
    result <- runMaybeT addIO -- result :: Maybe Int
    print result

runMaybeTMaybeT m a -> m (Maybe a) 型で定義されており、今回は MaybeT IO Int から IO (Maybe Int) に変換したというわけですね。

MaybeT モナドの実装

MaybeT モナドの実装 を確認すると以下のようになっています(簡略化しています)。

newtype MaybeT m a = MaybeT { runMaybeT :: m (Maybe a) }

instance (Monad m) => Monad (MaybeT m) where
    return = MaybeT . return . Just

    x >>= f = MaybeT $ do
        v <- runMaybeT x
        case v of
            Nothing -> return Nothing
            Just y  -> runMaybeT (f y)

分かりやすいようにコメントを追加すると以下のようになります(一部の式の型注釈もコメントに追加しています)。

instance (Monad m) => Monad (MaybeT m) where
    -- return :: a -> MaybeT m a
    return = MaybeT . return . Just

    -- (>>=) :: (MaybeT m a) -> (a -> MaybeT m b) -> MaybeT m b
    x >>= f = MaybeT $ do -- ④ 最後に`MaybeT`で包む
        -- runMaybeT x :: m (Maybe a)
        -- v           :: Maybe a
        v <- runMaybeT x  -- ① MaybeT を剥がして、中身の`Maybe a`を取り出し、
        case v of         -- ② 普段の Maybe モナドと同じように処理し、
            Nothing -> return Nothing
            -- f y             :: MaybeT m b
            -- runMaybeT (f y) :: m (Maybe b)
            Just y  -> runMaybeT (f y) -- ③ `f`を適用した結果を`runMaybeT`で再び剥がし、

このように見てみると、MaybeT で包んだり、runMaybeT で剥がしたりする処理が含まれているものの、中身の実装としては Maybe モナドの考え方と変わらないことが分かります。

まとめ

もっとも理解しやすいであろう Maybe モナドに対応するモナド変換子の1つである MaybeT について、利用するモチベーションや基本的な使い方を見てきました。

  • 1つの do 式では1つのモナドしか扱えない。(IO モナドと Maybe モナドを同時には扱えない)
  • その制限を突破するの方法の1つがモナド変換子と呼ばれるもの。
  • モナド変換子は、別のモナドを包み込んだモナドを作ってしまおうという考え方。
  • その1つの例が MaybeT で、IO を包み込んだ MaybeT IO といったモナドが作れる。
  • MaybeT は Maybe モナドと同じような挙動をする。
  • IO (Maybe a)MaybeT で包むことができ、IO alift で包むことができる。

私もまだ深く理解しているわけではありませんが、モナド変換子の学習の入り口として役立てば幸いです。

P.S.

最近は Swift プログラマのための Haskell 入門 という連載ブログ記事を書いてます。

Discussion