Haskellの例外処理事情
Haskellを使うたびに例外について調べ直す癖がついているので、諸々をまとめておく。
TL;DR
- 部分関数を使うな。
- 失敗可能性は
Maybe
?Either
?IO
? -> 迷ったらMonadThrow
を使え。 - 予めエラー型を統一しないと例外処理クソだるいんだけど ->
SomeException
を使え。 - ライブラリどれ使えばいいの? ->
safe-exceptions
を使え。
部分関数を使うな
死の化身。部分関数自体を避けるしか方法はない。用意された部分関数を使う場合は必ずラップすること。
パターンマッチの失敗やundefined
, error
等のピュアな文脈での例外は、IO
モナドの中でのみキャッチすることができる。しかし、ピュアな文脈で解決できる問題を神モナドで解決するのはあまり嬉しくない。ピュアなものはピュアなものに、神のものは神に返すべきである。
もし失敗をどの型で表すのか迷ったら、続くセクションにあるように、MonadThrow
を使うとよい。
-- 'head'は部分関数なので、ラップして使おう。
safehead :: [a] -> Maybe a
safehead a = head a <$ guard (not $ null a)
-- 'MonadThrow'を使う
safehead' :: MonadThrow m => [a] -> m a
safehead' = maybe (throwString "could not take a head") return . safehead
失敗可能性はEither
? Maybe
? IO
?
失敗可能性をどのモナドで表すか問題。結論を言えば、MonadThrow
型クラスを使ってモナドを一般化し、SomeException
型を使って例外型を一般化するのがよい。
ここで考えたい問題は以下の3点だ。
- 失敗可能性を
Either
で表すと、Left
の型に困る。do
文はLeft
の型が食い違ったEither
モナドを混ぜて使うことはできない。 - かといって失敗可能性を
Maybe
で表すと、失敗の原因を伝えられない。 -
IO
を使えば、上二つの問題を同時に解決できる。しかし、ピュアな文脈が汚染されてしまう。
これらの問題を解決するために、例外を扱うライブラリが作られている。基本的なアプローチは、失敗の送出と失敗のハンドリングをそれぞれ型クラスを使って抽象化することだ。さらに、存在量化された型を使えば、失敗の型を統一することもできる。
MonadThrow
失敗可能性を表す型クラスがMonadThrow
だ。IO
やMaybe
, Either
はもちろん、State
やCont
等transformers
に収録される様々なモナドの実装があり、これを使うとこれらモナドの失敗を統一的に表現できる。
例えば、安全なdiv
を下のように実装する。このsafediv
は、返ってくる型を受け取り手が選択することができる。
import Control.Monad (when)
import Control.Exception.Safe (MonadThrow, SomeException, throwString)
safediv :: MonadThrow m => Int -> Int -> m Int
safediv m n = do
when (n == 0) $
throwString "divided by 0"
return $ m `div` n
main :: IO ()
main = do
print $ (2 `safediv` 0 :: Either SomeException Int) -- 1
print $ (2 `safediv` 0 :: Maybe Int) -- 2
2 `safediv` 0 >>= print -- 3
1は例外をEither
として受け取っている。SomeException
という型は、Exception
型クラスを実装した型ならば何でも入れることができる存在量化された型だ。throwString
がStringException
というException
型クラスを実装した型を投げる。
2では例外はNothing
となる。例外の送出時に付加した情報は失われる。
3では例外はIO
モナドで処理されることになる。すなわち、プログラムはクラッシュするということだ。
便利なスニペット
たまに欲しくなる、Maybe
やEither
をMonadThrow
に変換するやつ。maybe
や|||
をthrow
とreturn
で挟む覚えやすい形だ。
|||
はControl.Arrow
に含まれている。余談だが、Control.Arrow
にはややトリッキーだがEither
を便利に使う関数がたくさん含まれている。
maybeToMonadThrow :: (MonadThrow m, Exception e) => e -> Maybe a -> m a
maybeToMonadThrow e = throw e `maybe` return
eitherToMonadThrow :: (MonadThrow m, Exception e) => Either e a -> m a
eitherToMonadThrow = throw ||| return
SomeException
失敗の原因を外部に伝えようとするとその失敗を表す型の扱いに困る問題を解決するのが、SomeException
型だ。これは存在量化された型で、Exception
型クラスを実装している型なら何でも突っ込むことができる型である。
SomeException
はおおよそ以下のような定義となっている。
{-# LANGUAGE ExistentialQuantification #-}
data SomeException = forall e. Exception e => SomeException e
instance Exception SomeException
自作の例外を作るときは以下のようにする。これでMyException
をSomeException
に変換できるようになる。
throw
を使えば、自作の例外をSomeException
に変換したうえで文脈から脱出できる。
data MyException = ZeroDivisionException deriving (Show)
instance Exception MyException
safediv :: MonadThrow m => Int -> Int -> m Int
safediv m n = m `div` n <$ (when (n == 0) $ throw ZeroDivisionException)
外部ライブラリの関数が独自の例外型を定義していても、統一的に扱うことができる。
-- 外部ライブラリの提供する例外型と、それを投げる関数
data ExternalException = ReadException deriving (Show)
instance Exception ExternalException
saferead :: MonadThrow m => String -> m Int
saferead = maybe (throw ReadException) return . readMaybe
-- 外部ライブラリの例外と自作ライブラリの例外を混ぜて扱う
readAndDivide :: (MonadCatch m) => String -> String -> m Int
readAndDivide m n = do
m' <- saferead m
n' <- saferead n
m' `safediv` n'
MonadCatch
MonadThrow
は例外を投げる部分を統一的に書けるようにした。しかし、投げられた例外をハンドルする部分はどうだろうか。
readAndDivide
が投げるのはZeroDivisionException
とReadException
で、そのうちZeroDivisionException
を処理すると、以下のような処理になる。
sample :: String -> String -> Either SomeException String
sample m n = case (readAndDivide m n :: Either SomeException Int) of
Right r -> Right $ show r
Left e -> case fromException e of
Just ZeroDivisionException -> Right $ show ZeroDivisionException
Nothing -> Left e
これを見れば、MonadThrow
同様、例外をキャッチする箇所も抽象化しようと思うのは自然な発想だろう。
MonadCatch
とそのメソッドcatch
を使えば、これを下にように書ける。
sample' :: MonadCatch m => String -> String -> m String
sample' m n = do
show <$> readAndDivide m n
`catch` \(ZeroDivisionException) ->
return $ show ZeroDivisionException
複数の例外をハンドルしたい場合は、catch
をネストすればよい。
sample'' :: MonadCatch m => String -> String -> m String
sample'' m n = (do
show <$> readAndDivide m n
`catch` \ZeroDivisionException
-> return $ show ZeroDivisionException)
`catch` \ReadException
-> return $ show ReadException
あるいは、catches
という関数を使う。
sample'' :: MonadCatch m => String -> String -> m String
sample'' m n = do
show <$> readAndDivide m n
`catches`
[ Handler $ \ZeroDivisionException -> return $ show ZeroDivisionException
, Handler $ \ReadException -> return $ show ReadException
]
ライブラリはどれ使えばいいの?
safe-exceptions
を使うのが良い(らしい)。
上のような機能が使える例外ライブラリはいくつかあるが、safe-exceptions
は必要に応じてbase
, mtl
, transformers
, exceptoins
あたりの主要なライブラリの内容を再エクスポートしつつ、非同期例外も安全に扱えるパッケージになっている。
必要なものは再エクスポートされているため、例外を扱う目的ではsafe-exceptions
だけインポートしていればよい。
まとめ
Haskellの例外処理事情についてざっくりとまとめた。大体この辺が思い出せればHaskellの例外周りでは困らないはず。
より新しい情報があれば、そちらへのリンクをコメントに頂けると幸いです。