Table of Contents >> Show >> Hide
- Table of Contents
- What TinyLisp Is (and What It Isn’t)
- Why Lisp Fits in Tiny Spaces
- The REPL Pipeline: Read → Eval → Print
- Eval/Apply: The Engine That Won’t Die
- Data Representation: NaN Boxing in Plain English
- Cells, Cons, and a Small Garbage Collector
- Tail Calls: Saving the Stack on Tiny Machines
- Practical Examples You Can Type Into the REPL
- How to Extend TinyLisp Without Losing Your Mind
- Tradeoffs: The Price of Being Tiny
- Hands-On Experiences: Lessons From Building “TinyLisp-Like” Interpreters (Extra )
- Conclusion
There are two kinds of programmers: the ones who see “a Lisp interpreter in 99 lines of C” and say “cute,” and the ones who immediately whisper, “Show me the trick.” TinyLisp is for bothbecause yes, the line count is a flex, but it’s also a surprisingly sharp lesson in how Lisp works when you peel away everything that isn’t essential.
This article breaks down what’s really going on inside TinyLisp: how it reads S-expressions, how the classic eval/apply loop gives you a real interpreter, why NaN boxing lets “one type” pretend to be many, and how you can extend the interpreter without turning your elegant 99-line haiku into a 5,000-line epic poem that nobody asked for.
What TinyLisp Is (and What It Isn’t)
TinyLisp is a minimal Lisp interpreter in C designed to demonstrate something that sounds impossible until you see it: you can implement a recognizable Lisp with a reader, an evaluator, a working REPL, basic scoping, and memory managementwithout writing a small novel.
It’s important to frame it correctly: TinyLisp isn’t trying to replace production-grade Lisps or Schemes. It’s doing what the best “tiny language” projects doteaching you the core mechanics with as little ceremony as possible. Think of it like a microscope slide: you’re not building a zoo, you’re trying to see the cells.
And yes, the 99-line constraint matters. Constraints are honest. They force the design to reveal itself. Every extra feature has to fight for oxygenso you learn what a Lisp needs to feel like Lisp, and what’s just nice-to-have frosting.
Why Lisp Fits in Tiny Spaces
Lisp has a “cheat code” that many languages don’t: its surface syntax is basically the same shape as its internal structure. In Lisp, code is made of lists, and lists are made of pairs. That means parsing can be simpler, and manipulation becomes wonderfully uniform.
Even better: Lisp can be built from a very small core of primitives. Historically, you can get very far with list operations (the classic “car/cdr/cons” family), a way to control evaluation (quote), a way to branch (cond/if), and a way to make functions (lambda). Everything else can be layered on top.
Why this matters for a tiny interpreter
When your language has:
- Uniform structure (S-expressions): your syntax tree is basically already there.
- A small semantic core: fewer special cases in the evaluator.
- First-class functions: the evaluator becomes the language’s beating heart.
TinyLisp leans into these strengths. The goal isn’t to implement every convenience feature you’ve ever usedit’s to implement the minimum that makes Lisp feel like Lisp: symbolic expressions, evaluation rules, environments, and a REPL you can actually play with.
The REPL Pipeline: Read → Eval → Print
A Lisp REPL is deceptively simple: it reads an expression, evaluates it, prints the result, and repeats until you stop it (or until you ask it to compute the 40,000th Fibonacci number without tail calls and it takes a nap).
What makes Lisp interpreters approachable is that the REPL reflects the interpreter’s architecture:
- Read: convert characters into an internal representation (usually lists, symbols, numbers).
- Eval: compute the meaning of that representation in an environment.
- Print: convert the result back into readable Lisp syntax.
If you’ve ever built a calculator parser, you’ve already done the emotional version of “Read → Eval.” Lisp just makes the data structure obvious: nested parentheses become nested lists.
Reader basics: why S-expressions are friendly
Most languages need an elaborate grammar just to decide whether something is a statement, an expression, a block, a declaration, or a mildly threatening punctuation mark. Lisp’s reader can be much simpler: parentheses define structure, and atoms (numbers, symbols) fill in the leaves.
In practice, that means your “parser” can often be a token splitter plus a recursive function that builds lists. TinyLisp takes advantage of that simplicitybecause nothing keeps an interpreter small like not writing an entire parser generator by accident.
Eval/Apply: The Engine That Won’t Die
If Lisp interpreters had a “classic rock” playlist, eval and apply would be the guitar riff everyone recognizes within three seconds.
Conceptually:
- eval answers: “What does this expression mean in this environment?”
- apply answers: “How do I run this function on these argument values?”
What eval usually does (the practical version)
A small Lisp evaluator typically classifies expressions into a few buckets:
- Self-evaluating: numbers evaluate to themselves.
- Symbols: look up the value in the environment.
- Special forms (like
quote,if,lambda,define): each has custom evaluation rules. - Applications: evaluate the operator to a function, evaluate the arguments, then apply.
Why “special forms” are the whole game
The biggest lesson TinyLisp teaches is this: most of a language is not “features,” it’s evaluation rules. A normal function evaluates all its arguments before running. A special form gets to bend that rulelike if evaluating only one branch, or quote returning data without evaluation.
Once you can implement a few special forms cleanly, the rest of Lisp becomes an exercise in building libraries in Lisp itself. That’s not just neatit’s the reason Lisp can feel so “small” while still being expressive.
Data Representation: NaN Boxing in Plain English
TinyLisp’s headline trick isn’t just clever evaluationit’s how it represents values compactly. This is where NaN boxing enters the chat, wearing sunglasses indoors.
The problem: dynamically typed values need a uniform shape
In C, an int is an int. A pointer is a pointer. The compiler knows what everything is. But in a dynamic language, a value might be a number, a symbol, a pair (cons cell), a function, or “nil,” and you want to pass it around as a single type.
Many interpreters use tagged unions (a struct with a type field plus a payload). That’s clear and robustbut it costs memory. TinyLisp is trying to live on a budget so tight it checks the couch cushions for spare bytes.
The idea: use unused bit patterns inside floating-point NaNs
IEEE 754 doubles reserve a huge range of bit patterns for NaN (“Not a Number”). CPUs treat these as NaN and, for many operations, don’t care about the payload bits. NaN boxing uses those payload bits to store “this is actually a pointer” or “this is actually a special value,” while leaving normal doubles untouched for numeric values.
The practical payoff: you can store numbers and non-numbers in a single 64-bit slot, and type-check by inspecting bits. That makes value passing cheap and often cache-friendlyperfect for small interpreters and constrained memory.
Important caveats (because computers love edge cases)
- Portability: NaN boxing assumes details about floating-point representation and pointer sizes. It’s common in runtimes, but it is still a “know what you’re doing” optimization.
- Debuggability: When your “number” might actually be a pointer in disguise, naive print statements can become performance art.
- Future hardware: If pointer widths expand or platforms change NaN payload behavior, you may need to revisit assumptions.
Still, as a learning tool, NaN boxing is gold: it forces you to think like a runtime implementerabout bits, about tags, and about the tradeoffs between elegance and survival.
Cells, Cons, and a Small Garbage Collector
Lisp lives on lists, and lists live on cons cells. A cons cell is just a pair: (car . cdr). Build enough of them and you can represent lists, trees, and half of modern civilization (the other half is spreadsheets, which Lisp politely ignores).
A tiny heap is usually a fixed pool
In a small interpreter, memory is often a preallocated array of “cells.” When you need a new cons cell, you grab the next free slot. When you run out, you reclaim unused cells.
Where garbage collection comes in
Tiny interpreters rarely start with a fancy GC. They start with something simple:
- Mark the cells you can still reach from “roots” (like the current environment and evaluation stack).
- Sweep everything else back into the free list.
Even a simple collector teaches the key truth: memory management isn’t a side questit’s part of the language. If your language builds lists freely, you must reclaim them or you’ll eventually stare into the abyss of “out of cells.”
Why the GC interacts with representation
Value representation choices (like NaN boxing or tagged pointers) affect how you find references during GC. The collector needs to know, for every value, whether it’s a pointer into the heap or an immediate (like a number). This is why “tiny” runtime designs often look like puzzles: every piece must fit with every other piece.
Tail Calls: Saving the Stack on Tiny Machines
Tail-call optimization (TCO) is one of those topics that sounds academic until you try to run recursion on a small stack and discover your program can, in fact, fall down the stairs.
In Lisp, recursion is not optionallots of idioms depend on it. If your interpreter can optimize tail calls, certain recursive functions can run in constant stack space. That matters on constrained systems and also matters for performance and correctness in general.
TinyLisp includes tail-call-optimized variants, which is a strong signal about intent: it’s not only about “can we implement Lisp,” but “can we implement Lisp in a way that behaves like Lisp programmers expect when they write recursive code?”
Practical Examples You Can Type Into the REPL
The easiest way to understand a tiny interpreter is to run programs that stress each subsystem: the reader, evaluation rules, environments, and the heap. Here are examples that highlight each piece (and are short enough to type without developing a tragic relationship with your backspace key).
1) Arithmetic and nesting
This checks the reader (nested lists) and the evaluator (function application order).
2) Quoting: code as data
If this returns a list rather than trying to “call” 1 as a function, your special-form handling is working.
3) Conditionals: only one branch should run
If the interpreter eagerly evaluates both branches, you’ll discover it immediatelyand dramatically.
4) Defining a function (and testing environments)
5) A classic recursive function (tail-call-friendly version recommended)
This is a good “TCO detector.” If your interpreter optimizes tail calls, you can push n higher without stack pain.
6) Closures: the moment your interpreter becomes “real”
If this works, your interpreter is properly capturing environments and creating function values with lexical scope. That’s not a toy anymorethat’s a language.
How to Extend TinyLisp Without Losing Your Mind
Once you understand the core loop, extending TinyLisp becomes a controlled experiment instead of a chaotic “why is everything on fire?” situation.
Start by adding one primitive
Pick something smalllike a list utility or a numeric operatorand implement it as a builtin primitive in C. The goal is to learn the full path:
- How arguments are evaluated (or not evaluated, if you’re making a special form).
- How values are represented.
- How new objects are allocated and traced by the GC.
Then add one special form
Special forms are where languages become languages. Adding something like and/or (with short-circuiting), or let (as syntactic sugar over lambdas), teaches you how syntax transforms interact with evaluation rules.
Finally, consider “macro power” carefully
Macros are the superpower people associate with Lisp, but they also demand a clear model of code-as-data and expansion time vs run time. In a tiny interpreter, macros are totally possiblebut you want to add them when you can already explain your evaluator to a rubber duck without the duck calling HR.
Tradeoffs: The Price of Being Tiny
TinyLisp teaches real interpreter techniques, but it also highlights the compromises you make when you compress code:
Readability vs. minimalism
A 99-line interpreter is a tight poem. Poems aren’t always the easiest thing to maintain. If your goal is education, you’ll often want a commented or expanded version as your “primary” reference and treat the 99-line version as the distilled essence.
Performance vs. portability
NaN boxing can be fast and compact, but it relies on assumptions about floating-point representation and pointer usage. If you want maximal portability, you might choose a more explicit tagged union representation.
Features vs. simplicity
File loading, advanced numeric types, strings, macros, exceptionsthese are all doable, but each one adds pressure on the implementation. The art is knowing when you’re extending the language and when you’re accidentally building a second operating system.
Hands-On Experiences: Lessons From Building “TinyLisp-Like” Interpreters (Extra )
The first time you try to build something TinyLisp-adjacent, you’ll probably assume the hard part is “writing eval.” That’s adorablelike thinking the hard part of cooking is turning on the stove. The hard part is everything that touches everything else: representation choices, environments, and the million tiny decisions that become “the language.”
My earliest “tiny Lisp” attempt failed in the most classic way: the reader worked, the evaluator worked, but together they formed a beautiful machine for producing nonsense. I’d parse (+ 1 2) into a nested structure and then evaluate it… only to discover I’d represented symbols inconsistently. Sometimes a symbol was a string. Sometimes it was an interned pointer. Sometimes it was “whatever was convenient five minutes ago.” The interpreter wasn’t broken; my mental model was. That’s the first lesson: pick a representation and commit to it, because your environment lookups, equality checks, printing, and GC will all depend on it.
The second lesson arrived when I implemented if as “evaluate all arguments, then choose one.” You can guess how long it took before I wrote an example like (if true 1 (/ 1 0)) and watched my interpreter faceplant. In tiny Lisps, special forms are the secret backbone. They’re not “features.” They’re the rulebook that defines what evaluation even means. When you get special forms right, your language suddenly feels coherent.
Then came closuresthe moment when a toy interpreter either becomes real or becomes a haunted house. I built make-adder and got the wrong answer, consistently, in a way that felt personal. The bug was scoping: I had implemented dynamic scoping by accident. It “worked” until it didn’t. Fixing it taught me why lexical scoping is fundamentally about packaging a function together with the environment in which it was created. In other words, a closure isn’t magic; it’s a data structure. Once you understand that, you stop fearing it.
Garbage collection was the next rite of passage. At first I tried to avoid it (classic mistake). I used a fixed pool of cons cells and thought I could “just not allocate too much.” That’s like buying a goldfish and promising it you’ll “just not add water.” Lisp allocates. Lists are its oxygen. Even a simple mark-and-sweep collector felt like a victory because it turned random crashes into predictable behavior: when the heap fills, the collector runs; if live memory still doesn’t fit, you get a clean failure. Predictability is an underrated feature.
Finally, I experimented with NaN boxing. It felt like cheatingin the best way. Suddenly, values were one word, type tests were bit masks, and the runtime looked clever enough to wear a tiny tuxedo. But it also raised the stakes: debugging required tools and discipline, because printing a “value” wasn’t always printing a number. The big takeaway was balance: use cleverness when it buys you something concrete (memory, speed, simplicity), and keep a “boring mode” representation nearby when you need clarity.
The weirdly joyful part? After you build even a small slice of Lisp, you start recognizing interpreter patterns everywhere: in configuration languages, in template engines, in query DSLs. TinyLisp isn’t just a stunt; it’s a compact doorway into how languages are builtand once you walk through it, you can’t unsee the machinery (in a good way).
Conclusion
TinyLisp proves a point that’s both practical and philosophical: a programming language is less about syntax and more about evaluation rules, representation, and the environment model that ties names to values. In a tiny interpreter, every one of those ideas is visibleno framework fog, no abstraction smog.
If you want to learn language implementation, TinyLisp is a great stepping stone: small enough to read in one sitting, rich enough to teach you how a real Lisp breathes. Treat it like a lab specimenpoke it, extend it, break it, fix itand you’ll come away understanding not just Lisp, but interpreters in general.