Arantium Maestum

プログラミング、囲碁、読書の話題

HaskellとParsecでLisp REPL その8(変数定義)

今回の変更

github.com

「ユーザによる変数定義」機能を追加する。

これはかなり大きな変更で、変数の内容を保持・評価するために、変更可能な「状態」を導入する必要がある。

Haskellだと状態をStateSTモナドで管理することも可能(らしい)のだが、基本的にそれらは「ローカルに状態が必要な関数」に役にたつようだ。今回のケースでは変数の状態はほぼグローバルに必要な上、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

値が存在しない場合はLispValBool 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に代入している。

評価器

evalVariablesで定義した関数を追加していく。

今まで評価は

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の値が使われている

次回

次はモナド・トランスフォーマを使ったエラー処理の枠組みを追加する。