続・Haskellの最近の例外ハンドリング
前回 の記事ではHaskellの例外ハンドリングには exceptions パッケージを使えばいいのではないかと書いた。
ところが今年の6月に safe-exceptions という exceptions を拡張したようなパッケージがさる FPComplete から 発表 された。
そこでこの記事では safe-exceptions について調べてみる。 おそらくほぼ FPComplete の発表の受け売りになってしまうので英語を読める人は原文を読む方がいいかもしれない。
さすが FPComplete だけあってこれは既に LTS Haskell に入っている。 この記事では lts-6.14 を用いる。
Haskellの例外のつらいところ
自分が認識している範囲ではHaskellの例外まわりは以下のところがつらい。
- 標準の例外系の関数が
IO
に特化されていて取り回しが悪い - いかにも純粋そうな関数が例外を出したりスレッドセーフでなかったりする
- 例外型は何でも非同期例外として投げることができるし、キャッチ部で非同期例外かそうでないかの区別をつけられない
1.については前回の記事で紹介した exceptions パッケージによってかなり改善される。
2.は内部的にFFIでC関数を呼ぶライブラリを使うとたまにある。 まあ例外出すかやスレッドセーフかどうかはHaddockに大体書いてあるし、気になるのは好みの問題かもしれない。
3.が結構な問題である。
非同期例外というのは別のスレッドから来る例外で、
コード上ではtry節の外で発生する。
以下の someFunc
の catch
はもちろん InternalException
をキャッチするために書かれている。
しかし実際は ExternalException
をキャッチしてしまうのである!
{-# LANGUAGE ScopedTypeVariables, DeriveDataTypeable #-} module Main where import Data.Typeable (Typeable) import Control.Concurrent (forkIO, threadDelay) import Control.Exception data InternalException = InternalException String deriving (Show, Typeable) instance Exception InternalException data ExternalException = ExternalException String deriving (Show, Typeable) instance Exception ExternalException someFunc :: IO () someFunc = do ( do threadDelay $ 1000 * 1000 throw $ InternalException "error form inside!" ) `catch` (\(e::SomeException) -> do print e ) main :: IO () main = do threadId <- forkIO someFunc threadDelay $ 500 * 1000 throwTo threadId $ ExternalException "error from outside!" threadDelay $ 100 * 1000 return () -- > ExternalException "error from outside!"
この例では InternalException
と ExternalException
で同期例外と非同期例外を型で区別できそうにも見える。
しかし throwTo
はどんな例外でも投げることができるので InternalException
も投げる可能性を排除できない。
すべての catch
は非同期例外を受け取りうるし、キャッチした例外が同期か非同期か区別できないということを
念頭に置いて書かなければならない。
これは非常につらい。
safe-exceptions以前の対処
enclosed-exceptions パッケージというものがある。 enclosedの名前のとおり、try部の中の例外だけをキャッチする関数を提供する。
その仕組みは FPCompleteの記事 に書かれているが、 非同期処理をもって非同期例外を倒すという感じで面白い。
safe-exceptions
enclosed-exceptions は内部的に非同期処理を用いて非同期例外をうまく扱う。 しかし非同期処理を使うことのオーバーヘッドが生まれてしまった。
safe-exceptions は別のアプローチを用いる。
このライブラリは throw
や throwTo
をラップして同名で再エクスポートする。
これらは実際に例外を投げる「前」に例外を SyncExceptionWrapper
もしくは AsyncExceptionWrapper
で包んで投げる。
catch
は同期例外と非同期例外を型で区別できないので、投げるときに区別できるようにしてしまうわけだ。
非同期処理を使っていないので非同期処理によるオーバーヘッドもない。
このライブラリは throw
や throwTo
のほかにも、 throwIO
や catch
や catchIO
などの
おなじみの関数を再実装しているので新たに使いかたを学ぶ必要はほとんどない。
しかも exceptions と同様にモナド変換子に対応している。
もし危険性を理解して非同期例外もキャッチしたいときには catchDeep
が使える。
まとめ
safe-exceptions を使おう。
Haskellの最近の例外ハンドリング
どうもHaskellには標準のControl.Exception
モジュールだけでなくmtl
やexceptions
やexceptional
といった例外を扱うためのパッケージがあるらしいのだが、そのあたりのパッケージの選び方や使い方についてまとまった情報を見つけられなかった。
HaskellWiki例外のページも少々古いようで、deprecatedなものや統合される前のパッケージを書いていたりする。
調べた限り、mtl
とexceptions
が今の主流っぽい。
その2つの使い方をまとめる。
なおバージョンはlts-6.1を基準としている。
mtl
mtl
パッケージのControl.Monad.Except
モジュールはMonadError
というモナドとExceptT
というモナド変換子を提供する。
以下のように使う。
import Control.Monad.Trans(lift) import Control.Monad.Except(runExceptT, throwError, catchError) main :: IO () main = do runExceptT $ do (do lift $ print "start" throwError "exception!" lift $ print "finish" ) `catchError` (\e -> do lift $ print "caught" lift $ print e ) return () -- > "start" -- > "caught" -- > "exception!"
throwError
とcatchError
がMonadError
のプリミティブで、
runExceptT
はExceptT
をはがす役である。
catchError
でキャッチしない場合はEither
として取り出せる。
main = do ret <- runExceptT $ do lift $ print "start" throwError "exception!" return "ret value" print ret -- > "start" -- > Left "exception!"
あくまでthrowError
をキャッチするものだということに注意:
main = do ret <- runExceptT $ do lift $ print "start" file <- lift $ readFile "nothing.txt" throwError "exception!" lift $ print file return "ret value" print ret -- > "start" -- > except-test-exe: nothing.txt: openFile: does not exist (No such file or directory)
実はIO
もMonadError
のインスタンスなので、IO
中のIOException
をキャッチしたい場合はrunExceptT
なしで]
以下のように書ける。
main = do (do print "start" file <- readFile "nothing.txt" print file ) `catchError` (\e -> do print "caught" print e ) -- > "start" -- > "caught" -- > nothing.txt: openFile: does not exist (No such file or directory)
ただし、IO
をMonadError
として使う場合はthrowError
できるのはIOException
だけである。
以下のようなものはコンパイルエラーになってしまう。
main = do (do print "start" file <- readFile "nothing.txt" throwError "my error" -- > Couldn't match type ‘GHC.IO.Exception.IOException’ with ‘[Char]’ print file ) `catchError` (\e -> do print "caught" print e )
自作の別の例外を投げたいときはやはりrunExceptT
を使う必要がある。
exceptions
exceptions
パッケージのControl.Monad.Catch
モジュールは標準のControl.Exception
に対してよりフレンドリーである。
Control.Exception
の大部分をラップあるいは再エクスポートしており、Control.Exception
をインポートしなくても
Control.Exception
と同じ名前の関数が同じように使える。
{-# LANGUAGE DeriveDataTypeable #-} {-# LANGUAGE ScopedTypeVariables #-} import Data.Typeable import Control.Monad.Catch data MyException = MyException String deriving(Show, Typeable) instance Exception MyException data MyAnotherException = MyAnotherException String deriving(Show, Typeable) instance Exception MyAnotherException main = do (do print "start" throwM $ MyException "exception!" throwM $ MyAnotherException "another exception!" print "finish" ) `catch` (\(e::SomeException) -> do print "caught" print e ) -- > "start" -- > "caught" -- > MyException "exception!"
Control.Exception
との違いはthrow
がthrowM
に変わるくらいである。
Control.Exception
のthrow
はモナドがIO
に限定されてしまうが、exceptions
のthrowM
ではMonadThrow
を実装しているモナドであればよい。
MonadThrow
の実装が提供されているのはIO
のほかにはmtl
パッケージの各モナド(WriterT
など)がある。
まとめ
例外のためにモナドスタックを一段増やすのは例外の意義から言ってもやりすぎのような気がする。
IO
がすでに失敗しうるという意味を含んでいるし。
個人的には実用上はexceptions
を使えばよいと思う。