In this update, we extend our Lisp interpreter with floating point numbers and floating point math functions.
This brings us closer to full numeric support, allowing for a much richer mathematical capability.
If you’re following along, you can find the implementation for this article here.
Floating Point Type
Until now, our Lisp implementation only supported integers:
dataLispVal=AtomString|NumberInteger|BoolBool|StringString|FloatDouble-- Added floating point support|List[LispVal]|PairLispValLispVal|Lambda[String]LispValEnv|BuiltinFunc([LispVal]->ThrowsErrorLispVal)|BuiltinFuncIO([LispVal]->IOThrowsErrorLispVal)
We introduced a new constructor Float Double to represent floating point numbers.
Parsing
We needed to update our parser to recognize floating point literals. We do this in such a way to operate alongside the
current integer math that we currently support. We don’t want to disturb the work that we’ve already done, so we’ll use
that effort here.
parseInteger::ParserLispValparseInteger=dosign<-optionMaybe(char'-')-- Look for optional '-'digits<-many1digitletnumber=readdigitsreturn$Number$casesignofJust_->-numberNothing->numberparseFloat::ParserLispValparseFloat=dosign<-optionMaybe(char'-')-- Look for optional '-'whole<-many1digitchar'.'fractional<-many1digitletnumber=read(whole++"."++fractional)return$Float$casesignofJust_->-numberNothing->numberparseNumber::ParserLispValparseNumber=tryparseFloat<|>parseInteger
So, we changed the meaning of parseNumber from our original implementation. Instead of parseNumber handling only
integers, we split the logic into parseInteger and parseFloat, ensuring both number types are correctly parsed
This ensures that expressions like 3.14 are correctly interpreted as floating point numbers, while still maintaining
expressions like 3 being handled as integers.
One extra added feature here is handling negative numbers. We never observed the - symbol that can appear before
some numbers, so we’ve fixed this in the parser at the same time.
Numeric Coercion
Next, we needed to handle operations between integers and floats.
Our previous numeric functions assumed only integers, so we modified them to coerce integers to floats when
necessary. This means that we can use integers and floats together in our expressions.
This same logic was applied to subtraction, multiplication, and division.
Division Considerations
Division needed special attention because it must always return a float when dividing integers:
numericDiv[Numbera,Numberb]=ifb==0thenthrowError$TypeMismatch"Division by zero"(Numberb)elsereturn$Float(fromIntegrala/fromIntegralb)numericDiv[Floata,Floatb]=return$Float(a/b)numericDiv[Numbera,Floatb]=return$Float(fromIntegrala/b)numericDiv[Floata,Numberb]=return$Float(a/fromIntegralb)numericDivargs=throwError$TypeMismatch"Expected numbers"(Listargs)
This ensures that (/ 3 2) evaluates to 1.5 instead of performing integer division.
Adding Floating Point Math Functions
With float support in place, we introduced math functions like sin, cos, tan, exp, log, and sqrt:
importPreludehiding(log)numericSin,numericCos,numericTan,numericExp,numericLog,numericSqrt::[LispVal]->ThrowsErrorLispValnumericSin[Floata]=return$Float(sina)numericSin[Numbera]=return$Float(sin(fromIntegrala))numericSinargs=throwError$TypeMismatch"Expected a number"(Listargs)numericCos[Floata]=return$Float(cosa)numericCos[Numbera]=return$Float(cos(fromIntegrala))numericCosargs=throwError$TypeMismatch"Expected a number"(Listargs)numericTan[Floata]=return$Float(tana)numericTan[Numbera]=return$Float(tan(fromIntegrala))numericTanargs=throwError$TypeMismatch"Expected a number"(Listargs)numericExp[Floata]=return$Float(expa)numericExp[Numbera]=return$Float(exp(fromIntegrala))numericExpargs=throwError$TypeMismatch"Expected a number"(Listargs)numericLog[Floata]=ifa<=0thenthrowError$TypeMismatch"Logarithm domain error"(Floata)elsereturn$Float(loga)numericLog[Numbera]=ifa<=0thenthrowError$TypeMismatch"Logarithm domain error"(Numbera)elsereturn$Float(log(fromIntegrala))numericLogargs=throwError$TypeMismatch"Expected a positive number"(Listargs)numericSqrt[Floata]=ifa<0thenthrowError$TypeMismatch"Square root of negative number"(Floata)elsereturn$Float(sqrta)numericSqrt[Numbera]=ifa<0thenthrowError$TypeMismatch"Square root of negative number"(Numbera)elsereturn$Float(sqrt(fromIntegrala))numericSqrtargs=throwError$TypeMismatch"Expected a non-negative number"(Listargs)
We then added them to the built-in function table:
In our previous installment, we added
basic list operations to our Lisp interpreter, including cons, car, cdr, append, and reverse.
Now, we are expanding that functionality by introducing higher-order list functions:
map
filter
foldl and foldr
sort
Additionally, we’re introducing string manipulation functions that allow us to treat strings as lists of characters:
string->list
list->string
These changes bring our Lisp interpreter closer to standard Scheme-like behavior.
If you’re following along, you can find the updated implementation for this article here.
Lambda Expressions
A lambda function in computer programming is an anonymous function
that we use to pass to higher-order functions.
To support map, filter, and fold, we implemented lambda functions properly. Previously, our Lisp could
parse lambdas but couldn’t correctly apply them.
Adding Lambda Support
We modified eval to properly capture the current environment and store parameter names:
Now that lambda functions work, we can introduce list processing functions.
Map
map applies a function to each element in a list and returns a new list.
listMap::[LispVal]->IOThrowsErrorLispVallistMap[Lambdaparamsbodyenv,Listxs]=List<$>mapM(\x->apply(Lambdaparamsbodyenv)[x])xslistMapargs=throwError$TypeMismatch"Expected a function and a list"(Listargs)
Example
(map(lambda(x)(*x2))'(1234));; => (2 4 6 8)
Filter
filter removes elements that don’t satisfy a given predicate.
listFilter::[LispVal]->IOThrowsErrorLispVallistFilter[func@(Lambda___),Listxs]=dofiltered<-filterM(\x->doresult<-applyfunc[x]caseresultofBoolTrue->returnTrueBoolFalse->returnFalse_->throwError$TypeMismatch"Expected boolean return"result)xsreturn$ListfilteredlistFilterargs=throwError$TypeMismatch"Expected a function and a list"(Listargs)
Example
(filter(lambda(x)(>x5))'(2468));; => (6 8)
Fold (Reduce)
foldl and foldr accumulate values from left to right or right to left.
listFoldL::[LispVal]->IOThrowsErrorLispVallistFoldL[Lambdaparamsbodyenv,initial,Listxs]=foldM(\accx->apply(Lambdaparamsbodyenv)[acc,x])initialxslistFoldLargs=throwError$TypeMismatch"Expected a function, initial value, and a list"(Listargs)
listFoldR::[LispVal]->IOThrowsErrorLispVallistFoldR[Lambdaparamsbodyenv,initial,Listxs]=foldM(\accx->apply(Lambdaparamsbodyenv)[x,acc])initial(reversexs)listFoldRargs=throwError$TypeMismatch"Expected a function, initial value, and a list"(Listargs)
Example
(foldl(lambda(accx)(+accx))0'(12345));; => 15
This works like (((((0 + 1) + 2) + 3) + 4) + 5).
Whilst swapping out foldr for foldl in this scenario gives us the same result, it’s very different in execution.
It ends up working like (1 + (2 + (3 + (4 + (5 + 0))))).
Sort
sort Sorts a list in ascending order by default or with a custom comparator.
listSort::[LispVal]->ThrowsErrorLispVallistSort[Listxs]=casexsof[]->return$List[](Number_:_)->return$List(sortBycompareNumbersxs)(String_:_)->return$List(sortBycompareStringsxs)_->throwError$TypeMismatch"Cannot sort mixed types"(Listxs)wherecompareNumbers(Numbera)(Numberb)=compareabcompareStrings(Stringa)(Stringb)=compareablistSort[Lambdaparamsbodyclosure,Listxs]=casexsof[]->return$List[]_->throwError$TypeMismatch"Custom sorting requires ThrowsErrorIO"(Listxs)-- If you later want custom sorting, you'd need `ThrowsErrorIO`listSortargs=throwError$TypeMismatch"Expected a list (optionally with a comparator function)"(Listargs)
Example
(sort'(53814));; => (1 3 4 5 8)
Optionally, the implementation allows you to specify a predicate to control the sort.
String Manipulation
Strings are now treated as lists of characters in our Lisp. This can sometimes make things easier to work with when
dealing with strings.
Example
We can convert a string into a list (and therefore operate on it like it’s a list) by using string->list.
listToString::[LispVal]->ThrowsErrorLispVallistToString[Listchars]=casemapMextractCharcharsofRightstrList->return$String(concatstrList)Lefterr->throwErrorerrwhereextractChar(String[c])=Right[c]extractCharinvalid=Left$TypeMismatch"Expected a list of single-character strings"invalidlistToStringargs=throwError$NumArgs1args
(list->string'("h""e""l""l""o"));; => "hello"
Conclusion
We now how have higher order functions and lambda functions controlling our list processing. Stay tuned for further
updates as we add more features to our Lisp.
In the previous post we added
conditionals to our basic Lisp interpreter. Now, it’s time to introduce list manipulation – one of Lisp’s most
fundamental features.
This update brings support for:
Pairs (cons), first element (car), and rest (cdr)
List predicates (null?)
Common list operations (append, length, reverse)
Proper parsing of quoted lists ('(...))
Pairs and Lists
In Lisp, lists are built from pairs (cons cells). Each pair contains a head (car) and a tail (cdr). We’ll add Pair
into our LispVal data type:
(car and cdr)[https://en.wikipedia.org/wiki/CAR_and_CDR] are list primitives that allow you to return the first or
second component of a pair.
The expression (car (cons x y)) evaluates to x, and (cdr (const x y)) evaluates to y.
car::[LispVal]->ThrowsErrorLispValcar[List(x:_)]=returnxcar[Pairx_]=returnxcar[List[]]=throwError$TypeMismatch"Cannot take car of empty list"(List[])car[arg]=throwError$TypeMismatch"Expected a pair or list"argcarargs=throwError$NumArgs1argscdr::[LispVal]->ThrowsErrorLispValcdr[List(_:xs)]=return$Listxscdr[Pair_y]=returnycdr[List[]]=throwError$TypeMismatch"Cannot take cdr of empty list"(List[])cdr[arg]=throwError$TypeMismatch"Expected a pair or list"argcdrargs=throwError$NumArgs1args
We can now uwe these functions to work with our lists and pairs:
Going a little bit further now, we can easily implement append, length, and reverse.
listAppend::[LispVal]->ThrowsErrorLispVallistAppend[Listxs,Listys]=return$List(xs++ys)listAppend[Listxs,y]=return$List(xs++[y])listAppend[x,Listys]=return$List([x]++ys)listAppendargs=throwError$TypeMismatch"Expected two lists or a list and an element"(Listargs)listLength::[LispVal]->ThrowsErrorLispVallistLength[Listxs]=return$Number(toInteger(lengthxs))listLength[arg]=throwError$TypeMismatch"Expected a list"arglistLengthargs=throwError$NumArgs1argslistReverse::[LispVal]->ThrowsErrorLispVallistReverse[Listxs]=return$List(reversexs)listReverse[arg]=throwError$TypeMismatch"Expected a list"arglistReverseargs=throwError$NumArgs1args
These functions allow us to perform some more interesting processing of our lists:
(append'(12)'(34));; (1 2 3 4)(append'(ab)'c);; (a b c)(append'a'(bc));; (a b c)(length'(12345));; 5(length'());; 0(reverse'(12345));; (5 4 3 2 1)(reverse'());; ()
Quoted Lists
Finally, Lisp allows shorthand notation for quoting lists. For example, '(1 2 3) is equivalent to (quote (1 2 3)).
Our Lisp interpreter is now becoming a little more sophisticated. List processing is so fundamental to how Lisp operates
that we needed to get this implemented as soon as possible. The code for this particular article is available up on GitHub
to pull down and take a look at.
In our previous post, we introduced
persistent variables into our Lisp interpreter, making it possible to store and retrieve values across expressions.
Now, it’s time to make our Lisp smarter by adding conditionals and logic.
In this post, we’ll extend our interpreter with:
if expressions
Boolean logic (and, or, xor, not)
String support for conditionals
Expanded numeric comparisons (<=, >=)
By the end of this post, you’ll be able to write real conditional logic in our Lisp, compare both numbers and strings,
and use logical expressions effectively.
Adding if Statements
We start with the classic Lisp conditional expression:
evalenv(List[Atom"if",condition,thenExpr,elseExpr])=doresult<-evalenvconditioncaseresultofBoolTrue->returnthenExpr-- Return without evaluating againBoolFalse->returnelseExpr-- Return without evaluating again_->throwError$TypeMismatch"Expected boolean in if condition"result
Now, we’ll add some boolean operators that are standard in conditionals:
(and ...) → Returns #t if all values are #t.
(or ...) → Returns #t if at least one value is #t.
(xor ...) → Returns #t if exactly one value is #t.
(not x) → Returns #t if x is #f, otherwise returns #f.
These functions get added to Eval.hs:
booleanAnd,booleanOr,booleanXor::[LispVal]->ThrowsErrorLispValbooleanAndargs=return$Bool(allisTruthyargs)-- Returns true only if all args are truebooleanOrargs=return$Bool(anyisTruthyargs)-- Returns true if at least one arg is truebooleanXorargs=letcountTrue=length(filterisTruthyargs)inreturn$Bool(countTrue==1)-- True if exactly one is truenotFunc::[LispVal]->ThrowsErrorLispValnotFunc[Boolb]=return$Bool(notb)-- Negates the booleannotFunc[val]=throwError$TypeMismatch"Expected boolean"valnotFuncargs=throwError$NumArgs1argsisTruthy::LispVal->BoolisTruthy(BoolFalse)=False-- Only #f is falseisTruthy_=True-- Everything else is true
These functions get added as primitives to our Lisp:
Before this point, our Lisp has been very number based. Strings haven’t really seen much attention as our focus has
been on putting together basic functionality first. With conditionals being added into our system, it’s time to give
strings a little bit of attention.
First job is to expand = to also support strings.
numericEquals[Numbera,Numberb]=return$Bool(a==b)numericEquals[Stringa,Stringb]=return$Bool(a==b)-- Added string supportnumericEqualsargs=throwError$TypeMismatch"Expected numbers or strings"(Listargs)
Testing
We can see this in action now with a string variable:
λ> (define name "Joe")
"Joe"
λ> (if (= name "Joe") "yes" "no")
"yes"
λ> (if (= name "Alice") "yes" "no")
"no"
More Numeric Comparators
To round out all of our comparison operators, we throw in implementations for <= and >=.
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:
typeEnv=MapStringLispVal
This meant that variable bindings were immutable and couldn’t persist across expressions.
We changed Env to an IORef (Map String LispVal), making it mutable:
typeEnv=IORef(MapStringLispVal)
We added nullEnv to create an empty environment:
nullEnv::IOEnvnullEnv=newIORefMap.empty
Why?
This allows variables to persist across expressions.
Future changes (like set! for modifying variables) require mutability.
IORef enables 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=doputStrLn"Welcome to Mini Lisp (Haskell)"replprimitiveEnv
We now pass it in as a value. Note that the underlying types have changed.
main::IO()main=doenv<-primitiveEnv-- Create a new environmentputStrLn"Welcome to Mini Lisp (Haskell)"replenv
Why?
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: