Haskellの例外周りの話
RIOのベストプラクティスから.これはYesodの開発の知見から作られている.FP Completeの元記事がある. IOの場合
結論
safe-exceptionパッケージを使おう!
GHCの方針
非同期例外をサポート
IO内のすべてのコードはExceptionのインスタンスであるすべての型を持つ例外を持てる
RIOの方針
IO内の任意のコードは実行時例外を投げられる
任意のスレッドは非同期例外でいつでも殺すことができる
アンチパターン
ExceptT IOアンチパターン
code: exception1.hs
myFunction :: String -> ExceptT MyException IO(Int)
composableでない
MyExceptionを使っているため,HisExceptionなどと合成させようとするとめんどい.
型が意味するところが間違っている
この型が意味するのは,MyException例外のみが投げられることだが,実際IOはあらゆる例外が投げらうる.
同期例外が投げられないとしても,ほぼすべての非同期例外が投げられる.
例外の可能性を制限していない
myFunctionはthrowEかliftIO . throwIOのどちらも許す.
このようなExceptTなどでラップしたIO-based transformer stack(IOを基底としたモナド変換子を適用したモナドのこと)をつかう例外処理は大体間違っている.
他の問題
そもそも一般に公開するAPIで使用されているような具体的なtransformer stackを使用することはほとんどの場合で間違っている.必要に応じてmtlの型クラスをつかって,型クラスの要件に従って関数を表現するのが普通である.
同様のパターン
code: exception2.hs
myFunction :: String -> ExceptT Text IO Int
これは,TextがMyExceptionなどの具体的なエラー型に変換されることを期待した書き方であるが,Text自体が実際のデータ型の合成の問題を回避するために有用なので,最後まで残ってしまうことが多い.しかし,この方法は,構造化されていないTextとして,有用なエラーデータ型を表現することにつながるためよろしくない.
解決策
より多くの関数がEitherを返し,珍しいエラーの場合に例外を投げるようにする
ExceptT IOからEitherを返すとただ一つの関数の中のエラー元が3つ存在することに注意すること.
なお,ExceptTを非IO-baseのモナド(例えば純粋なコード)と組み合わせることは非常に良いパターン
全部マスクしちゃうぞアンチパターン
非同期例外の処理はどこでも難しいので,すべてマスクしてしまう
非同期例外は厄介だが,システムを正しく動かすために必要不可欠なのでこういうのはやめよう!
非同期例外使ったライブラリがハングしたりする
解決策
可能な限りbracketパターンを用いる
safe-exceptionパッケージを使用する
例外を投げる前にSyncExceptionWrapperかAsyncExceptionWrapperにラップしてから投げるので,同期例外か非同期例外かが型でわかる
catchは非同期例外を捕捉しない
非同期例外も捕捉したいときはcatchDeepで捕捉できる
本当に複雑な制御フローとリソースの非線形なスコープがある場合はresourcetパッケージを使用する
グッドパターン
MonadThrow
code: exception3.hs
foo <- lookup "foo" m
bar <- lookup "bar" m
baz <- lookup "baz" m
f foo bar baz
この関数がNothingを返す場合,理由がわからない.以下の理由が考えられる.
"foo"はMapになかった
"bar"はMapになかった
"baz"はMapになかった
fがNothingを返した
問題はこの関数がMaybeを返すため,多くの情報を捨ててしまっていることだ.
代わりに考えられる次のような関数もあまり良いとは言えない.
code: exception4.hs
lookup :: Eq k => k -> (k, v) -> Either (KeyNotFound k) v f :: SomeVal -> SomeVal -> SomeVal -> Either F'sExceptionType F'sResult
この関数の問題は型が具体的すぎることである.また,大体の場合,なぜlookupが失敗したかよりも,この失敗にどう対処するかのほうが問題となる.こういう場合はMaybeのほうが良い.
これらの解決策としてexceptionパッケージのMonadThrow型クラスである.
code: exception5.hs
lookup :: (MonadThrow m, Eq k) => k -> (k, v) -> m v f :: MonadThrow m => SomeVal -> SomeVal -> SomeVal -> m F'sResult
Eitherに比べ,これはいくつかの情報,つまり投げられうる例外の型の情報が失われている.しかしながら,この方法は合成しやすく,Maybeと同様の有用なMonadThrowのインスタンス(IOなど)を扱えるようになっている.
MonadThrowはトレードオフになっているが,よく考えられたトレードオフで,通常はこれを使うのが正しい.また,Haskellの実行時例外システムともよく似ているが,例外システムは投げられうる例外の種類を捕捉することはない.
変換子
次の型シグネチャは具体的すぎる.
code: exception6.hs
foo :: Int -> IO String
これは大体liftIOを用いて一般化できる.
code: exception7.hs
foo :: MonadIO m => Int -> m String
これにより,この関数はIO上にあるすべての変換子で簡単に動作する.これはliftIOを適用するのが簡単でなければならないが,そんなに厳しい制限ではない.しかし,次の関数を考えてみる.
code: exception8.hs
bar :: FilePath -> (Handle -> IO a) -> IO a
IO上の変換子内にある関数がほしいとき,これを動作させるのは難しい.lifted-baseパッケージを使えば行うことができるが,簡単ではない.代わりに,safe-exceptionパッケージの観点からこの関数に以下のような一般的な型シグネチャを与えるのがベストだ.
code: exception9.hs
bar :: (MonadIO m, MonadMask m) => FilePath -> (Handle -> m a) -> m a
mask関数を使えば非同期例外を保留できるため,変換子内部の関数を確実に実行できる.これは例外処理にのみ適用されるのではなく,スレッドをフォークするなどの処理にも適用される. このような場合に考えられるもう1つの方法は,resourcetパッケージからAcquire型を使用することだ.
カスタム例外型
次はバッドプラクティスである.
code: exception10.hs
foo = do
if x then return y else error "something bad happened"
問題は任意の,文字列ベースのエラーメッセージの使用である.これだと,この例外を高レベルのコールスタックで直接処理することができない.代わりに,典型的なオーバーヘッドを伴うが,カスタム例外型を使うのが良い.
code: exception11.hs
data SomethingBad = SomethingBad
deriving Typeable
instance Show SomethingBad where
show SomethingBad = "something bad happened"
instance Exception SomethingBad
foo = do
if x then return y else throwM SomethingBad
これにより,より高レベルでSomethingBad例外型を簡単に補足できる.更に,throwMはerrorよりも優れた順序保証を提供してくれる.つまりは,例外が投げられる前に評価される必要のある純粋な値の中に例外を作る.
一つの大きな問題として,このようなShowの使い方の是非がある.(ことの是非は置いといて)実際的な問題として,このような使い方をせざるを得ない.Exception型クラスのdisplayException関数がこの問題の解決になる日がくるかもしれない.
GHCの提供する例外のデザインが素晴らしい理由
なぜIOが例外を投げるんだ,型にちゃんと明示しろ!という派閥に対する答え
何らかの理由で失敗できないようなIOアクションは事実上0
すべてのIOアクションがIO (Either UniqueExceptionType a)場合,プログラミングモデルは非常に面倒
aが()の場合,例外が発生したかどうかを確認するために戻り値の型をチェックするのを忘れることは非常にありそう
もし,代わりにすべてのIOアクションがIO (Either SomeException a)返した場合,少なくとも異なる例外型を扱う必要はなく,ErrorTを使用してコードを簡単にすることができるが......
多くの人間は現実の問題を無視しているだけである.つまりは,このIOの契約と呼ぶべきものは,「すべてのIOアクションは失敗しうる」ということを暗に示している.それが意味することは唯一つである.組み込みの実行時例外は型にその事実を隠しているが,我々はそれを認識する必要がある.実行時例外は,どこであれ,ErrorTよりはるかに効率が良い.
具体的な例
readLineを実装する.入力Stringを解析するだけでなく,どの値が解析されなかったか(入力String)と,それをどのように解析しようとしたのかを示す意義ある例外がほしい.これをreadM関数として以下のように実装する.
code: exception12.hs
-- stack --resolver lts-7.8 runghc --package safe-exceptions
{-# OPTIONS_GHC -Wall -Werror #-} import Control.Exception.Safe (Exception, MonadThrow, SomeException, throwM)
import Data.Typeable (TypeRep, Typeable, typeRep)
import Text.Read (readMaybe)
data ReadException = ReadException String TypeRep
deriving (Typeable)
instance Show ReadException where
show (ReadException s typ) = concat
[ "Unable to parse as "
, show typ
, ": "
, show s
]
instance Exception ReadException
readM :: (MonadThrow m, Read a, Typeable a) => String -> m a
readM s = res
where
res =
case readMaybe s of
Just x -> return x
Nothing -> throwM $ ReadException s (typeRep res)
main :: IO ()
main = do
print (readM "hello" :: Either SomeException Int)
print (readM "5" :: Either SomeException Int)
print (readM "5" :: Either SomeException Bool)
-- Also works in plain IO
res1 <- readM "6"
print (res1 :: Int)
res2 <- readM "not an int"
print (res2 :: Int) -- will never get called
これは、複数のモナドに一般化可能であり,有用な例外を持つという最初の基準を満たしている.stdinから読み込むreadLine関数を作成すると,本質的に異なる2つのの型シグネチャが考えられる.
readLine1 :: (MonadIO m, MonadThrow n, Read a, Typeable a) => m (n a) :失敗のケースが割と一般的であり,故にそれをIOの副作用を処理しているモナドと混合したくない場合
readLine2 :: (MonadIO m, MonadThrow m, Read a, Typeable a) => m a:対照的に,2つの異なるモナド(IO副作用と失敗)を1つのレイヤに統合することができる.これは暗黙のうちに,この失敗は通常対処したくないケースなので,ユーザはtryAnyのように明示的に失敗を抽出すべきということを言っている.実際には,liftIOを使って組み合わせることができるのでMonadIOとMonadThrowの両方を持つ意味はない. よって,型シグネチャはreadLine2 :: (MonadIO m, Read a, Typeable a) => m aになる.
実際にどちらを選択するのかは,好みで良い. 前者は失敗をはっきりと示しているが,一般的に, IO上のExceptTのように、すべての失敗が内側の値によって捕捉されるという誤った印象を与えうることに注意したい.
code: exception13.hs
-- stack --resolver lts-7.8 runghc --package safe-exceptions
{-# OPTIONS_GHC -Wall -Werror #-} import Control.Exception.Safe (Exception, MonadThrow, SomeException, throwM)
import Control.Monad (join)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Typeable (TypeRep, Typeable, typeRep)
import Text.Read (readMaybe)
data ReadException = ReadException String TypeRep
deriving (Typeable)
instance Show ReadException where
show (ReadException s typ) = concat
[ "Unable to parse as "
, show typ
, ": "
, show s
]
instance Exception ReadException
readM :: (MonadThrow m, Read a, Typeable a) => String -> m a
readM s = res
where
res =
case readMaybe s of
Just x -> return x
Nothing -> throwM $ ReadException s (typeRep res)
readLine1 :: (MonadIO m, MonadThrow n, Read a, Typeable a) => m (n a)
readLine1 = fmap readM (liftIO getLine)
-- Without the usage of liftIO here, we'd need both MonadIO and
-- MonadThrow constraints.
readLine2 :: (MonadIO m, Read a, Typeable a) => m a
readLine2 = liftIO (join readLine1)
main :: IO ()
main = do
putStrLn "Enter an Int (non-runtime exception)"
res1 <- readLine1
print (res1 :: Either SomeException Int)
putStrLn "Enter an Int (runtime exception)"
res2 <- readLine2
print (res2 :: Int)
continuation-base monadの場合
conduit/pipe/enumratorみたいなので例外使いたい,bracketねぇのか?って話
結論
resourcetパッケージを使おう!
enumeratorのbracket
enumeratorパッケージは,ファイルの内容を読み込むenumFileを提供するが,iterFileはデータを書き戻すことはない.bracketを使えば,(その関数が安全であるかを確認するためのデバッグログを含んだ)そのような関数を書くことは実際に簡単である.
code: exception14.hs
iterFile :: (MonadCatchIO m, MonadIO m, Functor m)
=> FilePath -> Iteratee ByteString m ()
iterFile fp = bracket
(liftIO $ do
putStrLn $ "opening file for writing: " ++ fp
IO.openFile fp IO.WriteMode)
(\h -> liftIO $ do
putStrLn $ "closing file for writing: " ++ fp
IO.hClose h)
iterHandle
acquire引数にファイルハンドルを開き,release引数でそのハンドルを閉じてから,内部引数にハンドルを使用します.実際にこの関数を使用してみよう.例外が投げられる場合と投げられない場合だ.
code: exception15.hs
main :: IO ()
main = do
writeFile "exists.txt" "this file exists"
run (enumFile "exists.txt" $$ iterFile "output1.txt") >>= print
run (enumFile "does-not-exist.txt" $$ iterFile "output2.txt") >>= print
このコードを実行してみると次のような出力が出る.
code: exception16.hs
opening file for writing: output1.txt
closing file for writing: output1.txt
Right ()
opening file for writing: output2.txt
Left does-not-exist.txt: openBinaryFile: does not exist (No such file or directory)
ここで,output2.txtのハンドルは決して閉じられないということに注意しよう.これは,継続が呼び出されるであろうという保証がまったくないための,継続ベースモナド(continuation based monad)の動作につきものの問題である.また,例えば,その継続が一度のみしか呼び出されないということを知ることも不可能だ.ContTのようなものを使えば,継続は複数回実行することができる.そのような場合,困ったことにクリーンアップ動作は複数回行われることになる.
この問題に対して,exceptionパッケージは正しい方法で処理を行う.exceptionパッケージには,例外を捕獲するためのMonadCatch(継続ベースモナドが例外を投げることを考慮している)と,bracket/finallyセマンティクスについての保証を与えるMonadMask(継続ベースモナドは例外を投げることはできない)の2つの型クラスが存在する.もう一つの有効なアプローチとしてmonad-controlがある.これは無効なインスタンスを書くことができないようにする.
継続ベースモナドを使い,例外による安全なリソース管理が必要な場合は,resourcetを使うのが良い.resourcetは,継続ベースのコードの領域外で例外安全を持ち上げ,変更可能な変数を介してファイナライザ機能を維持する.これは,継続ベースモナドだけではなく,プログラムの実行フローを完全に制御できない状況にも便利である.たとえば,io-streamsディレクトリトラバーサルでも同じ手法を使っている.
最後に一つ注意すべきことがある.あなたが継続を実行するコードの完全な知識を持ち,すべての継続が常に実行されることを保証することができる場合であれば,継続ベースモナドが理論的に有効なbracket関数を持つことができる.この場合,コンストラクタを非表示にしてそのような実行関数を公開するだけで十分安全である.しかし,前提の証明の負担はあなたにある.
FFIの場合
一見純粋でもスレッドセーフでなかったり,おもくそ例外投げたりする.
注意深くドキュメントを読み,信じる
最悪ソース読むしかない
以下,余談
TODO
MonadMaskとMonadBracket
unlift-IO
monad-control