Hatena::ブログ(Diary)

純粋関数型雑記帳 このページをアンテナに追加 RSSフィード Twitter

このページはHaskellを愛でるページです。
日刊形式でHaskellなどについての記事をだらだらと掲載しとります。
2004 | 07 | 08 | 09 | 10 | 11 | 12 |
2005 | 01 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 10 | 11 | 12 |
2006 | 01 | 02 | 03 | 04 | 06 | 07 | 08 | 09 | 11 |
2007 | 03 | 04 | 05 | 07 | 08 | 09 | 12 |
2008 | 02 | 04 | 05 | 06 | 07 | 08 | 09 | 10 | 11 | 12 |
2009 | 03 | 05 | 06 | 09 | 10 | 11 | 12 |
2010 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 10 | 12 |
2011 | 01 | 02 | 05 |
本体サイト

2010年12月07日(火) Haskellのエラー処理とMonadCatchIOの落とし穴

[] Haskellエラー処理とMonadCatchIOの落とし穴  Haskellのエラー処理とMonadCatchIOの落とし穴を含むブックマーク  Haskellのエラー処理とMonadCatchIOの落とし穴のブックマークコメント

(この記事はHaskell Advent Calendar jp 2010のために書かれました)

Haskellではエラー処理に例外が用いられます(MaybeモナドやErrorモナドも用いられますが、ここでは例外に焦点をあてます)。

例外インターフェースの話

Haskellにも、例外を扱うためにtry, catch, finallyなどが用意されています。他の多くの言語ではこれらは構文として用意されますが、HaskellではIOモナド引数にとる関数になっています

try :: Exception e => IO a -> IO (Either e a)
catch :: Exception e => IO a -> (e -> IO a) -> IO a
finally :: IO a -> IO b -> IO a

tryはIOアクション引数にとり、それを実行した結果が正常に値を返したか、はたまた例外かを返します。catchは例外が起こった場合の処理を記述できます。finallyは例外が起こっても起こらなくても第2引数アクションを実行します。

これら(ともっと他にある例外処理関数)を組み合わせてHaskellではエラー処理を行います。ひときわ良くあるケースとして、リソースの獲得、リソースの使用、リソース解放の一連のパターンがあります。たとえば、ファイルオープンして、ファイルアクセスして、用事が済んだらファイルクローズする処理を考えてみます

main = do
  h <- openFile "hoge" ReadMode
  ... ファイルにアクセス
  hClose h

ファイルオープンクローズが別々になっているので、クローズを忘れるということがあるかもしれません。これを抽象化してみます

main = do
  withFile "hoge" ReadMode $ \h ->
    ... ファイルにアクセス

withFile filename mode m = do
  h <- openFile filename mode
  m h
  hClose h

これでwithFileを用いている限りは、ファイルのcloseし忘れということに煩わされる心配はなくなりました。ところが、この実装は不完全です。ファイルアクセスする部分のコードが例外を発生させた場合、withFileの最後の行、hCloseが実行されずに終わってしまます。そのため、正しく例外を処理するコードが必要になります

withFile filename mode m = do
  h <- openFile filename mode
  m h `finally` hClose h

これで正しいコードができました。この様なリソースの確保、リソースの解法を例外安全に行う処理というのは至る所で必要になってくるので、bracketという便利な関数が用意されています

bracket :: IO a -> (a -> IO b) -> (a -> IO c) -> IO c

第一引数リソース獲得関数で、第2引数リソース解法関数で、第3引数リソースを使用する関数です。リソース解法関数は、例外が起こった場合も正しく実行されます。これを用いると、withFileは次のようにかけます

withFile filename mode m =
  bracket (openFile filename mode)
          hClose
          m

MonadCatchIO

このようにしてHaskellエラー処理を記述することができますが、一つ大きな問題点があります。それは、これらの関数がすべてIOモナドを用いたインターフェースになっていることです。Haskellで例外が発生するのはIOモナドだけではありません。一つはpureなコードから発生する場合ともう一つはIOをリフトしたモナドから発生する場合です。pureなコードから発生する場合は、evaluate :: a -> IO a という関数を介することにより、IOモナド経由で例外を処理することができます後者はたとえば変換子版のモナドを用いている場合に頻発します。近年の多くのモナディックなライブラリでは変換子版が用意されていることが多く、liftIOを用いてどこでもIOができるようになっています。liftIOによってIOをリフトできるモナドはMonadIOクラスとして抽象化されています

MonadIOに対して例外処理を追加し、例外処理を一般化したものがMonadCatchIOクラスです。Hackage上の、MonadCatchIO-mtlや、MonadCatch-transformersのいずれかで利用できます。これを用いると、IOモナドをリフトできるIOモナド以外のモナド(たとえば StateT Int IO など)に対してtry, catch, bracketなどができるようになります

foo :: MonadCatchIO m => m ()
foo = bracket (putStrLn "begin")
              (\_ -> putStrLn "end")
              (\_ -> ... 凝った処理 ... )

エラーモナドに対するインスタンス

ところがこれには大きな罠が潜んでいますMonadCatchIO-transformersのドキュメントWarningとして記載されていますが、ここでそれを解説しておきたいと思います

(MonadCatchIO m, Error e) => MonadCatchIO (ErrorT e m)

問題となっているMonadCatchIOのインスタンスはこれです。どうしてこれがいけないのかというと、このモナドには2つのエラー通知方法があります。一つはMonadCatchIOで扱える例外、もう一つはエラーモナドです。一方のエラー処理の方法では、当然ながら他方のエラーは検出できません。つまり、エラー処理が分散してしまうことになります。これはこれでうれしいことではないのですが、さらに良くないことに、このことは奇妙な、望ましくない現象を引き起こします

例えば、次のようなコードを考えます

import Data.Typeable
import Control.Exception as E
import Control.Monad.CatchIO as MCIO
import Control.Monad.Trans

data MyException = MyException deriving (Show, Typeable)
instance Exception MyException

iofail :: IO ()
iofail = do
  E.throwIO MyException

foo :: MonadCatchIO m => m () -> m ()
foo m = MCIO.bracket (liftIO $ putStrLn "abc")
                     (\_ -> liftIO $ putStrLn "def")
                    (\_ -> m)

main :: IO ()
main = do
  foo iofail

MyExceptionはユーザ定義の例外を定義しています。iofailで例外をIOモナドとして発生させています。fooにiofailを渡しているので、bracketの中で例外が発生しますが、終了処理のputStrLn "def"は実行されるはずです。実行結果は次のようになります

abc
def
mcio.hs: MyException

この場合、MonadCatchIOは単なるIOとしてインスタンス化されます。正しく動いているように見えます。次に、ErrorT String IO としてfooを実行してみます

errfail :: MonadCatchIO m => m ()
errfail = do
  MCIO.throw MyException

foo :: MonadCatchIO m => m () -> m ()
foo m = MCIO.bracket (liftIO $ putStrLn "abc")
                     (\_ -> liftIO $ putStrLn "def")
                     (\_ -> m)

main :: IO ()
main = do
  r <- runErrorT $ foo errfail
  print (r :: Either String ())
abc
def
mcio.hs: MyException

これも正しく動いているように見えます

これらはいずれも例外を投げていました。次に、もう一つのモナドの標準的なエラーであるfailを試してみます

iofail :: IO ()
iofail = do
  fail "hoge"

foo :: MonadCatchIO m => m () -> m ()
foo m = MCIO.bracket (liftIO $ putStrLn "abc")
                     (\_ -> liftIO $ putStrLn "def")
                     (\_ -> m)

main :: IO ()
main = do
  foo iofail

まずはIOモナドです。

abc
def
mcio.hs: user error (hoge)

これは正しく動作します。次に、ErrorT String IO で試してみます

errfail :: MonadCatchIO m => m ()
errfail = do
  fail "hoge"

foo :: MonadCatchIO m => m () -> m ()
foo m = MCIO.bracket (liftIO $ putStrLn "abc")
                     (\_ -> liftIO $ putStrLn "def")
                     (\_ -> m)

main :: IO ()
main = do
  r <- runErrorT $ foo errfail
  print (r :: Either String ())

さて、実行結果です。

abc
Left "hoge"

おや、さっきとは変わりました。failによるエラーがErrorモナドによるエラーの扱いになっているようですね。結果としてLeft "hoge"が返って来ています。そこはそれで良いのですが、問題なのはdefが出力されていないということです。これはどういうことなのでしょうか?

これはErrorTモナドbindのセマンティクスおよびfailのセマンティクスに問題があります。ErrorTモナドでは、エラーが起こるとbindにおける右辺値、すなわち後続の計算がすべてショートカットされるようになっています。そして、failはErrorTモナドにおけるエラー値を返します。ゆえに、ErrorTにおいては、failが呼ばれた時点で残りの計算はすべてスキップされてしまます。当然それにはリフトされているIOも含まれます。折角MonadCatchIOを用いて記述した例外処理も含まれます。確実にリソース解放処理を行わせるためにbracketを使っているのにこれでは大問題です。

どうすべきか

MonadCatchIOの例外処理が正しく動作するかどうかは、インスタンスにするモナドのセマンティクスに全面的に依存します。たとえば標準モナド変換子ライブラリですと、ErrorTとContTがこの問題を抱えているようで、ドキュメントにその旨が記載されていますモナドのセマンティクスに依存するので、もちろんそれ以外のモナドでもありうるかもしれません。例えば、Snap web frameworkにおけるSnapモナドでも同様の問題がありました(Snapモナドでは例外発生時でもbracketをすり抜けてしまっていました)。

どうすればいいんでしょうか。これに対する決定的な解決を私は知りません。自分がMonadCatchIOのインスタンス作成する場合、もし可能なら、計算ショートカットしないようにすれば解決です。しかし、それは一般的には可能ではないでしょう。

この様な問題を抱えたMonadCatchIOのモナドを扱うにおいては、もっとも妥当な対策として、liftIOする前にすべての例外を捕まえてしまうようにするのがいいかと思われます(何のためのMonadCatchIOなのかという話になりますが…)。

少なくとも、この様な現象が発生し得るということを知っておくだけでもデバッグの役に立つかもしれません。私は最初この現象に遭遇した時に、必死にprintを挿んでコードを追っていましたが、突然コードパスが途切れて一体どうなっているのかとかなり悩んでしまいました。