Writing Your Own Lisp Interpreter in Haskell - Part 2
15 Feb 2025Introduction
In our previous post we put a very simple Lisp interpreter together that was capable of some very basic arithmetic.
In today’s update, we’ll introduce variable definitions into our Lisp interpreter, allowing users to define and retrieve values persistently. This required a number of structural changes to support mutable environments, error handling, and function lookups.
All the changes here will set us up to do much more sophisticated things in our system.
If you’re following along, you can find the implementation for this article here.
Mutable Environment
In order to store variables in our environment, we need to make it mutable. This way we can store new values in the environment as we define them.
Env was originally a pure Map:
type Env = Map String LispValThis meant that variable bindings were immutable and couldn’t persist across expressions.
We changed Env to an IORef (Map String LispVal), making it mutable:
type Env = IORef (Map String LispVal)We added nullEnv to create an empty environment:
nullEnv :: IO Env
nullEnv = newIORef Map.emptyWhy?
- This allows variables to persist across expressions.
- Future changes (like
set!for modifying variables) require mutability. IORefenables safe concurrent updates in a controlled manner.
REPL update
Now we need to update our REPL at the top level to be able to use this mutable state.
Previously, our REPL was just using the primitiveEnv value.
main :: IO ()
main = do
putStrLn "Welcome to Mini Lisp (Haskell)"
repl primitiveEnvWe now pass it in as a value. Note that the underlying types have changed.
main :: IO ()
main = do
env <- primitiveEnv -- Create a new environment
putStrLn "Welcome to Mini Lisp (Haskell)"
repl envWhy?
- The REPL now uses a mutable environment (
primitiveEnv). - This ensures variables persist across expressions instead of resetting each time.
Variable Definition
We introduced defineVar to allow defining variables:
defineVar :: Env -> String -> LispVal -> IOThrowsError LispVal
defineVar envRef var val = do
env <- liftIO $ readIORef envRef -- Read environment
liftIO $ writeIORef envRef (Map.insert var val env) -- Update environment
return valThis enables us to define variables like this:
(define x 10)defineVar reads the current environment, updates it, and then writes it back.
Evaluation
Probably the biggest change is that our evaluation no longer returns just a ThrowsError LispVal.
eval :: Env -> LispVal -> ThrowsError LispValThis has had to be upgraded to support our IO activity as we now have mutable state.
eval :: Env -> LispVal -> IOThrowsError LispValThis change allows eval to interact with mutable variables stored in Env and perform IO actions when updating
environment bindings
We also added parsing support for define:
eval env (List [Atom "define", Atom var, expr]) = do
val <- eval env expr
defineVar env var valVariable Lookup
Our lookupVar now needs an upgrade:
lookupVar :: Env -> String -> ThrowsError LispVal
lookupVar env var = case Map.lookup var env of
Just val -> Right val
Nothing -> Left $ UnboundVar varIt’s not designed to work in a mutable IORef environment. We create getVar to accomodate.
getVar :: Env -> String -> IOThrowsError LispVal
getVar envRef var = do
env <- liftIO $ readIORef envRef -- Read environment from IORef
case Map.lookup var env of
Just val -> return val
Nothing -> throwError $ UnboundVar ("Undefined variable: " ++ var)This now allows variables to be defined and retrieved across multiple expressions.
Builtins
Previously, built-in functions were not stored as part of the environment.
primitives :: [(String, LispVal)]
primitives =
[ ("+", BuiltinFunc numericAdd),
("-", BuiltinFunc numericSub),
("*", BuiltinFunc numericMul),
("/", BuiltinFunc numericDiv)
]This has changed now with primitiveEnv which will store a set of these.
primitiveEnv :: IO Env
primitiveEnv = newIORef (Map.fromList primitives)This change enables us to dynamically add more built-in functions in the future.
Fixing eval
With the introduction of IO into our program, our evaluation logic needed updates to handle variable bindings
correctly.
eval env (List (Atom func : args)) = do
func' <- eval env (Atom func)
args' <- mapM (eval env) args
apply func' args'Now, we’ll look up the function in the environment:
eval env (List (Atom func : args)) = do
func' <- getVar env func -- Look up function in the environment
args' <- mapM (eval env) args -- Evaluate arguments
apply func' args'Now, we’ll find any of our functions in the environment itself.
Fixing apply
Now, we need to look at the apply function.
apply (BuiltinFunc f) args = f argsWe add support for functions out of the environment with the liftThrows helper:
apply (BuiltinFunc f) args = liftThrows $ f argsProvision is also added for user-defined functions (lambda):
apply (Lambda params body closure) args = do
env <- liftIO $ readIORef closure -- Read function's closure environment
if length params == length args
then eval closure body
else throwError $ NumArgs (length params) argsThrowsError
Previously, ThrowsError was used for error handling:
type ThrowsError = Either LispErrorHowever, since we now interact with IO, we introduce IOThrowsError:
type IOThrowsError = ExceptT LispError IOWe also add helper functions to manage conversions between them:
runIOThrows :: IOThrowsError String -> IO String
runIOThrows action = runExceptT action >>= return . extract
where
extract (Left err) = "Error: " ++ show err
extract (Right val) = val
liftThrows :: ThrowsError a -> IOThrowsError a
liftThrows (Left err) = throwError err
liftThrows (Right val) = return valWhy?
- Allows
IOoperations inside error handling (necessary for mutable Env). - Prevents mixing
IOand pure computations incorrectly. - Enables future features like reading files, user-defined functions, etc.
Fixing readExpr
Finally, we need to fix readExpr. It’s current defined like this:
readExpr :: String -> ThrowsError LispValIt changes to support IOThrowsError:
readExpr :: String -> IOThrowsError LispVal
readExpr input = liftThrows $ case parse parseExpr "lisp" input of
Left err -> Left $ ParserError (show err)
Right val -> Right valThis allows readExpr to integrate with our new IOThrowsError-based evaluator.
Running
With all of these pieces in place, we can use define to define variables and start to work with them.
Welcome to Mini Lisp (Haskell)
λ> (define a 50)
50
λ> (define b 120)
120
λ> (define c 4)
4
λ> (+ (- b c) a)
166
λ>Conclusion
This update introduced:
- Persistent variables using
define - A mutable environment with
IORef - Function lookup inside the environment
- A fully working REPL that retains state across expressions
We’ll continue to add to this as we go. See you in the next chapter!