blog.ryota-ka.me

MaybeとIOを一緒に使いたくなったら

たまには初学者向けにライトな話題を.

対象読者#

  • すごいH本12章13章ぐらいまでは読んだ
  • do構文を使ってIOなどの処理が書ける
  • Haskellのプログラムはなんとなく書けるが,あまり綺麗に書けている気がしない

IOの中でMaybeを使う#

例として,以下のようなプログラムを考えてみよう.

  • 2つの整数a, bを標準入力から1行ずつ順に読み込む
  • abの和を標準出力に出力する
  • 与えられた入力が整数でなかった場合には,その時点でエラーメッセージを出力し,プログラムを終了する

これらの要件を満たすプログラムを素朴に実装するならば,以下のようになるだろう.

import System.Exit (die)
import Text.Read (readMaybe)

readInt :: String -> Maybe Int
readInt = readMaybe

main :: IO ()
main = do
    a <- readInt <$> getLine
    case a of
        Nothing -> die "not an integer"
        Just a' -> do
            b <- readInt <$> getLine
            case b of
                Nothing -> die "not an integer"
                Just b' -> print (a' + b')

さて,このプログラムを少し改変して,今度は3つの整数を受け取り,それらの和を出力するようにしたい.

import System.Exit (die)
import Text.Read (readMaybe)

readInt :: String -> Maybe Int
readInt = readMaybe

main :: IO ()
main = do
    a <- readInt <$> getLine
    case a of
        Nothing -> die "not an integer"
        Just a' -> do
            b <- readInt <$> getLine
            case b of
                Nothing -> die "not an integer"
                Just b' -> do
                    c <- readInt <$> getLine
                    case c of
                        Nothing -> die "not an integer"
                        Just c' -> print (a' + b' + c')

4整数の場合であれば以下のようになるだろう.

import System.Exit (die)
import Text.Read (readMaybe)

readInt :: String -> Maybe Int
readInt = readMaybe

main :: IO ()
main = do
    a <- readInt <$> getLine
    case a of
        Nothing -> die "not an integer"
        Just a' -> do
            b <- readInt <$> getLine
            case b of
                Nothing -> die "not an integer"
                Just b' -> do
                    c <- readInt <$> getLine
                    case c of
                        Nothing -> die "not an integer"
                        Just c' -> do
                            d <- readInt <$> getLine
                            case d of
                                Nothing -> die "not an integer"
                                Just d' -> print (a' + b' + c' + d')

延々とネストが深くなっていってしまうことがわかる.うまく抽象化できていない「臭い」がする.

MaybeIOの同居が問題なのではない#

今しがた直面した,ネストが延々と深くなるという問題は,何も「MaybeIOを同時に使いたいがために起こる」といった性質のものではない.例えば次のコードではMaybeのみを用いており,IOは一切登場しない.

safeDiv :: Int -> Int -> Maybe Int
safeDiv m n
  | n == 0    = Nothing
  | otherwise = Just (m `div` n)

example :: Maybe Int
example = case safeDiv 42 2 of
    Nothing -> Nothing
    Just a -> case safeDiv a 0 of
        Nothing -> Nothing
        Just b -> case safeDiv b 3 of
            Nothing -> Nothing
            Just c -> safeDiv c 7

先程見た例と同様,ネストが延々と深くなる構造を抱えており,抽象化に失敗している様子が見て取れる.

とはいえ,実際にこのようなコードが書かれることはまずあり得ない.なぜならばMaybeMonad型クラスのインスタンスであるので,>>=が利用でき(すなわちMaybeのための>>=の実装が与えられていて),以下のように書き直すことができるからである.

example' :: Maybe Int
example' =
    safeDiv 42 2 >>= (\a ->
        safeDiv a 0 >>= (\b ->
            safeDiv b 3 >>= (\c ->
                safeDiv c 7
            )
        )
    )

更にdo構文を用いて書き直せば以下のようになる.

example'' :: Maybe Int
example'' = do
    a <- safeDiv 42 2
    b <- safeDiv a 0
    c <- safeDiv b 3
    safeDiv c 7

立ちどころにネストが消えてしまった.

ここで改めて確認しておきたいのは,適切な抽象化を行わなければ,Maybeだけを用いた場合でもネストが深くなり続ける問題は生じるということだ.言い換えると,冒頭の例が醜いコードになってしまったのは,何も**MaybeIOを同時に使おうとしたことそれ自体が直接の原因というわけではない**.

では,ここで言う「適切な抽象化」とは何だろうか.答えは「Monad型クラスのインスタンスになっていて,>>=が利用できること」である.

Monad型クラスによる抽象化#

Monad型クラスは,以下のように定義される*1型クラスである.

class Applicative m => Monad m where
    (>>=) :: m a -> (a -> m b) -> m b

「ある型mをモナドとして振る舞わせたい」と思ったときには,このm a -> (a -> m b) -> m bという型を持つ演算子>>=をその型に合った形で実装してやれば,それが叶うのだった.

「その型に合った形で実装してやれば」と書いたように,Monadのインスタンスになっている型によって>>=の具体的な実装は異なっている.Maybe, Either, [], IOなどなど,Monadのインスタンスになっている型はいろいろあれど,(そもそも型が違うので当然だが)それぞれは異なる>>=の実装を持っている.このように,異なる型について同じ関数や演算子を用いることができる(ように見えるが実際には型によって違う実装が与えられている)多相を**アドホック多相(ad-hoc polymorphism)**と呼ぶ.逆に言えば,単に>>=と書いた場合には,どの型に対する実装を指しているのかがわからない.そこで,特に先程見たMaybeの実装を指していることを強調したい場合には(>>=) @Maybeと書くことにする*2

Maybeは以下のように>>=を実装することで,Monad型クラスのインスタンスになっている.

-- GHC.Base より一部抜粋・可読性のため改変
instance Monad Maybe where
    Just x  >>= f = f x
    Nothing >>= _ = Nothing

左辺の値がJustであるかNothingであるかによって条件分岐をしている.左辺がJust xの場合には,中身のxの値を取り出して,「続きの計算」であるfに渡す.左辺がNothingの場合には,右辺の関数を使わず,その場で計算を中断する,という風に読める.この左辺の条件分岐は,Maybeを返すような計算を直列に繋げる場合に,毎回case ~ of ...を用いたパターンマッチで場合分けを行う部分を抽象化してくれる.先程のsafeDivの例では,(>>=) @Maybeを導入した途端にJustNothingの場合分けが姿を消した.これは何を隠そう,(>>=) @Maybeの実装の中にその場合分けが記述されているからなのであった.このように,繰り返し登場する常套句を一箇所にまとめて定義し再利用することは,日常のプログラミングにおいてごく当たり前のことだろう.

MaybeIOの両方の計算効果を持った型#

さて,元々のモチベーションを思い出すと,冒頭のMaybeIOが入り混じったコードをなんとか綺麗にしたいのだった.前節では,(>>=) @Maybe,すなわちMaybeに対する>>=の実装が,単なるパターンマッチによるJustNothingの場合分けであることを見た.ここから得られる示唆は以下の様なものである.すなわち,適当に新しい型mを作ってやって,その型をMonad型クラスのインスタンスにする.つまり(>>=) @mの実装を書く.その際に,一連の計算を途中で失敗させることができるMaybeと,副作用を引き起こすことができるIOの,両方の計算効果を持たせるような抽象化を施してやればいいのではないだろうか.つまり,x >>= fの実装として,

  • IO アクションxを実行する
    • 例: 標準入力から1行読み込み,整数であればJust nを,そうでなければNothingを返すような IO アクション
  • 実行結果はMaybe a型を持つので,その値が
    • Nothingのときはpure Nothingを返し,その場で計算を終了する
    • Just yのときは,続きの計算であるfyを渡して計算を続ける
      • f yはIOアクションでこれを実行した結果はMaybe b型を持つので…… (以下同様)

というようなものを与えてやる.こうした実装を与えてやった上で>>=を使えば,x >>= f >>= g >>= h >>= ...といった形で,好きなだけ計算を続けることができるし,f, g, hがそれぞれ計算結果としてNothingを返した場合*3には,そこで計算を打ち止めにする,といったことが可能になるはずである.

では実際に,次のようなデータ型を定義してみよう.

newtype MaybeIO a = MaybeIO { runMaybeIO :: IO (Maybe a) }

ここでnewtypeについての注意をしておく.既に馴染み深い読者は読み飛ばしてしまって構わない.

newtypeとは,あるデータ型に対して,元の型と同型(isomorphic であるような新しい型を定義するための機能である.実際,

-- コンストラクタ (型ではない)
MaybeIO :: IO (Maybe a) -> MaybeIO a

-- フィールド名
runMaybeIO :: MaybeIO a -> IO (Maybe a)

であり,

MaybeIO . runMaybeIO == id
runMaybeIO . MaybeIO == id

であるので,MaybeIO aは,実質的にIO (Maybe a)と同一視してしまって構わない.では,なぜ元の型(IO (Maybe a))と同一視できるはずの新しい型(MaybeIO a)を定義するのだろうか.type MaybeIO a = IO (Maybe a)でシノニムを与えるだけで十分ではないのか.実は,このあとこの型をFunctor, Applicative, Monadクラスのインスタンスにするのだが,Haskell 2010ではtypeキーワードで定義した型シノニムに対して,インスタンスの宣言ができないのである*4newtypeで定義された型は,型検査時には元の型とは別の型として扱われるので,ある型クラスのインスタンスにしたい場合に便利なことが多い.実行時には元の型と同一のものとして扱われるので,オーヴァーヘッドは発生せず,抽象化によるペナルティを受けない.

newtypeを用いたコードベースは,辻褄合わせのためにMaybeIOやらrunMaybeIOやらがたくさん登場して,初学者にとってはあまり読みやすいものではないかもしれない.しかしながら,先程も説明したとおり,MaybeIO aIO (Maybe a)は同一視できるので,MaybeIOrunMaybeIOは特別何かをしているわけではなく,ただ型を合わせるために辻褄合わせをしているだけである.コードを読んでいて混乱した場合には,これらがどのような型を持つかを冷静に見つめ直した上で,どの項がどのような型を持つかの把握に努めてほしい.

  • MaybeIO :: IO (Maybe a) -> MaybeIO a
    • IO (Maybe a)を型クラスのインスタンスにするためのラッピング
  • runMaybeIO :: MaybeIO a -> IO (Maybe a)
    • 中身を取り出せばIOの世界で使える

では,実際にMaybeIOMonad型クラスのインスタンスにしてみよう*5

instance Functor MaybeIO where
    fmap :: (a -> b) -> MaybeIO a -> MaybeIO b
    -- doブロック全体はIO (Maybe b)型を持ち
    -- これをMaybeIOコンストラクタに渡すことで
    -- 右辺全体がMaybeIO b型になることに注意
    fmap f action = MaybeIO $ do -- IOのdo
        -- actionはMaybeIO a型なので
        -- runMaybeIOでIO (Maybe a)にすればIOのdoの中で使える
        x <- runMaybeIO action -- IO (Maybe a)から<-で取り出したxはMaybe a型を持つ
        case x of
            Nothing -> pure Nothing
            Just x' -> pure (Just (f x'))

instance Applicative MaybeIO where
    pure :: a -> MaybeIO a
    pure x = MaybeIO (pure (Just x)) -- 右辺のpureはIOのpure

    (<*>) :: MaybeIO (a -> b) -> MaybeIO a -> MaybeIO b
    lhs <*> rhs = MaybeIO $ do -- やはりIOのdo
        mf <- runMaybeIO lhs -- IOに変換してから<-で取り出す
        case mf of
            Nothing -> pure Nothing
            Just f -> do
                mx <- runMaybeIO rhs -- IOに変換してから<-で取り出す
                case mx of
                    Nothing -> pure Nothing
                    Just x -> pure (Just (f x))

instance Monad MaybeIO where
    (>>=) :: MaybeIO a -> (a -> MaybeIO b) -> MaybeIO b
    lhs >>= rhs = MaybeIO $ do -- やはりIOのdo
        x <- runMaybeIO lhs    -- IOアクションの実行
        case x of              -- 結果による場合分け
            Nothing -> pure Nothing
            Just x' -> runMaybeIO (rhs x')

「IOアクションの実行」と「Just/Nothingに対するパターンマッチ」の両方を行うコードをそのまま書き下した.このような抽象化を用いれば,冒頭の例は以下のように書き換えられる.

main :: IO ()
main = do -- IOのdo
    -- doブロック全体はMaybeIO Int型を持ち
    -- runMaybeIOでIO (Maybe Int)に変換していることに注意
    msum <- runMaybeIO $ do -- MaybeIOのdo
        a <- MaybeIO (readInt <$> getLine)
        b <- MaybeIO (readInt <$> getLine)
        pure (a + b)
    case msum of -- msumはMaybe Int型をもつ
        Nothing -> die "not an integer"
        Just sum -> print sum

読み込む行数を2行から4行に変更してもこの通り.

main :: IO ()
main = do
    msum <- runMaybeIO $ do
        a <- MaybeIO (readInt <$> getLine)
        b <- MaybeIO (readInt <$> getLine)
        c <- MaybeIO (readInt <$> getLine)
        d <- MaybeIO (readInt <$> getLine)
        pure (a + b + c + d)
    case msum of
        Nothing -> die "not an integer"
        Just sum -> print sum

Maybeと他のモナドを一緒に使いたくなったら#

自分でMaybeHoge型を定義して,Functor, Applicative, Monadのインスタンス宣言を書いてもらって……ではあまりに面倒なので,よりよい抽象化の方法を考えたい.

先程MaybeIOFunctor, Applicative, Monadのインスタンスにした際のコードをもう一度見返してみよう.

instance Functor MaybeIO where
    fmap f action = MaybeIO $ do
        x <- runMaybeIO action
        case x of
            Nothing -> pure Nothing
            Just x' -> pure (Just (f x'))

instance Applicative MaybeIO where
    pure :: a -> MaybeIO a
    pure x = MaybeIO (pure (Just x))

    lhs <*> rhs = MaybeIO $ do
        mf <- runMaybeIO lhs
        case mf of
            Nothing -> pure Nothing
            Just f -> do
                mx <- runMaybeIO rhs
                case mx of
                    Nothing -> pure Nothing
                    Just x -> pure (Just (f x))

instance Monad MaybeIO where
    lhs >>= rhs = MaybeIO $ do
        x <- runMaybeIO lhs
        case x of
            Nothing -> pure Nothing
            Just x' -> runMaybeIO (rhs x')

よくよく見てみると,このコード中にはgetLine :: IO StringunsafePerformIO :: IO a -> aなど,IOに関わるものはまったく登場しない.このコードが語るのは,何かしらのモナドmの中にMaybe aが入っているということだけであり,IOという型自体にはまったく依存していないことがわかる.MaybeIOという名前をつけてみたものの,実はIOだけではなく,好きなモナドmMaybeを組み合わせることができるようになっていたようだ.

さて,MaybeIOは実はIO以外にも対応していたということがわかったので,もう少し良い名前を付けてあげたい.これは巷ではMaybeTという名前で呼ばれている.このように,あるモナドの中に別のモナドを入れて,両方の(あるいは3つ以上の)計算効果を同時に扱うパターンはモナド変換子monad transformer)と呼ばれている.MaybeTTはTransformerに由来する.

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

参考までに,m = IOとした場合には,

MaybeT IO a = MaybeT { runMaybeT :: IO (Maybe a) }

となる.

newtype MaybeIO = MaybeIO { runMaybeIO :: IO (Maybe a) }

だったことを思い出すと,IOが任意のmに抽象化されただけであることがわかる.

このように定義されたMaybeT型は,transformersというライブラリによって提供されているIOだけでなく,任意のMonad m => mについてMaybeT mMonad型クラスのインスタンスになっていることにより,ライブラリのユーザが好きなモナドを自由に組み合わせて使える柔軟性を獲得している.

transformersライブラリを利用すれば,冒頭のコードは最終的に以下のように書くことができる.

#!/usr/bin/env stack
-- stack runhaskell --resolver lts-11.10 --package transformers

import Control.Monad.Trans.Maybe (MaybeT (MaybeT, runMaybeT))
import System.Exit (die)
import Text.Read (readMaybe)

readMaybeInt :: IO (Maybe Int)
readMaybeInt = readMaybe <$> getLine

main :: IO ()
main = do
    msum <- runMaybeT $ do
        a <- MaybeT readMaybeInt
        b <- MaybeT readMaybeInt
        c <- MaybeT readMaybeInt
        d <- MaybeT readMaybeInt
        pure (a + b + c + d)
    case msum of
        Nothing -> die "not an integer"
        Just sum -> print sum

さて,このように構成したモナド変換子(monad transformer)自体を抽象化するものとして,MonadTrans型クラスが提供されている.このメソッドたるlift関数を抑えておけば,モナド変換子を習得したと言ってよいだろう.

終わりに#

MaybeIOを一緒に使いたくなったら,それはあなたがモナド変換子を訪ねるきっかけかもしれない.

脚注#

*1: GHC.Base より一部抜粋の上,可読性のため改変した.

*2: この記法は TypeApplications GHC 言語拡張の下で有効である.

*3: 厳密に言えば,これらの関数が返す IO アクションの実行結果が Nothing であるとき.

*4: TypeSynonymInstances 拡張を有効にすれば可能.

*5: 可読性のために,メソッドの宣言時に型シグネチャを記述しているが,これは InstanceSigs GHC 言語拡張を有効にすることで可能になる.