人気ブログランキング | 話題のタグを見る

48時間でSchemeを書こう/エラー処理と例外

例外処理とエラー・モナドについて少し予習したので、『48時間でSchemeを書こう/エラー処理と例外』が読めるようになった。

この記事で『48時間でSchemeを書こう』というチュートリアルのチュートリアル記事の最後にする。Parsec の構文解析の結果をどうプログラムの動作に結びつけていくかを知るという目的は達成されたとおもえるからだ。しかし、Haskell を使いこなすためのその他のヒントが『48時間..』のページには満載されている。管理人の Haskell 力が成長したらまた残りの部分にも挑戦してみたい。

それでは、本文を最初から読んでみよう。まずは、Control.Monad.Error のモジュールのインポートから。

import Control.Monad.Error

これをやっておくと、戻値を Either でラッピングするだけで、エラー・モナド(Either モナド)型の関数を作ることができる。また、エラー・モナド型の関数には、throwError と catchError という2つの関数を関数適用できる。

次にやることは、Scheme 用のエラー値のセット LispError を作ること。

data LispError = NumArgs Integer [LispVal] ... (以下省略)

Scheme の構文解析や、実行の段階でエラーが発生した時、これらのエラー値が戻される。また、エラー値を文字列に変換する関数 showError は必須。

showError :: LispError -> String
showError (UnboundVar message varname) = message ++ ": " ++ varname ... (以下略)

LispError を Show クラスのインスタンスにして、show を showError で定義しておくと show 関数で LispError 関数を文字列化できる。

instance Show LispError where show = showError

LispError を例外発生時にエラー情報として戻すためには、LispError を Error クラスのインスタンスにして、noMsg と strMsg を定義しなくてはならない。逆にたったこれだけで、LispError をエラー情報として利用することができるようになる。

instance Error LispError where
     noMsg = Default "An error has occurred"
     strMsg = Default

次にやることは、throwError でユーザ例外を発生したり、catchError で例外を補足するときに LispError を使えるようにすること。このためには、LispError を Either でラッピングする必要がある。

type ThrowsError = Either LispError

Either 型コンストラクタを利用するときは、Either a b という形を取るのに上の例では b が記述されていない。これは、型コンストラクタの定義をカリー化することで、Either LispError Integer や Either LispError LispVal など Right b のコンテナに入るデータ型が異なっていても良いようにするためだ。

ここまでで、自前のエラー値を使うためのインフラは整った。これまでの手順は、ユーザ定義エラーを使うための定型的な手順になる。新米 Haskeller が最も欲しい情報だ。

インフラはできたので、後は要所要所でエラー発生の例外を生成させるようにする。

先ず構文解析のパーサ readExpr が parse 関数で例外が発生したときに例外を投げるようにしている。

readExpr :: String -> ThrowsError LispVal
readExpr input = case parse parseExpr "lisp" input of
    Left err -> throwError $ Parser err
    Right val -> return val

case parse paseExpr "lisp" input of でパーサからのエラー情報 Left err を取得した場合、そのエラーを lispError 型の Parser データコンストラクタでラップして throwError 関数に渡すと、Parser err は ThrowsError 型にさらにラップされて、例外として投げられる。

一方が ThrowsError モナド値になったので、マッチが成功した時の値 Right val も return val でThrowsError モナドにして返さなければ、戻値の型を合わせられない。

この改造で readExpr はマッチが失敗した場合は、Left (Parser err) を例外として投げ、成功した時は Right val が戻値として返される。parse 関数からの Either 型の戻値は、readExpr で ThrowsError モナドの値に変換される。

こうして構文解析器からエラーが投げられるようになったが、次は評価器からもエラーが投げられるようにする。文字列や、数値や、真理値やクォーテッドリストが評価された場合は、それを return で ThrowsError モナド値にして戻す。また、関数の評価の時は引き数 args をモナドマップの mapM で評価し、それを apply func 関数に渡す。LispVal のBadFormデータ型の場合は、throwError 関数を使ってエラーを投げる。

eval :: LispVal -> ThrowsError LispVal
eval val@(String _) = return val
eval val@(Number _) = return val
eval val@(Bool _) = return val
eval (List [Atom "quote", val]) = return val
eval (List (Atom func : args)) = mapM eval args >>= apply func
eval badForm = throwError $ BadSpecialForm "Unrecognized special form" badForm

apply 関数は func と args を引数に取り、func が primitives テーブルになければエラーを投げ、primitive テーブルにあれば、それを args に関数適用し Right LispVal の形で戻値を返す。

apply :: String -> [LispVal] -> ThrowsError LispVal
apply func args = maybe (throwError $ NotFunction "Unrecognized primitive function args" func)
                        ($ args)
                        (lookup func primitives)

関数適用 ($ args) の時に return が用いられていないが、primitives テーブルの関数の方を ThrowsError Lispval の値を戻すように手を入れてある。

primitives :: [(String, [LispVal] -> ThrowsError LispVal)]

このあたりは型チェックの厳格さに対応するための綱渡のように見える。しかし、コードを読む側に立つと、戻り値の型を追跡するだけで、どこが関数の終わりなのかを知ることが容易になる。

primitives テーブルの中に現れる次の2つの関数 numericBinop、unpackNum もエラーを投げるパターンを追加すると共に戻値を ThrowsError型になるように改変してある。

numericBinop :: (Integer -> Integer -> Integer) -> [LispVal] -> ThrowsError LispVal
numericBinop op singleVal@[_] = throwError $ NumArgs 2 singleVal
numericBinop op params = mapM unpackNum params >>= return . Number . foldl1 op

unpackNum :: LispVal -> ThrowsError Integer
unpackNum (Number n) = return n
unpackNum (String n) = let parsed = reads n in
                          if null parsed
                            then throwError $ TypeMismatch "number" $ String n
                            else return $ fst $ parsed !! 0
unpackNum (List [n]) = unpackNum n
unpackNum notNum = throwError $ TypeMismatch "number" notNum

最後に main 関数も ThrowsError 型のモナドを扱えるように改変してある。

main :: IO ()
main = do
    args <- getArgs
    evaled <- return $ liftM show $ readExpr (args !! 0) >>= eval
    putStrLn $ extractValue $ trapError evaled

IOモナドの中で、ThrowsError モナドを扱うというモナドの入れ子構造になるので、新米 Haskeller には少し荷が重かった。そこで本文の説明を引用するだけにする。

この新たな関数がやっていることは以下の通りです。
  1. argsはコマンドライン引数のリスト
  2. evaledは以下の結果
    1. 最初の引数を取って(args !! 0)
    2. パースして(readExpr)
    3. evalに渡して(>>= eval - bind演算子は関数適用より高い優先順位を持つ)
    4. Errorモナドの中の値に対してshowを呼ぶ。アクション全体がIO (Either LispError String)型を持つので、evaledがEither LispError String型を持つことに注意してください。trapError関数がエラーをStringにのみ変換でき、その型は普通の値の型に適合しなければならないので、そうでなくてはなりません。
  3. caughtは以下の結果
    1. trapErrorをevaledに対して呼び、エラーをその文字列表現に変える
    2. extractValueを呼びStringをEither LispError Stringアクションから取り出す
    3. putStrLnで結果を表示


ここまでの編集結果は listing5.hs に反映されている。コピーした listing5.hs を ghci に :load してエラー処理が起きるかどうか確認してみた。

Prelude> :l listing5.hs
[1 of 1] Compiling Main ( listing5.hs, interpreted )
Ok, modules loaded: Main.
*Main> :main "(+ 2 \"two\")"
Invalid type: expected number, found "two"

チュートリアルのチュートリアルという変な記事が続いているが、『48時間でSchemeを書こう』の著者の背景知識と管理人のそれがかなりレベルに差があるので仕方がない。

しかし、きちんと動くコードが添付されているので、まず動かしてから何が書かれているのかを確かめられるのでありがたい。普通の入門書には書かれていないテクニックが満載なのがうれしい。おなじようなコードを書けと言われても書けないと断言できるが、実際に動くコードが手元にあれば、習熟した後でもう一度読んで理解を深めることができるだろう。

『48時間でSchemeを書こう』のチュートリアルのチュートリアル記事はこれでおしまい。Parsec の構文解析の結果をどのようにプログラムに結びつけていくかという部分は終了できたと思うからだ。さらに、管理人の知識がしだいに息切れし始めたのも理由のひとつ。

しかし、『48時間でSchemeを書こう』の後半は、Parsecとは離れた、Haskell プログラミングのコツが満載されている。そのテクニックを細切れに取り出して、プログ記事にできればいいなと思っている。いずれにせよ、宝物を手に入れた気分だ。
by tnomura9 | 2011-12-13 07:27 | Haskell | Comments(0)
<< DO@RAT うんぱるんぱ >>