続・Haskellの最近の例外ハンドリング

前回 の記事ではHaskellの例外ハンドリングには exceptions パッケージを使えばいいのではないかと書いた。

ところが今年の6月に safe-exceptions という exceptions を拡張したようなパッケージがさる FPComplete から 発表 された。

そこでこの記事では safe-exceptions について調べてみる。 おそらくほぼ FPComplete の発表の受け売りになってしまうので英語を読める人は原文を読む方がいいかもしれない。

さすが FPComplete だけあってこれは既に LTS Haskell に入っている。 この記事では lts-6.14 を用いる。

Haskellの例外のつらいところ

自分が認識している範囲ではHaskellの例外まわりは以下のところがつらい。

  1. 標準の例外系の関数が IO に特化されていて取り回しが悪い
  2. いかにも純粋そうな関数が例外を出したりスレッドセーフでなかったりする
  3. 例外型は何でも非同期例外として投げることができるし、キャッチ部で非同期例外かそうでないかの区別をつけられない

1.については前回の記事で紹介した exceptions パッケージによってかなり改善される。

2.は内部的にFFIでC関数を呼ぶライブラリを使うとたまにある。 まあ例外出すかやスレッドセーフかどうかはHaddockに大体書いてあるし、気になるのは好みの問題かもしれない。

3.が結構な問題である。 非同期例外というのは別のスレッドから来る例外で、 コード上ではtry節の外で発生する。 以下の someFunccatch はもちろん 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!"

この例では InternalExceptionExternalException で同期例外と非同期例外を型で区別できそうにも見える。 しかし throwTo はどんな例外でも投げることができるので InternalException も投げる可能性を排除できない。

すべての catch は非同期例外を受け取りうるし、キャッチした例外が同期か非同期か区別できないということを 念頭に置いて書かなければならない。 これは非常につらい。

safe-exceptions以前の対処

enclosed-exceptions パッケージというものがある。 enclosedの名前のとおり、try部の中の例外だけをキャッチする関数を提供する。

その仕組みは FPCompleteの記事 に書かれているが、 非同期処理をもって非同期例外を倒すという感じで面白い。

safe-exceptions

enclosed-exceptions は内部的に非同期処理を用いて非同期例外をうまく扱う。 しかし非同期処理を使うことのオーバーヘッドが生まれてしまった。

safe-exceptions は別のアプローチを用いる。 このライブラリは throwthrowTo をラップして同名で再エクスポートする。 これらは実際に例外を投げる「前」に例外を SyncExceptionWrapper もしくは AsyncExceptionWrapper で包んで投げる。 catch は同期例外と非同期例外を型で区別できないので、投げるときに区別できるようにしてしまうわけだ。 非同期処理を使っていないので非同期処理によるオーバーヘッドもない。

このライブラリは throwthrowTo のほかにも、 throwIOcatchcatchIO などの おなじみの関数を再実装しているので新たに使いかたを学ぶ必要はほとんどない。 しかも exceptions と同様にモナド変換子に対応している。

もし危険性を理解して非同期例外もキャッチしたいときには catchDeep が使える。

まとめ

safe-exceptions を使おう。