HaskellとParsecでLisp REPL その8(変数定義)
今回の変更
「ユーザによる変数定義」機能を追加する。
これはかなり大きな変更で、変数の内容を保持・評価するために、変更可能な「状態」を導入する必要がある。
Haskellだと状態をStateやSTモナドで管理することも可能(らしい)のだが、基本的にそれらは「ローカルに状態が必要な関数」に役にたつようだ。今回のケースでは変数の状態はほぼグローバルに必要な上、REPL上でユーザからのIOの間で状態を保持する必要がある、ということでIOモナド内で状態を持つIORefを使う。
IORefモナド
IORefというのはData.IORefに定義されているモナド。今回は以下の関数で扱う:
newIORef :: a -> IO (IORef a) readIORef :: IORef a -> IO a writeIORef :: IORef a -> a -> IO ()
あまり「他言語の機能との比喩」は好きではないが、newIORefはコンストラクタ、readIORefはgetter、writeIORefはsetter、そしてIORef自体はポインタ的なもの、という理解だ。上記の関数は、型からもわかる通り、使うと必ずIOモナドの中に入る。
Environmentモジュール
新しくEnvironmentというモジュールを作成した。定義された変数すべてを持ち歩く「環境」変数を定義するモジュールである。
type Env = IORef [(String, IORef LispVal)]
個別のユーザ定義変数は(変数名, IORef 値)というタプルで表現される。
それらを集めたリストをさらにIORefモナドで包んだのが環境変数を保持するための型であるEnv。
そして、「初期状態」を表すEnv型のnullEnvも定義:
nullEnv :: IO Env nullEnv = newIORef []
REPLが始まったばかりでユーザがまだなにも定義していない状態を表している。
Variablesモジュール
Variablesモジュールも新しく追加。変数定義・代入・値取得などの関数をここに書いていく。
以下の関数を定義:
isBound :: Env -> String -> IO Bool // 変数が定義されているか確認 getVar :: Env -> String -> IO LispVal // 変数の値を取得 setVar :: Env -> String -> LispVal -> IO LispVal // 値を既存の変数に代入 defineVar :: Env -> String -> LispVal -> IO LispVal // 変数を定義してから値を代入
まずは、ある変数がすでに定義されているかどうかを調べるヘルパー関数:
isBound :: Env -> String -> IO Bool isBound envRef var = readIORef envRef >>= return . lookup var >>= return . maybe False (const True)
環境を表すenvRefの中身をlookupして成功か失敗かを返す。
maybe関数の第二引数はJust値に適用する関数なので、const Trueで引数がなんだろうと常に「真」を返す関数を作成して渡している。
次に値取得:
getVar :: Env -> String -> IO LispVal getVar envRef var = readIORef envRef >>= return . lookup var >>= maybe (return $ Bool False) readIORef
値が存在しない場合はLispValのBool Falseを返し、存在する場合はreadIORefで変数に含まれる値を返す。環境も個々の変数もIORefを使っているのでreadIORefを二回使っている。
すでに存在する変数に対する代入:
setVar :: Env -> String -> LispVal -> IO LispVal setVar envRef var val = do { env <- readIORef envRef; maybe (return ()) (flip writeIORef val) (lookup var env); return val; }
代入するべき変数がlookupで見つかったらwriteIORefで代入。見つからなかったら何もしない。式全体の値は代入されるべき値。
変数が未定義の時に暗黙裡に失敗しているのがいや。エラー処理を実装する時に直す。
変数の定義:
defineVar :: Env -> String -> LispVal -> IO LispVal defineVar envRef var val = do { alreadyDefined <- isBound envRef var; if alreadyDefined then setVar envRef var val else do { valRef <- newIORef val; env <- readIORef envRef; writeIORef envRef ((var, valRef):env); return val; } }
すでに定義されている場合は値を代入するだけ。
変数が未定義の場合、その変数と値のタプルを追加した新しい環境変数を作成し、それをenvRefに代入している。
評価器
evalにVariablesで定義した関数を追加していく。
今まで評価は
eval :: LispVal -> LispVal
だったのが
eval :: Env -> LispVal -> IO LispVal
に変わった。この型の違いは
- ある
LispValを評価するには、評価対象だけではなく、世界の状態をもつEnvも必要 - 「評価」自体がIOを発生させ得る
という大きな変更を反映している。
それをふまえて既存のevalにも少し変更を加えないといけない。
一番簡単なのはこういうふうに:
eval env lispVal = return lispVal
引数を増やして、IOモナドに入れるためにreturnを追加するだけ。
関数適用やif構文ももちょこちょことモナド的な構文に変えている:
それでようやくVariablesで定義した関数を追加できる:
eval env (Atom var) = getVar env var
Atom一個をそのまま評価する場合、以前はAtomそのものを返していたのだが、変数として扱うようになった。
あとはset!とdefineを定義するだけ:
eval env (List [Atom "set!", Atom var, form]) = eval env form >>= setVar env var eval env (List [Atom "define", Atom var, form]) = eval env form >>= defineVar env var
REPL・Main
あまり大きな変更はないのだが、まずreadEvalPrintが環境envを引数として取るようにしたこと:
readEvalPrint :: Env -> String -> IO () readEvalPrint env str = (eval env $ readExpr str) >>= putStrLn . show
今までmain関数でREPLの構成関数を組み合わせていたのを、nullEnvを初期値としてreadEvalPrintに渡す必要があったので独立したREPL関数にまとめたこと:
readEvalPrintLoop :: IO () readEvalPrintLoop = nullEnv >>= loopUntil (== "quit") (readPrompt ">> ") . readEvalPrint
これでmain関数がすっきりした:
main :: IO () main = readEvalPrintLoop
実行
>> (define x 5) 5 // xを定義 >> (+ x 1) 6 // xを変数として使った式の評価 > (set! x 6) 6 // xに代入 >> (+ x 1) 7 // xの値が変わっている >> (set! y 2) 2 // 未定義の変数yに代入 >> (+ x y) 6 // yはまだ未定義なので値は0として扱われる >> (define y 1) 1 // yの定義 >> (+ x y) 7 // yの値がつかわれている >> (set! y 2) 2 // yに代入 >> (+ x y) 8 // 新しいyの値が使われている
次回
次はモナド・トランスフォーマを使ったエラー処理の枠組みを追加する。