LoginSignup
32
34

More than 3 years have passed since last update.

Haskellの例外処理事情

Last updated at Posted at 2020-05-19

Haskellの例外処理事情

 Haskellを使うたびに例外について調べ直す癖がついているので、諸々をまとめておく。

TL;DR

  1. 部分関数を使うな。
  2. 失敗可能性はMaybeEitherIO? -> 迷ったらMonadThrowを使え。
  3. 予めエラー型を統一しないと例外処理クソだるいんだけど -> SomeExceptionを使え。
  4. ライブラリどれ使えばいいの? -> 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

失敗可能性はEitherMaybeIO

 失敗可能性をどのモナドで表すか問題。結論を言えば、MonadThrow型クラスを使ってモナドを一般化し、SomeException型を使って例外型を一般化するのがよい。
 ここで考えたい問題は以下の3点だ。

  1. 失敗可能性をEitherで表すと、Leftの型に困る。do文はLeftの型が食い違ったEitherモナドを混ぜて使うことはできない。
  2. かといって失敗可能性をMaybeで表すと、失敗の原因を伝えられない。
  3. IOを使えば、上二つの問題を同時に解決できる。しかし、ピュアな文脈が汚染されてしまう。

 これらの問題を解決するために、例外を扱うライブラリが作られている。基本的なアプローチは、失敗の送出と失敗のハンドリングをそれぞれ型クラスを使って抽象化することだ。さらに、存在量化された型を使えば、失敗の型を統一することもできる。

MonadThrow

 失敗可能性を表す型クラスがMonadThrowだ。IOMaybe, Eitherはもちろん、StateConttransformersに収録される様々なモナドの実装があり、これを使うとこれらモナドの失敗を統一的に表現できる。
 例えば、安全な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型クラスを実装した型ならば何でも入れることができる存在量化された型だ。throwStringStringExceptionというException型クラスを実装した型を投げる。
 2では例外はNothingとなる。例外の送出時に付加した情報は失われる。
 3では例外はIOモナドで処理されることになる。すなわち、プログラムはクラッシュするということだ。

便利なスニペット

 たまに欲しくなる、MaybeEitherMonadThrowに変換するやつ。maybe|||throwreturnで挟む覚えやすい形だ。
 |||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

 自作の例外を作るときは以下のようにする。これでMyExceptionSomeExceptionに変換できるようになる。
 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が投げるのはZeroDivisionExceptionReadExceptionで、そのうち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の例外周りでは困らないはず。
 より新しい情報があれば、そちらへのリンクをコメントに頂けると幸いです。

32
34
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
32
34