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の値が使われている
次回
次はモナド・トランスフォーマを使ったエラー処理の枠組みを追加する。