あどけない話

Internet technologies

Haskell での例外処理

リツイート数が30を超えたので、Haskell での例外処理について説明します。僕が思うに、Haskell での例外処理が分かりにくいのには、2つ理由があります。

歴史的経緯により、Prelude にも Control.OldException にも Control.Exception にも catch があります。歴史的経緯を説明するのは面倒なので、これだけ覚えて下さい。「Control.Exception だけを使って、それ以外は忘れる」

そもそも純粋関数型で catch とか言われても分からないかもしれません。Haskell では、純粋な関数と IO とでは、例外処理の方法が異なります。命令的な catch などを使うのは IO です。純粋な関数には Maybe か、Either を使います。

純粋な関数

純粋な関数では、原則として例外を投げてはいけません。では、どうするのでしょうか? ほら、C 言語には例外なんてなかったことを思い出して下さい。純粋な関数では、返り値で例外を表現するのです。

とにかく失敗だと分かればいいのであれば、Maybe を使います。失敗は Nothing で表します。以下、Data.List の lookup の例です:

lookup :: (Eq a) => a -> [(a,b)] -> Maybe b
lookup _key []          =  Nothing
lookup  key ((x,y):xys)
    | key == x          =  Just y
    | otherwise         =  lookup key xys

正常の場合と例外の場合は、case 式で処理しましょう。(case 式が面倒になってきたら、Data.Maybe の maybe を使いましょう。)

Haskell では、純粋な関数であれば、失敗する可能性があるのか、型から判断できます。Haskell 初心者にはよく分からないかもしれませんが、これは素晴らしい機能です。Haskell では、Null ポインターを踏むことがなくなるのですから。

失敗した理由も伝えたい場合は、Either を使います。以下は、Parsec の parser の型です:

parse :: Stream s Identity t => Parsec s () a -> SourceName -> s -> Either ParseError a

正常の場合と例外の場合は、case 式で処理しましょう。

とは言っても、お行儀の悪い純粋な関数はあります。たとえば、純粋な関数である head や tail は例外を投げますね。これを Haskell の恥の一つと考える人もいます。head や tail は、例外を投げようがない場合(リストが空でないのを確かめた場合)に限り、使ってもいいのです。

IO

IO は、命令型と同じような感じで、例外を使います。IO という型自体が、失敗するかもしれないことを意味しています。

これ以降、Control.Exception を import しているとして話を進めます。

import Control.Exception as E

例外を止める系

例外を捕まえて、そこで止める、つまり上位に伝えないためには、catch を使います。

catch :: Exception e => IO a -> (e -> IO a) -> IO a

こういう風に使います。

catch (したいこと) (例外処理)

catch を二項演算子にして使うとおしゃれです。

(したいこと) `catch` (例外処理)

(したいこと)は、普通の IO です。問題は、(例外処理)が引数に Exeption e を取る関数だと言うことです。初心者には e の型を決めるのが、とても困難でしょう。とりあえず、どんな例外も合致する SomeException という型があるので、最初はそれを使ってみましょう。

以下は、ファイル関係のエラーを無視する例です:

ignore :: SomeException -> IO ()
ignore _ = return ()

copy :: FilePath -> IO ()
copy file = do
   xs <- readFile file
   writeFile file xs

safeCopy :: FilePath -> IO ()
safeCopy file = copy file `E.catch` ignore

mapM に対して forM が定義してあるように、Haskeller としては本質的なところを目立たせて書きたくなります。そこで、引数の順番を入れ替えた、handle という関数もあります。これを使えば、copy という補助関数が不要になります。

safeCopy :: FilePath -> IO ()
safeCopy file = handle ignore $ do
   xs <- readFile file
   writeFile file xs

例外をスルーする系

例外が起こったときに何かをするけど、その例外は止めない、つまり上位に伝達する関数に onException があります。

onException :: IO a -> IO b -> IO a

どんな例外が起こったかは、上位の関数が catch して調べることを想定しているので、例外処理をする第二引数は関数でないことに注意して下さい。こういう風に使います。

(したいこと) `onException` do
   -- 例外は上位関数に伝搬するよ
   例外処理を書く
   例外処理を書く

似たような関数に finally があります。これは、正常終了したときと、異常終了したときの両方で、後処理を実行します。

(したいこと) `finally` do
   -- 例外は上位関数に伝搬するよ
   後処理を書く -- 正常終了してもここにくるし
   後処理を書く -- 異常終了してもここにくるよ

ローンパターン

ある資源を確保して、正常終了しても異常終了しても、資源を解放したいことはよくあります。それには、bracket という関数を使います。

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

こういう風に使います。

bracket (資源割当) (資源解放) (したいこと)

ポート番号から、リッスンソケットを開き、したいことをした後、必ずソケットを解放する withSocket という関数は以下のように定義できます。

import Network

withSocket :: PortID -> (Socket -> IO ()) -> IO ()
withSocket port body = bracket (listenOn port) sClose body

bracket も例外を上位に伝搬します。

おまけ

Haskell は、非同期例外も処理できます。非同期例外というのは、そのスレッドの動作によって引き起こされた訳ではない例外です。簡単に言えば、自分が他のスレッドから kill されたときの例外も捕まえられるのです。

> tid <- forkIO ((threadDelay 5000000 >> putStrLn "Completed") `onException` putStrLn "Killed")
> killThread tid
Killed ← 非同期例外を捕まえた
> tid <- forkIO ((threadDelay 5000000 >> putStrLn "Completed") `onException` putStrLn "Killed")
> Completed ← 5秒間生き延びた

明日に続く

時間切れなので、例外の投げ方と自分用の例外を作る方法は、明日書きます。