Haskellerへの道 #19 - Exception

Last Edited: 7/20/2024

このブログ記事では、Haskellの重要な型クラスであるExceptionを紹介します。

Haskell & Exception

Exception

物事がうまくいかない場合、それを管理しなければなりません。例えば、div関数は0で割ると動作しませんので、この場合に対処する方法が必要です。 以前はMaybeを使ってこれを行いましたが、Exception(例外)も使用できます。Exceptionは以下のような多くのインスタンスを持つ型クラスです。

import Control.Exception -- インポート
 
-- 例
instance Exception SomeAsyncException
instance Exception IOException
instance Exception ArithException
instance Exception TypeError
--- その他多数

例外を投げるには、次のようにします。

divide = do
    x <- getFloat
    y <- getFloat
    if y == 0 then throw DivideByZero else do return $ x / y

上記のコードは、ユーザーが提供したyが0の場合、Control.Exceptionモジュール内のDivideByZero例外をthrowを使って投げています。 また、独自の例外を定義してそれを投げることもできます。

data MyError = ErrorA deriving Show
instance Exception MyError
 
throw ErrorA -- => ***Exception: ErrorA

エラーをExceptionのインスタンスにするために関数を定義する必要はありませんが、Show型クラスに属する必要があります。

捕捉

例外はユーザーに表示されるように捕捉される必要があります。表示には画面に出力することが含まれるため、例外は不純なコードまたは IOモナド内でのみ捕捉されます。divideの例外をmain IOアクションで捕捉してみましょう。

-- IOアクション内で例外を捕捉する関数
catch :: Exception e => IO a -> (e -> IO a) -> IO a
 
main :: IO Float
main = do
    catch divide (\e -> 
      do 
      let t = e :: ArithException
      putStrLn "0では割れません。"
      return 0
      )

上記のcatch関数は、例外の可能性があるIOアクション、例外のハンドラーを取り、同じ型のIOアクションを返します。ここでは、divideに対して使用され、 DivideByZeroArithExceptionデータ型の値コンストラクタであるため、ArithExceptionに対するハンドラーを使用しています。ハンドラーはユーザーに 警告するために「0では割れません。」と表示し、0を返してdivideと同じ型であるIO Floatとなるようにします。

すべての例外はSomeExceptionであるため、全ての例外の捕捉としてSomeExceptionを使用することもできますが、すべての例外に対してハンドラーを異なる方法で定義する方が良いため、 これは必ずしも良い方法ではありません。複数の例外を捕捉する場合、代わりに次のようにできます。

f = <expr> `catch` \ (ex :: ArithException) -> handleArith ex
           `catch` \ (ex :: IOException) -> handleIO ex

上記のコードは一見良さそうですが、実際にはhandleArithハンドラーからIOExceptionを捕捉する可能性があるため、これは悪い方法です。 望むのは、<expr>からのみArithExceptionまたはIOExceptionを捕捉することです。これを達成するために、catches関数を使用できます。

-- 複数の例外を捕捉
catches :: Exception e => IO a -> [Handler (e -> IO a)] -> IO a
 
-- catchesで書き直し
f = <expr> `catches` [Handler (\ (ex :: ArithException) -> handleArith ex),
                      Handler (\ (ex :: IOException) -> handleIO ex)]

Try

例外を捕捉するもう一つの方法はtry関数を使用することです。これはIOアクションを取り、その結果をLeftに例外、 Rightに計算結果を含むEither型のIOアクションを出力します。

-- try関数
try :: Exception e => IO a -> IO (Either e a)
 
main :: IO Float
main = do
    result <- try divide :: IO (Either ArithException Float)
    case result of
        Left ex -> do 
          putStrLn "You divided by 0. " 
          return 0
        Right val -> return val

trycatchとほぼ同じですが、catchと異なり、tryは非同期例外によって中断される可能性があります。 また、純粋な関数から例外を捕捉したい場合は、evaluateを使用して純粋な関数の実行を強制する必要があります。 (これはcatchも同様。)

-- 例外を投げる純粋なdivide関数
divide x y
  | y == 0 = throw DivideByZero
  | otherwise = x / y
 
-- 純粋関数の実行にevaluateを使用
main = do
    result <- try (evaluate(divide 5 0)) :: IO (Either ArithException Float)
    case result of
        Left ex -> do 
          putStrLn "You divided by 0. " 
        Right val -> print val

trycatchcatchesの他にもtryJustcatchJustfinallyなどの事前定義された関数があります。それについて興味があるなら、 ググってみてください!(プログラマーにとってググることは最も重要なスキルの一つです。)

例外を使用するタイミング

Exceptionについて多くを学びましたが、どのようにしてMaybeEitherExceptionを使い分ければ良いのでしょうか? 最も簡単な答えは、「可能な限りExceptionは使わずMaybeEitherを使う」です。なぜなら、Exceptionは本当は関数型の思想に反しているからです。 IO、スレッド、システムに関する場合はExceptionは避けられないので使わざるをえませんが、それ以外の場合は、MaybeEitherを使用するようにしてください。

クイズ

この記事では、学習した内容を確認するためのクイズを設けます。記事のメイン部分を読んだ後に、ぜひ自分で問題を解いてみることを強くお勧めします。各問題をクリックすると答えが表示されます。

リソース