00 β Overview
The arc in one page
MicroHs began on 2023-05-25 as two files: Lam.hs, 227 lines of Haskell
implementing a lambda-calculus-to-combinator compiler, and eval.c, 539
lines of C that evaluate the resulting combinator graph. That first commit,
ccb31388 ("First version"), is small enough to read in one sitting. There
is no parser worth the name, no error handling, no modules β just enough
machinery to compile a lambda expression to SKI-style combinators and run
it. This is the whole point of starting here: you can hold the entire
system in your head.
From there the author, Lennart Augustsson, grew the two files into a real
compiler. Four days in, commit a9178695 is bluntly titled "Make a real
compiler." β the toy evaluator gets an actual parser, a proper node
representation, and an optimiser. Growth from here is not linear or tidy.
The next day, commit 17587b2d is called "Hack around a
problem." β a fix that works, described as exactly what it is, not dressed
up as a design decision. That honesty runs through the whole history:
6f08f029, dated 2023-07-03, reads "Running IO still doesn't work. :(" and
is committed anyway, because committing broken states is how this workflow
works.
Over the next week or so the language fills out: let, infix operators,
tuples, lists, string and character literals, do-notation, modules, a
Prelude, list comprehensions β the surface syntax converges on something
recognizably Haskell, even though the back end is still combinator graph
reduction on a tiny C runtime. In parallel, the compiler learns to talk to
the outside world (monadic IO, serialization of program state to a file)
and grows a lab-notebook workflow of its own: command-line flags, ad hoc
tests, a first README.
The second half of the story is about the compiler outgrowing GHC. Starting
in July, commits accumulate around making the compiler's own source
compile under itself β commit messages like "Make it compile with uhs" and
"Make Desugar compile with uhs" appear repeatedly as each module is ported.
On 2023-07-20, 298c9824 renames uhs to mhs, and within the same
window the compiler successfully compiles itself. The last stretch, through
early August, adds a Hindley-Milner type checker and gets the self-hosted
compiler bootstrapping with type checking β a38c64ce, "Now bootstraps
with type checking.", 2023-08-10, is where this account stops.
That is 232 commits by one author over 77 days, and this repo's git history is the primary source β nothing here is a retelling from memory. The reader contract for the rest of this book: read chapters 01 through 09 in order (each assumes the state left by the one before it), and actually run the "Try it" blocks in a throwaway worktree rather than just reading past them. The value of this material is in watching working code evolve commit by commit, not in a summary of what changed.
Timeline
| Ch | Dates (2023) | Commit range | Chapter | Summary |
|---|---|---|---|---|
| 01 | 05-25 | ccb31388 + 21168890 | 01-the-seed.md | The seed: Lam.hs + eval.c |
| 02 | 05-26 β 05-30 | 21168890..b56ba28b | 02-a-real-compiler.md | A real compiler: parser, node repr, optimiser |
| 03 | 05-30 β 06-01 | b56ba28b..95ff6a34 | 03-growing-the-language.md | Growing the language: let, infix, tuples, lists, strings, do |
| 04 | 06-01 β 06-02 | 95ff6a34..8c5fbb1a | 04-talking-to-the-world.md | Talking to the world: monadic IO, serialization |
| 05 | 06-02 | 8c5fbb1a..8018eec8 | 05-lab-notebook-workflow.md | Lab-notebook workflow: flags, tests, first README |
| 06 | 06-02 β 06-07 | 8018eec8..41d6a7e8 | 06-becoming-haskell.md | Becoming Haskell: modules, Prelude, comprehensions |
| 07 | 07-01 β 07-18 | 41d6a7e8..a824a175 | 07-the-self-hosting-push.md | The self-hosting push: "compile with uhs" |
| 08 | 07-18 β 07-20 | a824a175..298c9824 | 08-bootstrap.md | Bootstrap: the compiler compiles itself |
| 09 | 07-20 β 08-10 | 298c9824..a38c64ce | 09-types-at-last.md | Types at last: HM checker, typed bootstrap |
Note the jump between chapters 06 and 07: the last commit of chapter 06,
41d6a7e8, is dated 2023-06-07; the first commit of chapter 07,
44004020, is dated 2023-07-01. Nothing in this repo's history explains
the 24-day gap β the log simply resumes. Chapter boundaries in this book
follow the thematic shape of the work, not a fixed cadence.
How to use this branch
Every chapter tells you which commit range to look at and what to build and run there. The convention, used throughout this book, is to check out a commit into its own worktree rather than disturbing your main clone:
git worktree add /tmp/mhs-<shortsha> <shortsha> && cd /tmp/mhs-<shortsha> && nix develop $MICROHS -c make <target>
$MICROHS is the absolute path to your clone of this repo, set once as
described in the top-level README.md. When you're done with a commit,
remove its worktree:
git worktree remove --force /tmp/mhs-<shortsha>
Reading the history directly, rather than trusting a paraphrase of it, is the whole method of this book. As a worked example, here is the actual first commit:
$ git show --stat ccb31388
.gitignore | 6 +
Lam.hs | 227 ++++++++++++++++++++++++++
Makefile | 14 ++
eval.c | 539 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 786 insertions(+)
Four files, 786 lines, one commit. That is the entire system chapter 01
starts from. When a chapter later claims a commit did something, you can
run git show <sha> yourself and check.
What this is not
This is not a Haskell tutorial β it assumes you can already read Haskell
comfortably. It is not a reference for modern MicroHs; the project kept
going long after this account ends, and none of that later history is in
scope here. This book is a close reading of one specific, bounded stretch
of git history: ccb31388 (2023-05-25) through a38c64ce ("Now bootstraps
with type checking.", 2023-08-10). It stops there, deliberately.
01 β The seed
Where we are
Nothing. This is commit ccb31388, "First version," 2023-05-25 19:33:17
+0200 β the first commit in the repository. There is no prior state to
assume; everything in this chapter is read straight out of one commit.
Four files land together: .gitignore, Lam.hs (227 lines of Haskell),
Makefile (14 lines), and eval.c (539 lines of C) β 786 lines total (see
00-overview.md for the commit stat). What the
code shows, without reading further intent into it than the code
justifies: the author wants a compiler from a small Haskell-like language
down to combinators, plus a runtime tiny enough to fit in one C file, and
he wants the two halves working together by the end of the sitting. There
is no parser, no type checker, no module system, no error handling worth
the name β just enough machinery to compile one hardcoded lambda term and
run it.
What happened
The commit is a two-file architecture, plus a third artifact β a text file β that sits between them.
Lam.hs: compiler, no parser
Lam.hs defines the Exp AST (Var, App, Lam, Int, Comb,
Prim β see aux/lambda-calculus-and-ski.md
for what each constructor is for), a bracket-abstraction compiler that
turns Exp terms with Var/Lam into pure combinator terms (see
aux/bracket-abstraction.md for the
derivation), a Kiselyov-style improve pass that shrinks the combinator
output, a pretty-printer, and β at the very bottom of the file β one
hardcoded program, eNfib, computing nfib 35. There is no readFile,
no getArgs parsing beyond what the Makefile supplies, no lexer or
parser for the source language at all. Every example in this commit is
Haskell source built directly as an Exp value, by hand, inside Lam.hs
itself.
The whole pipeline is small enough to fit in the Makefile, quoted here
in full (ccb31388:Makefile):
test: eval nfib.comb
./eval nfib.comb
eval: eval.c
gcc -O3 eval.c -o eval
nfib.comb: Lam
./Lam > nfib.comb
Lam: Lam.hs
ghc Lam.hs -o Lam
clean:
rm -f *.hi *.o eval Lam
Read bottom to top and it's the whole story: ghc compiles Lam.hs into
a Lam binary; running Lam and redirecting its stdout produces
nfib.comb, a text file; gcc compiles eval.c into an eval binary;
running eval on nfib.comb is the actual program. Two independent
toolchains (GHC and a C compiler) meet at one text file.
The compiler itself is compile, dispatching to abstract on every
Lam. Quoted whole (ccb31388:Lam.hs, lines 97β101 β compile above it
is one line per constructor and is covered by
aux/bracket-abstraction.md section 1):
abstract :: Ident -> Exp -> Exp
abstract x e@(Var y) | x == y = CI
abstract x (App f a) = App2 CS (abstract x f) (abstract x a)
abstract x (Lam y e) = abstract x $ improveC $ abstract y e
abstract x e = App CK e
Four equations, no recursion into a name table, no environment β by
design; the aux doc walks through why. Lam.hs never grows past 227
lines in this commit, and most of that length is the pretty-printer, the
improve/improveC/improveT rewrite rules
(aux/bracket-abstraction.md section 3), the
reference reducer reduce/redOne, and a small combinator-building DSL
(ToLam, $$, comp) used to write eNfib in a readable style.
eval.c: the runtime
eval.c is a self-contained C program: a fixed-size cell heap, a switch
on node tags implementing every combinator reduction rule, an explicit
spine stack, and a real mark-and-scan garbage collector, with indirection
nodes used for in-place update. None of that is summarized further here
β aux/graph-reduction-and-laziness.md
covers the cell representation, the spine walk, sharing, and the GC in
detail with line citations, and
aux/y-combinator-and-recursion.md
covers how Y builds a genuinely cyclic graph for recursion. Both are
verified against this exact commit; there's no reason to duplicate them
here.
The .comb file: a text interface you can read
nfib.comb β the file Lam writes and eval reads β is plain text, one
line, human-legible combinator syntax. Generated fresh in a worktree at
this commit, it reads (whole file; it genuinely is one line):
((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)
That is not a compact binary blob β it's S/K/I/B/C/S'/C'
and friends, parenthesized left-to-right, printed by pretty() in
eval.c and parsed back by parse() in the same file (both discussed in
the aux doc). Choosing a plain-text interchange format between two
otherwise-unrelated programs is itself a decision worth noticing: it
means you can cat the intermediate representation, diff two versions
of it, or hand-write a test case, all without either compiler.
The commented-out block in main
main is not just the eNfib-and-print line that ships. ccb31388:Lam.hs,
lines 8β30, is a {- ... -}-commented block sitting directly above the
line that runs:
main :: IO ()
main = do
{-
let c1 = compile e1
c1' = improve c1
r1' = reduce (App c1 (Var "k"))
r1'' = reduce (App c1 (Int 5))
pp e1
pp c1
...
pp $ improve $ compile $ App (Lam "x" (App2 (Var "x") (Int 10) (Int 2))) (Prim "-")
-}
putStrLn $ toStringP $ eNfib `App` 35
That block is 21 lines of pp/let calls exercising e1, e2, e3
(three small hand-written test terms defined a few lines below main) β
compile them, improve them, reduce them, print every intermediate
result. It's not dead code in the sense of being obsolete; it's a REPL
session that got pasted into main and then commented out rather than
deleted, so it's still there, still correct, and still runnable β see
the "Try it" section.
Commit 2: "Clean more"
21168890, authored (and committed) 30 seconds after ccb31388 β same
day, same two timestamps a half-minute apart β changes exactly one line
of the Makefile:
clean:
- rm -f *.hi *.o eval Lam
+ rm -f *.hi *.o eval Lam *.comb
That's the entire diff. make clean didn't remove the generated
nfib.comb; thirty seconds after committing the first version, that got
noticed and fixed. Nothing else in the commit changes.
The B' gap
One loose end ships in this commit and stays: eval.c declares a BB
tag (printed as B'), the parser accepts B' syntax, and pretty()
knows how to print it β but the eval switch has no case BB:, so a
B' node reaching the runtime crashes with bad tag 12. It happens not
to matter, because improveC never emits B' in this commit, even
though Lam.hs's own redOne implements the rule correctly. See
aux/lambda-calculus-and-ski.md section
5 for the verified crash and
aux/bracket-abstraction.md section 4 for
why compile/improve never trigger it.
Why it matters
Everything the rest of this book covers is one of these two files, or the
format between them, growing up. The parser that arrives four days later
(chapter 02) replaces the hand-built Exp values in main; the improved
node representation and optimiser extend eval.c's tag switch and
improve's rewrite rules; the later self-hosting push replaces Lam.hs
itself, module by module, without ever changing the shape of the
pipeline it's part of. The .comb-file handoff β compile to a text
format, then run the text format with a separate program β is the same
shape the bootstrap uses two months later, just with a much bigger
compiler on one side. Starting with a hardcoded program instead of a
parser is exactly what makes day one end with something that runs
end-to-end: nfib 35, for real, computed by a graph-reduction machine
that didn't exist that morning.
Notebook margin
Habits visible already, at commit 1 and 2:
- Exploratory code is commented out, not deleted. The
e1/e2/e3scratch block inmainstays in the file, fully functional, just not running by default. - "Clean more" lands 30 seconds after "First version" β the same
session, immediately fixing a
make cleanoversight rather than batching cleanup for later. - The interface between the Haskell half and the C half is a text file you can read with your eyes, not a serialized binary format.
- The commit ships with a known-crashing case (
B') rather than waiting until every combinator is wired up end-to-end. It doesn't block shipping because it isn't reachable yet.
Try it
Convention used throughout this book: check out the commit into its own worktree.
git worktree add /tmp/mhs-ccb31388 ccb31388
cd /tmp/mhs-ccb31388
nix develop $MICROHS -c make test
($MICROHS is the absolute path to your clone, set once per the
top-level README.md.) Expect a GHC compile with one benign warning,
then the program output:
Lam.hs:211:13: warning: [GHC-63394] [-Wx-partial]
In the use of βheadβ
...
./eval nfib.comb
((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)
29860703
node size=24, heap size=100000
1045124618 reductions, 1944 GCs
29860703 is nfib 35. The warning is about Data.List.\\'s use of
partial head at line 211 (a version of \\ used to pick a fresh
variable name) β harmless here since the input lists it's subtracting
from are always non-empty in this commit's usage, but a real partiality
GHC is right to flag.
Exercise 1: run the commented-out experiment
Open Lam.hs and delete just the two comment markers: the {- on line
8 and the -} on line 30. Leave everything between them untouched. Then:
nix develop $MICROHS -c make Lam
nix develop $MICROHS -c ./Lam
This prints 16 lines β the block's own pp/putStrLn calls followed by
the usual nfib term. The first three lines are compile/improve
applied to e1 = \x. + x x:
(\x. + x x)
S (S (K +) I) I
S + I
β matching aux/bracket-abstraction.md
section 2's hand-traced derivation of compile e1 and section 3's
improve (compile e1) exactly, but now confirmed a second, independent
way (the block computes them directly in main, not via ghci).
Further down (line 14 of the run), pp $ improve $ compile e3 prints:
T
β the same answer aux/bracket-abstraction.md
section 5 asks you to predict by hand before checking in ghci; here
it's the same computation, done inline in the compiled Lam binary
instead. (Put the {-/-} back before moving on β the next two
exercises regenerate nfib.comb, and this block's extra output would
land in that file too if left uncommented, since nfib.comb is just
Lam's redirected stdout.)
Exercise 2: run a smaller nfib
Change line 31 from eNfib \App` 35toeNfib `App` 25. Before rebuilding, predict the answer from eNfib's own recurrence (quoted in [aux/y-combinator-and-recursion.md](aux/y-combinator-and-recursion.md) section 3): nfib n = 1ifn < 2, else nfib(n-1) + nfib(n-2) + 1`.
Expanding it from the base case:
nfib: 1, 1, 3, 5, 9, 15, 25, 41, 67, 109, 177, 287, 465, 753, 1219,
1973, 3193, 5167, 8361, 13529, 21891, 35421, 57313, 92735,
150049, 242785
(n= 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14
15 16 17 18 19 20 21 22 23
24 25)
So nfib 25 should be 242785. Rebuild and check:
nix develop $MICROHS -c make test
./eval nfib.comb
((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 25)
242785
node size=24, heap size=100000
8497488 reductions, 15 GCs
242785, as predicted β and, worth noting in passing, over two orders
of magnitude fewer reductions than nfib 35 (8,497,488 vs.
1,045,124,618) for a function whose call count roughly follows the
Fibonacci growth rate. Before the next exercise, change line 31 back to
35 and rebuild β editing Lam.hs alone does not regenerate the
nfib.comb sitting on disk, which is still the 25 version:
nix develop $MICROHS -c make test
This should print the original ... 35) term and 29860703 again;
nfib.comb is now back to its baseline content.
Exercise 3: break improveC and watch the term grow
improveC (ccb31388:Lam.hs, lines 106β117) has this case, one of six
special-cased patterns before the catch-all:
(App CS (App CK e1), e2) -> improve (App2 CB e1 e2)
This is the rule that introduces B β see
aux/bracket-abstraction.md section 4 for
why it exists. First, save the current (unbroken) nfib.comb so there's
something to diff against later:
cp nfib.comb nfib.comb.orig
Then delete that one improveC line and rebuild:
nix develop $MICROHS -c make test
./eval nfib.comb
((Y ((C ((S (K C)) ((S (K (S (> 2)))) ((C ((S (K C)) ((S (K (S (K +)))) ((S ((S (K S)) ((S (K (S (K +)))) ((C ((S (K S)) K)) (-' 1))))) ((C ((S (K S)) K)) (-' 2)))))) 1)))) 1)) 35)
29860703
node size=24, heap size=100000
1373651156 reductions, 2542 GCs
Still 29860703 β removing one improve rule doesn't change what the
program computes, only how big the compiled term is and how much work
eval does to reduce it. Measured against the nfib.comb.orig saved
just before the edit:
$ nix develop $MICROHS -c diff nfib.comb.orig nfib.comb
1c1
< ((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)
---
> ((Y ((C ((S (K C)) ((S (K (S (> 2)))) ((C ((S (K C)) ((S (K (S (K +)))) ((S ((S (K S)) ((S (K (S (K +)))) ((C ((S (K S)) K)) (-' 1))))) ((C ((S (K S)) K)) (-' 2)))))) 1)))) 1)) 35)
(diff isn't in this project's own Makefile, but it's on the PATH
nix develop hands you, since the devShell isn't built in isolation
from the host.) The one-line term grows from 102 bytes to 181 β every
B the missing rule would have produced instead shows up as
S (K _) _, spelled out. eval pays for that: 1,373,651,156 reductions
against the original 1,045,124,618 (31% more), and 2,542 GCs against
1,944 (31% more) β one missing rewrite rule, and both the program text
and the runtime cost grow by roughly a third, for a function whose
observable behavior is unchanged.
Clean up when done:
git worktree remove --force /tmp/mhs-ccb31388
02 β A real compiler
Where we are
chapter 01 left two files and a text format between them:
Lam.hs compiles one hand-built Exp value β eNfib, computing nfib 35 β down to combinators, and eval.c runs the result. There is still no
parser. Every example the system can run is Haskell source, but it's
Haskell source written as a literal Exp value inside Lam.hs itself,
not text the compiler reads. Commit 21168890 ("Clean more") is the last
commit before this chapter's range, and it changes one line of the
Makefile. Nothing about the hardcoded-program shape has moved yet.
What happened
Warm-up: 2023-05-26
Three commits land the Friday after the seed, all still inside the old
two-file world (Lam.hs/eval.c), none of them touching the parser that
hasn't been written yet:
54806439 ("Use file handle for printing.") turns eval.c's pretty/
pp functions from hardcoded printf/putchar calls into print(FILE *f, ...)/pp(FILE *f, ...) that take an explicit stream. main still
calls pp(stdout, n) both times, so nothing about the program's visible
output changes β but the function can now be pointed at any file handle,
which is what makes b0c33a8f, 34 minutes later, able to add a
label-parsing path that reads from the same kind of handle it prints to.
6485a341 ("Move some improvements earlier.") reorders Lam.hs's
optimizer without changing its rule set: the "flip arguments to
commutative ops" rewrites ((CC, Prim "+") -> Prim "+" and friends) move
out of improveT's case list and into improveC's, ahead of improveC's
own catch-all. Both passes still exist (improve = improveT . improveC),
but a term now gets flip-normalized during the first traversal instead
of having to wait for the second β more opportunities for the S'/C'
rules just above them in improveC to fire on an already-flipped operand
in one pass instead of two. The commit message names the intent ("move
... earlier"), not this mechanism; that's a pattern this chapter keeps
running into (see "Notebook margin").
b0c33a8f ("Print and parse sharing/cycles.") is the one warm-up commit
worth quoting, because the format it introduces is what chapter 02's own
compiler output uses from a9178695 onward: a way to print a DAG or a
cycle β not just a tree β as flat text. The parser side
(b0c33a8f:eval.c, lines 295β306):
case '_' :
l = (int)parse_int(f); /* The label */
if (shared[l] == NIL) ERR("shared == NIL");
return shared[l];
case ':' :
l = (int)parse_int(f); /* The label */
if (!gobble(f, ' ')) ERR("parse ' '");
if (shared[l] != NIL) ERR("shared != NIL");
shared[l] = alloc_node(IND); /* Must have a placeholder for cycles */
r = parse(f);
INDIR(shared[l]) = r;
return r;
:N <expr> defines shared node N (an indirection placeholder is
allocated before <expr> is parsed, specifically so a cyclic reference
inside <expr> back to N has something to point at); _N refers back
to it. The print side marks any node visited twice during a pre-pass
(find_sharing) as SHARED, then on the real print pass emits :N the
first time a SHARED node is reached and just _N every time after
(b0c33a8f:eval.c, lines 327β333):
if (MARK(n) == PRINTED) {
fprintf(f, "_%d", LABEL(n));
return;
} else if (MARK(n) == SHARED) {
fprintf(f, ":%d ", LABEL(n));
MARK(n) = PRINTED;
}
Nothing in this commit's own test data actually shares a node yet β it's
groundwork. Four days later, 17587b2d's "hack" is the first thing that
puts this label format to real use.
a9178695: "Make a real compiler." (2023-05-29, 16:14)
The pivot. Six new files, 561 lines, git show a9178695 --stat:
Desugar.hs | 31 +++++++++
Exp.hs | 141 ++++++++++++++++++++++++++++++++++++++++
Lam.hs | 6 ++
Main.hs | 14 ++++
Parse.hs | 204 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ParserComb.hs | 165 +++++++++++++++++++++++++++++++++++++++++++++++
ParserComb.hs and Parse.hs are a parser-combinator library and the
grammar built on it β aux/parser-combinators.md
covers both in detail. What matters here is what they accept and what
changes downstream. The surface language at this commit is small: a
module (module Name where plus a block of definitions), function
definitions (f x y = expr), data declarations (data T = C1 t1 t2 | C2), and expressions built from variable/constructor names, integer
literals, application, \x. expr lambdas, case expr of with
constructor-pattern arms, and parenthesized subexpressions β one
representative production (a9178695:Parse.hs, lines 147β150):
pDef :: P Def
pDef =
Data <$> (pKeyword "data" *> pLHS pUIdent <* pSymbol "=") <*> esepBy1 ((,) <$> pUIdent <*> many pAType) (pSymbol "|")
<|> Fcn <$> (pLHS pLIdent <* pSymbol "=") <*> pExpr
Exp.hs is, almost line for line, Lam.hs's own Exp type and
compile/abstract/improve/reduce pipeline
(aux/bracket-abstraction.md covers the
original) copied into its own module β Lam.hs keeps its copy
unmodified and stays the engine behind make test; Exp.hs becomes the
engine behind the new parser-driven path. The two diverge from this
commit on. Desugar.hs bridges Parse.hs's Expr/Def/Module AST to
Exp.hs's Exp: a plain case-to-application rewrite for expressions
(a9178695:Desugar.hs, lines 25β31, quoted whole)β
dsExpr :: Expr -> Exp
dsExpr (EVar i) = Var i
dsExpr (EApp f a) = App (dsExpr f) (dsExpr a)
dsExpr (ELam x e) = Lam x (dsExpr e)
dsExpr (EInt i) = Int i
dsExpr (ECase e as) = apps (dsExpr e) (map dsArm as)
where dsArm ((_, xs), r) = lams xs $ dsExpr r
β and, more interestingly, a data-declaration desugaring that turns each
constructor into a plain function taking its own fields followed by one
continuation argument per constructor and picking its own
(a9178695:Desugar.hs, lines 16β22): dsDef (Data _ cs) = zipWith dsConstr [0..] cs builds, for constructor i out of n, a function of
shape \x1 x2 ... $f0 $f1 ... $f(n-1) -> $fi x1 x2 ... β a Scott
encoding, case analysis compiled straight to application with no
separate pattern-match machinery in the runtime at all. main changes
from printing one hardcoded term to this (a9178695:Main.hs, quoted
whole):
import System.Environment(getArgs)
import Parse
import Desugar
import Exp
main :: IO ()
main = do
[fn] <- getArgs
mdl <- parseDie pTop fn <$> readFile fn
let ds = desugar mdl
ds' = [ (i, improve $ compile e) | (i, e) <- ds ]
mapM_ (\ (i, e) -> putStrLn $ i ++ " = " ++ toString e) ds
mapM_ (\ (i, e) -> putStrLn $ i ++ " = " ++ toString e) ds'
One command-line argument, a file name, no more; the program is read
(readFile fn), parsed (parseDie pTop fn), desugared, and each
top-level definition is compiled and improved independently β this
version doesn't yet try to link the definitions into one runnable
program; it just prints, per name, the naive and the optimized combinator
form. There's no writeFile yet, and no notion of which definition is
the entry point. That gap is exactly what 17587b2d addresses five
commits later.
648aeef0 ("Add another node representation.", 13 minutes after
a9178695) and 71729105 ("Add pair and cons.", 19 seconds after that)
follow the same afternoon; both are described below with the commits
they belong with thematically.
648aeef0: a third node layout for eval.c
Chapter 01's eval.c ran one node representation, NODE_NAIVE β a C
struct with real pointers, selected by #define NODE_NAIVE at the top
of the file β but the file already carried a second, uncompiled-into
option in the same #if/#elif chain, NODE_SPLIT (ccb31388:eval.c,
lines 5β8: the same comment block quoted below, minus its NODE_INDEX
line, which doesn't exist yet). This commit adds a third live option,
NODE_INDEX, and flips the active #define to it (648aeef0:eval.c,
lines 5β10):
/* Node representation:
* NODE_NAIVE all fields in a struct, regular pointers
* NODE_INDEX use 32 bit indices instead of pointers
* NODE_SPLIT split flags, funs, and args
*/
#define NODE_INDEX
NODE_INDEX replaces the two 8-byte pointers inside each cell (FUN/
ARG) with 32-bit array indices into one cells array
(648aeef0:eval.c, lines 51β67, the union body elided β same shape as
NODE_NAIVE's):
#elif defined(NODE_INDEX)
/* Naive node representation with minimal unions */
typedef u_int32_t NODEPTR;
typedef struct node {
enum node_mark mark;
enum node_tag tag;
union {
...
} u;
} node;
#define NIL (-1)
#define HEAPREF(i) (i)
#define MARK(p) cells[p].mark
This is a real, measured effect, not a hypothetical one: chapter 01's
ccb31388 build reports node size=24, heap size=100000 for NODE_NAIVE;
building this chapter's b56ba28b (which keeps NODE_INDEX active) and
running the exact same nfib 35 program through the unmodified Lam.hs
pipeline reports node size=16 β a third smaller, with the reduction
count and GC count both unchanged (1045124618 reductions, 1944 GCs,
identical in both β see "Try it" below for the transcript). Three
representation choices now live side by side in the same #if chain,
NODE_NAIVE and NODE_SPLIT both still compiling, just not selected β
representation experiments happen as edits to one #define, on the
mainline, not as a branch that gets merged later.
9cf1e937 + b4e44ded: "New optimiser." (2023-05-30, 12:46β12:47)
The next session, the day after, starts with the same commit message
twice, forty seconds apart. Read literally, the first one doesn't build:
9cf1e937's entire diff is one line added to Exp.hs,
toStringP (Lbl i e) = ":" ++ show i ++ " " ++ toStringP e β a
pretty-printer case for a constructor, Lbl, that this commit's Exp
type doesn't declare yet (Lbl isn't added to data Exp until
b4e44ded). Building Exp.hs at exactly 9cf1e937 and nowhere else in
the range confirms it:
Exp.hs:129:37: error: [GHC-58481] parse error on input β=β
|
129 | redOne (App4 (Comb "O") x1 x2 f1 f2 = Just $ App2 f2 x1 x2
β not even the error you'd expect from the missing Lbl constructor; a
plain unbalanced-parenthesis typo, inherited unfixed from 71729105
("Add pair and cons.", the previous afternoon), is enough on its own to
stop the file from compiling at all. b4e44ded, the second "New
optimiser." commit, both fixes that syntax error and does the real
rewrite: the old two-pass compile (naive bracket abstraction) then
improve = improveT . improveC (a separate cleanup pass) is replaced by
one pass whose abstraction step calls smart constructors β
cS/cC/cB/cS'/cC' β that apply the same rewrite rules inline,
while building the term, instead of afterward (b4e44ded:Exp.hs, lines
73β100):
abstract :: Ident -> Exp -> Exp
abstract x e@(Var y) | x == y = CI
abstract x (App f a) = cS (abstract x f) (abstract x a)
abstract x (Lam y e) = abstract x $ abstract y e
abstract x e = cK e
cK :: Exp -> Exp
--cK CI = CT
cK e = App CK e
cS :: Exp -> Exp -> Exp
cS (App CK e1) (App CK e2) = cK (App e1 e2) -- S (K e1) (K e2) = K (e1 e2)
cS (App CK e1) CI = e1 -- S (K e1) I = e1
cS (App CK e1) e2 = cB e1 e2 -- S (K e1) e2 = B e1 e2
cS e1 (App CK e2) = cC e1 e2 -- S e1 (K e2) = C e1 e2
cS (App2 CB e1 e2) e3 = cS' e1 e2 e3 -- S (B e1 e2) e3 = S' e1 e2 e3
cS e1 e2 = App2 CS e1 e2
cC :: Exp -> Exp -> Exp
cC (App2 CB e1 e2) e3 = cC' e1 e2 e3 -- C (B e1 e2) e3 = C' e1 e2 e3
--cC (Prim op) e2 | Just op' <- lookup op flipOps = App (Prim op') e2 -- C op e = flip-op e
cC (Var op) e2 | Just op' <- lookup op flipOps = App (Var op') e2 -- C op e = flip-op e
cC e1 e2 = App2 CC e1 e2
cB :: Exp -> Exp -> Exp
cB CC (App CC CI) = CP -- Pair
cB (App CB CK) CP = CO -- Cons
cB e1 e2 = App2 CB e1 e2
The old improveC/improve block is left in the file, commented out
({- ... -}) rather than deleted, in the same "keep exploratory code, just
stop running it" habit chapter 01 noted at commit 1. Machine-verified,
not just read: with compileOpt = improveT . compile, compiling \x. \y. \f. f x y (the pairing function) at a9178695 needs the separate
improve pass to collapse to the P combinator β
compile epair = S (K C) (S (S (K C) (K I)) I)
improve (compile epair) = P
β while at b4e44ded, compile alone already produces P, no second
pass required:
compile epair = P
compileOpt epair = P
Same input, same final answer, one fewer traversal to get there β the
"New optimiser." title describes the intent; reading the diff is the only
way to see that it means fusing the cleanup pass into abstraction, not
adding new rules (the rule set β Pair/Cons included β is close to a
straight port of what improveC already had; cB's Pair/Cons cases
are literally the two rules 71729105 had commented out the day before,
now live again).
17587b2d: "Hack around a problem." (2023-05-30, 12:47:15, 13 seconds later)
a9178695's main compiled each top-level definition independently
and printed each one on its own β it never combined them into a single
runnable term, and had no idea which one was the entry point. That's the
problem. The fix (17587b2d:Main.hs, quoted whole):
import Data.Maybe
import System.Environment(getArgs)
import Parse
import Desugar
import Exp
primOps :: [(Ident, Exp)]
primOps =
[ ("error", Prim "?")
, ("+", Prim "+")
, ("-", Prim "-")
, ("-'", Prim "-'")
, ("*", Prim "*")
, ("quot", Prim "/")
, ("rem", Prim "%")
, ("<", Prim "<")
, ("<=", Prim "<=")
, (">", Prim ">")
, (">=", Prim ">=")
, ("==", Prim "==")
, ("/=", Prim "!=")
]
main :: IO ()
main = do
[fn] <- getArgs
mdl <- parseDie pTop fn <$> readFile fn
let ds = desugar mdl
ds' = [ (n, compileOpt e) | (n, e) <- ds ]
defs :: [(Ident, Exp)]
defs = [ (n, ref i) | ((n, _), i) <- zip ds' [0..] ] ++ primOps
find n = fromMaybe (error $ "undefined: " ++ show n) $ lookup n defs
emain = find "main"
res = foldr def emain (zip ds' [0..])
def :: ((Ident, Exp), Int) -> Exp -> Exp
def ((n, e), i) r = App2 CT (Lbl i (subst e)) r
subst (Var n) = find n
subst (App f a) = App (subst f) (subst a)
subst e = e
ref i = Var $ "_" ++ show i
-- mapM_ (\ (i, e) -> putStrLn $ i ++ " = " ++ toString e) ds
mapM_ (\ (i, e) -> putStrLn $ i ++ " = " ++ toString e) ds'
putStrLn $ toStringP res
writeFile "out.comb" $ toStringP res
Every top-level definition n gets an integer index i (source-file
order, zip ds' [0..]); every free variable reference to a top-level name
inside any other definition's body is textually replaced (subst) by
Var "_i" β a reference into b0c33a8f's label format, not a real bound
variable. The whole program becomes nested T (Lbl i body_i) rest
applications (T x y = y β T here is used purely to sequence a
definition's text into the output; its value is discarded, its label is
what matters), ending in a reference to whichever index main got. There
is no dependency analysis, no forward-declaration handling beyond what
the label format's own placeholder-before-parsing trick
(b0c33a8f) buys for free, and an unresolved name doesn't fail to parse β
it fails at find, with error $ "undefined: " ++ show n, a Haskell
exception, not a diagnostic from the parser. It is exactly what the
commit message says it is: a hack, applied to the actual problem (turning
N independent compiled definitions into one closed, runnable graph)
rather than a general solution to it (linking, dependency ordering, a
real module system) β and it's honestly labeled as such in the commit
log, not dressed up as a design decision. "Try it" exercise 1 below runs
into this hack's exact limits.
4030f721 and b56ba28b: parsing and combinators keep growing
4030f721 ("Parse more") adds one new expression form β a parenthesized
operator, (+), usable anywhere a normal identifier is β in twelve added
lines, all ordinary combinator code built from pieces
aux/parser-combinators.md section 3 already
covers; that doc walks the diff in full (section 5). b56ba28b ("More
combinators.") is the eval.c side of b4e44ded's revived Pair/Cons
rules: the P/O combinators get a tag in enum node_tag, a global
combP/combO node, parser cases ('P'/'O'), a printer case, and
runtime reduction rules (b56ba28b:eval.c, lines 599β620, quoted whole):
case P: /* P x y f = f x y */
CHECK(3);
GCCHECK(1);
x = ARG(TOP(1));
y = ARG(TOP(2));
f = ARG(TOP(3));
POP(3);
n = TOP(0);
FUN(n) = new_ap(f, x);
ARG(n) = y;
GOTO ap;
case O: /* P x y g f = f x y */
CHECK(4);
GCCHECK(1);
x = ARG(TOP(1));
y = ARG(TOP(2));
f = ARG(TOP(4));
POP(4);
n = TOP(0);
FUN(n) = new_ap(f, x);
ARG(n) = y;
GOTO ap;
P is pair's own combinator, three arguments in, applies the third to
the first two; O is cons, four arguments, applies the fourth to the
first two (the third β a "nil" continuation β is skipped over, which is
what cons-vs-nil case analysis needs). Even the comment above case O: still says P x y g f = f x y β copy-pasted from the case above it
and never updated to say O. This commit closes out the range: after
b56ba28b, the language a source file can express has grown from nothing
to modules, functions, data declarations, case expressions, and integers,
and the runtime understands two new combinators it didn't a day earlier.
Why it matters
Four days after a compiler that could only run one hand-built term,
a9178695 gives it a real front door: source text in, by way of an
actual parser, not Haskell values assembled by hand in main. That's the
headline. But the more durable habit visible across these eleven commits
is what happens after the headline commit: the node representation
changes (648aeef0), the optimizer gets rewritten from a two-pass design
to a one-pass one and breaks compiling on the first attempt
(9cf1e937/b4e44ded, forty seconds apart), and the missing
"combine N definitions into one program" piece gets bolted on as an
admitted hack (17587b2d) β five commits, 9cf1e937 through b56ba28b,
span a total of 98 seconds of commit timestamps (12:46:22 to
12:48:00), which is not how long any of that code took to write; it's
how long it took to commit work that had already been done. Rewriting
two-day-old code β the optimizer, twice β costs nothing here, because
nothing outside this project depends on it yet. That's a luxury a toy has
and a shipped compiler doesn't, and this range uses it freely.
Notebook margin
- Commit messages state intent, not mechanism. "Make a real compiler." doesn't say it adds a parser-combinator library, a desugarer, and a second copy of the bracket-abstraction pipeline in six new files; "New optimiser." (twice, 40 seconds apart) doesn't say the first of the two doesn't compile, or that the real change is fusing a cleanup pass into abstraction rather than adding new rules.
- Representation experiments happen as commits on the mainline, not
branches:
648aeef0addsNODE_INDEXas a third option ineval.c's existing#if/#elifchain and flips one#defineto activate it;NODE_NAIVE(chapter 01's representation) stays in the file, compiling, just not selected. - A hack, applied and honestly labeled:
17587b2dis the commit message before you even open the diff. What it hacks around β turning independently-compiled top-level definitions into one runnable, entry-point-aware graph β is a real problem this range never gives a general solution to.
Try it
Anchor: b56ba28b. Convention (see 00-overview.md):
git worktree add /tmp/mhs-b56ba28b b56ba28b
cd /tmp/mhs-b56ba28b
nix develop $MICROHS -c make test
This still runs the old Lam.hs/nfib.comb pipeline β the
Makefile's test target was never updated in this range to build the
new Main.hs/Parse.hs path at all (git show b56ba28b:Makefile is
byte-identical to 6485a341's). Expect the same nfib 35 computation as
chapter 01, on the new NODE_INDEX representation:
./eval nfib.comb
((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)
29860703
node size=16, heap size=100000
1045124618 reductions, 1944 GCs
Same answer, same reduction count and GC count as ccb31388's node size=24 build in chapter 01 β the representation change is invisible to
the computation, only to memory per node.
Build note: the new parser-driven path (
Main.hs) has noMakefiletarget at this commit and needs one line added to build at all with this project's flake toolchain (GHC 9.8.4, mtl 2.3.1). Building it as written fails:nix develop $MICROHS -c ghc Main.hs -o uhsParserComb.hs:52:11: error: [GHC-88464] Variable not in scope: ap :: Prsr s (a -> b) -> Prsr s a -> Prsr s b Suggested fix: Perhaps use βmapβ (imported from Data.List) | 52 | (<*>) = ap | ^^
ap(used inParserComb.hs'sApplicativeinstance, aux/parser-combinators.md section 2) isn't brought into scope by any ofParserComb.hs's own imports on this GHC/ mtl combination β it needsControl.Monadexplicitly. Add one line after the existingimport Control.Monad.State.StrictinParserComb.hs:import Control.Monad(ap)Then the build succeeds:
nix develop $MICROHS -c ghc Main.hs -o uhs[1 of 6] Compiling ParserComb ( ParserComb.hs, ParserComb.o ) [2 of 6] Compiling Parse ( Parse.hs, Parse.o ) [3 of 6] Compiling Exp ( Exp.hs, Exp.o ) [4 of 6] Compiling Desugar ( Desugar.hs, Desugar.o ) [5 of 6] Compiling Main ( Main.hs, Main.o ) [6 of 6] Linking uhs(A GHC warning about partial
tailatParse.hs:203β the same kind of partiality chapter 01 noted inLam.hsβ appears too; harmless here, omitted above.) Apply this one-line patch before the exercises below.
Exercise 1: a tiny program in the accepted syntax
cat > pair.hs <<'EOF'
module Pair where
f x = x
main = f 3
EOF
./uhs pair.hs
f = I
main = f 3
((T :0 I) ((T :1 (_0 3)) _1))
The first two lines are ds' (each definition's compiled, optimized
form β f x = x collapses to the identity combinator I, exactly the
(\x. x) = I case aux/bracket-abstraction.md
section 1 covers). The third line is 17587b2d's linked program,
written to out.comb as well as stdout. Run it:
nix develop $MICROHS -c ./eval out.comb
3
node size=16, heap size=100000
14 reductions, 0 GCs
3, as expected. Worth trying, and worth knowing about 17587b2d's hack
before you rely on it: swap the declaration order in pair.hs β main = f 3 before f x = x β and rebuild:
cat > fwd.hs <<'EOF'
module Fwd where
main = f 3
f x = x
EOF
./uhs fwd.hs
nix develop $MICROHS -c ./eval out.comb
ERR: shared == NIL
Labels are assigned in source order (zip ds' [0..]), so main β first
in this file β is now label 0, referencing f β label 1 β by _1,
before :1 has appeared in the printed text at all. b0c33a8f's
parser is a single left-to-right pass with no forward-declaration
support; a label has to be defined before it's referenced in the text,
not just before it's needed. 17587b2d's "hack" inherits this
constraint silently β nothing catches or reports it above the C-level
ERR. (Put f back before main before continuing, or just use
pair.hs again.)
Exercise 2: trigger a parse error
cat > bad.hs <<'EOF'
module Bad where
main = )
EOF
./uhs bad.hs
Actual behavior: an uncaught Haskell exception, all to stderr, exit code
1 (parseDie, a9178695:Parse.hs line 85: Left err -> error err):
uhs: line 2, col 7:
found: ")"
expeced: = white-space
expeced: lower case
expeced: upper case
expeced: int digit
expeced: '('
expeced: \ symbol
expeced: case letter, digit
expeced: case <empty>
CallStack (from HasCallStack):
error, called at ./Parse.hs:85:17 in main:Parse
Read literally: expeced is misspelled in the actual source
(formatFailed, a9178695:ParserComb.hs line 109: pr e = " expeced: " ++ e) β
not a transcription error here, the running binary really prints it that way.
The list is every alternative tried and failed at the point of furthest
progress (column 7, right after main = ), merged via <?>'s labeling
and LastFail's longest. "case <empty>" traces to a specific,
verifiable spot: pExpr's third alternative, pCase, starts with
pKeyword "case" (a9178695:Parse.hs, lines 98β102, quoted whole):
pKeyword kw = (skipWhite $ do
s <- pIdentRest
guard (kw == s)
pure ()
) <?> kw
On ), pIdentRest reads zero characters () isn't a letter or digit),
so s = "", guard ("case" == "") is guard False, which is
Alternative's empty (guard is Control.Monad's, not
ParserComb.hs's own), and <?> "case" prepends "case" to empty's
own "<empty>" message β unwords ["case", "<empty>"]. An honest
byproduct of a two-day-old error-reporting pass (empty's literal
placeholder text leaking into a real error message) rather than a
deliberately designed one.
Exercise 3: the optimizer, before and after
9cf1e937~1..9cf1e937 (the literal endpoints of the first "New
optimiser." commit) is not buildable β confirmed above in "What
happened": 9cf1e937 alone fails with a parse error inherited from
71729105's typo, before the Lbl-not-in-scope problem its own diff
would otherwise cause even gets a chance to surface. Neither this pair
nor 71729105/9cf1e937/b4e44ded in any combination gives two
buildable Main.hs binaries either side of the rewrite: b4e44ded's own
Exp.hs builds, but the Main.hs sitting alongside it at that commit
still calls the old, now-removed improve function and fails with
Variable not in scope: improve.
Build note: substituting the nearest pair of commits where the relevant code (
Exp.hs'scompile/improve/compileOpt) both parses and typechecks on its own βa9178695(before the rewrite; the original two-passimproveC/improveT) andb4e44ded(after; the fused smart-constructor version) β and comparing viaghci/ghc -edirectly onExp.hs, the same way aux/bracket-abstraction.md section 2 does, rather than through the fullMain.hspipeline (which needs theappatch above at every commit in the range and isn't the part that actually changed).
git worktree add /tmp/mhs-a9178695 a9178695
cd /tmp/mhs-a9178695
Apply the same one-line ParserComb.hs fix as above (import Control.Monad(ap), needed here too since Exp.hs imports Parse, which
imports ParserComb), then:
nix develop $MICROHS -c ghc -e 'let epair = Lam "x" (Lam "y" (Lam "f" (App2 (Var "f") (Var "x") (Var "y")))) in putStrLn (toString (compile epair)) >> putStrLn (toString (improve (compile epair)))' Exp.hs
S (K C) (S (S (K C) (K I)) I)
P
epair is \x. \y. \f. f x y β the pairing function, the exact form the
Pair rule matches. Naive compile alone doesn't collapse it; a
separate improve call is required to reach P. Second worktree:
git worktree add /tmp/mhs-b4e44ded b4e44ded
cd /tmp/mhs-b4e44ded
Same one-line fix, then:
nix develop $MICROHS -c ghc -e 'let epair = Lam "x" (Lam "y" (Lam "f" (App2 (Var "f") (Var "x") (Var "y")))) in putStrLn (toString (compile epair)) >> putStrLn (toString (compileOpt epair))' Exp.hs
P
P
compile alone already produces P β the abstraction step and the
optimization are now one pass, not two, exactly as "What happened"
describes from reading the diff; running it on the same input both sides
of the rewrite confirms the diff's meaning rather than just its shape.
Clean up all three worktrees when done:
git worktree remove --force /tmp/mhs-b56ba28b
git worktree remove --force /tmp/mhs-a9178695
git worktree remove --force /tmp/mhs-b4e44ded
03 β Growing the language
Where we are
chapter 02 leaves a compiler that reads a small lambda language from
files: modules, function definitions, data declarations, case
expressions, integer literals, application, and \x. expr lambdas, all
parsed by the ParserComb.hs/Parse.hs combinator library
(aux/parser-combinators.md), desugared by
Desugar.hs to Exp.hs's tiny combinator core, and run by eval.c.
Every top-level definition is linked into one program by 17587b2d's
admitted hack: source order becomes a label number, and a name used
before its own label has been printed crashes the C runtime with ERR: shared == NIL. This chapter's range, b56ba28b..95ff6a34, is twelve
commits β nine landing in one sitting on 2023-05-30, the other three the
next working session, 2023-06-01 β that turn that minimal language into
something with let, infix operators, tuples, lists, characters,
strings, module imports, and do-notation.
What happened
5ccb1b2a: comments, let, and error
Three unrelated features in one commit, each small enough that "unrelated
things land together when they're this cheap" is itself worth noticing.
Comments are handled before the parser ever sees the text, not inside it:
parseDie now runs removeComments file on the whole source string
first (5ccb1b2a:Parse.hs line 84), and removeComments is a
line-oriented pass that blanks anything from -- to end of line while
leaving the inside of "..." string literals alone
(5ccb1b2a:Parse.hs, lines 90β98, quoted whole):
removeComments :: String -> String
removeComments = unlines . map remCom . lines
where remCom ('-':'-':_) = ""
remCom ('"':cs) = str cs
remCom (c:cs) = c : remCom cs
remCom "" = ""
str ('"':cs) = remCom cs
str ('\\':_:cs) = str cs
str (_:cs) = str cs
str "" = ""
(String literals aren't parseable syntax yet β that's ddb1bb29, seven
commits later β but comment-stripping already has to route around a
quoted -- so it doesn't eat the rest of the line.) let is a new
expression form, ELet [Def] Expr, and it desugars to nested,
non-recursive application of lambdas β "just sequential bindings", the
comment above the case says (5ccb1b2a:Desugar.hs line 33, quoted with
its comment):
-- For now, just sequential bindings.
dsExpr (ELet ds e) =
let ds' = concatMap dsDef ds
e' = dsExpr e
def (i, d) e = App (Lam i e) d
in foldr def e' ds'
let x = a; y = b in e becomes (\x -> (\y -> e) b) a β each binding
compiles to one more App (Lam ...) wrapped around what's inside it, in
source order, with no letrec-style self-reference or mutual recursion
support at all (a later chapter's problem). error is not a new syntax
form β it's a new eval.c primitive, reached the same way + or < is,
except it forces its argument, prints it to stderr, and exits
(5ccb1b2a:eval.c, lines 661β667, quoted whole):
case ERROR:
x = ARG(TOP(1));
x = evali(x);
fprintf(stderr, "error: ");
print(stderr, x);
fprintf(stderr, "\n");
exit(1);
3aa12deb: infix operators, and how fixity actually works here
Before this commit, pExpr only knew pExprApp (plain application),
pLam, pCase, and pLet β no expression could contain a binary
operator like + or == except by writing it as a parenthesized prefix
identifier, (+) x y. 3aa12deb adds a new alternative, pExprOp, and
this is the whole fixity mechanism at this commit, quoted whole
(3aa12deb:Parse.hs, lines 203β212):
pExprOp :: P Expr
pExprOp = pA
where
pA = pRightAssoc (pOpers ["$"]) pB
pB = pRightAssoc (pOpers ["||"]) pC
pC = pRightAssoc (pOpers ["&&"]) pD
pD = pNonAssoc (pOpers ["==", "/=", "<", "<=", ">", ">="]) pE
pE = pLeftAssoc (pOpers ["+", "-"]) pF
pF = pLeftAssoc (pOpers ["*", "quot", "rem"]) pG
pG = pExprApp
There is no fixity declaration anywhere in the language, no table read
from source, nothing a user program can influence. Precedence is
literally the position of a parser in this fixed chain β pA is
looser-binding than pB, which is looser than pC, and so on down to
pG, which is plain function application, tightest of all. Associativity
is which of three combinator functions wraps a given level:
pRightAssoc, pNonAssoc, or pLeftAssoc, each parsing one occurrence
of its own operand parser, then either recursing on itself
(right-associative), taking exactly one more operand with no further
chaining (non-associative β two == in a row is a parse error at this
level), or folding left over a flat list of (op, operand) pairs
(left-associative) β pLeftAssoc, quoted whole
(3aa12deb:Parse.hs, lines 235β239):
pLeftAssoc :: P String -> P Expr -> P Expr
pLeftAssoc pOp p = do
e1 <- p
es <- emany ((,) <$> pOp <*> p)
pure $ foldl (\ x (op, y) -> appOp op x y) e1 es
Fourteen operator tokens total get a home across these six levels ($,
||, &&, the six comparisons, +/-, */quot/rem), and every
operator not on one of these six lists simply isn't a valid infix
operator yet β writing a ++ b at this commit is a parse error, ++
doesn't appear until e96d04be inserts a new level for it (and :)
between the comparison level and +/-. It is crude β precedence is
baked into the parser's own call graph, adding an operator at a new
precedence means inserting a new mutually-recursive pN function into
the chain and renumbering nothing else, which is exactly what
e96d04be does thirteen minutes later (p0 through p10, eleven
numbered stages instead of six lettered ones, ->, :, ++, and .
added). But it works, and it's the entire mechanism: no operator table,
no user-extensible fixity, just a hand-written ladder of parsers.
e96d04be: type signatures, parsed and thrown away
The same commit that widens the operator ladder also adds f :: T as
parseable syntax β and nothing else. The new Def case,
Sign Ident Type (e96d04be:Parse.hs line 19), is built the same way
every other alternative in pDef is (e96d04be:Parse.hs line 178):
<|> Sign <$> (pLIdent <* pSymbol "::") <*> pType
pType, notably, isn't a separate grammar at all β it's pExpr under a
different name (e96d04be:Parse.hs, lines 183β184, quoted whole):
pType :: P Type
pType = pExpr
A type signature is parsed as if it were an ordinary expression β Type
is a type synonym for Expr β because there's no distinct type-syntax
grammar to reuse yet, and the infix chain e96d04be just widened
happens to already include -> at its loosest level (p0), which is
what a T -> T signature needs to parse at all. And then the desugarer
drops it on the floor, one line, the entire handling
(e96d04be:Desugar.hs line 24):
dsDef (Sign _ _) = []
dsDef returns a list of compiled definitions for each Def; a Sign
contributes zero of them. This is "parse and ignore": accept the syntax
now, so source files that write type signatures don't fail to parse,
without committing to any meaning for it yet. The meaning arrives months
later β chapter 09 covers the type checker that finally reads what
Sign is carrying instead of discarding it β but from e96d04be onward,
every commit in between is free to write and ship code with type
signatures in it that do nothing at all.
The shape of a feature commit: tuples, lists, strings
Three commits in a row show the same recipe at work β a new Expr
constructor, a pAExpr alternative that builds it, and a dsExpr case
that rewrites it to something the existing combinator core already
understands.
Tuples (8939d55b) parse as a comma-separated list inside
parentheses and desugar to a Scott-encoded continuation-passing function
β the same trick chapter 02's a9178695:Desugar.hs used for data
constructors, this time built by hand for the built-in tuple form
(8939d55b:Parse.hs, lines 197β200, quoted whole):
eTuple :: [Expr] -> Expr
eTuple [] = ELam "_x" (EVar "_x") -- encoding of ()
eTuple [e] = e
eTuple es = ELam "_f" $ foldl EApp (EVar "_f") es
A pair (a, b) becomes \f -> f a b β a value that is its own
eliminator, waiting for a continuation. This shape is common enough that
the same commit teaches the bracket-abstraction smart constructors to
recognize it and collapse it straight to the P (pair) combinator
chapter 02's b56ba28b added to eval.c, rather than build it out of
S/K/C at the call site (8939d55b:Exp.hs line 95, one new line
in cC):
cC (App2 CC CI e1) e2 = App2 CP e1 e2
List syntax (d91b9812) is even smaller β one parser alternative for
[e1, e2, ...], and a two-line desugaring straight to nested cons/nil
using b56ba28b's O combinator and the existing K (d91b9812:Desugar.hs
lines 39β40, quoted whole):
dsExpr (EList es) =
foldr (App2 CO) CK $ map dsExpr es
[1,2,3] becomes O 1 (O 2 (O 3 K)). No new runtime primitive: O's
own reduction rule (redOne (App4 (Comb "O") x1 x2 f1 f2) = Just $ App2 f2 x1 x2, chapter 02's b56ba28b:Exp.hs line 184) needs two
continuations, a nil-branch and a
cons-branch, applied in that order β K a b = a is exactly the function
that, given those two continuations, always picks the first (the
nil-branch), which is why CK alone is a valid empty list: a
Scott-encoded [] is a value that ignores its cons-continuation and
returns its nil-continuation, and K does precisely that once partially
applied to nothing else.
String literals (ddb1bb29) don't even need their own desugaring
rule at the list level β a string is defined as a list of characters and
handed straight to the list-desugaring case that already exists
(ddb1bb29:Desugar.hs line 47, quoted whole):
dsExpr (EStr cs) = dsExpr $ EList $ map EChar cs
Two commits earlier, 60cd285c had already done the one piece of real
work in this trio: character literals do need a new runtime primitive,
because a Char isn't representable as anything already in eval.c β a
new CHAR node tag joins enum node_tag right after INT
(60cd285c:eval.c line 18), the parser gains a case '\'' that reads
one raw byte off the stream into a CHAR node (60cd285c:eval.c lines
321β325), and eval's reduction loop treats CHAR the same inert way it
already treats INT β a case CHAR: arm whose whole body is RET;,
the same as INT's arm right above it, no reduction rule, just "this
is already a value" (60cd285c:eval.c lines 508β511). Once that groundwork
exists, EStr is free: strings are lists of Chrs, and both EList's
desugaring and O/K's runtime behavior are already in place to carry
them.
4e4c5252 ("Refactor.", between the list-syntax and character-literal
commits) is not a feature: it's the moment case-pattern parsing, which
had grown ad hoc pLHS pUIdent/tuple/[]/:-infix branches bolted on
piece by piece as tuples and lists were added, gets pulled into one Pat
type (PConstr Ident [Ident] | PTuple [Ident]) with dsExpr's ECase
arm rewritten to match it. Nothing about what parses changes; what
changes is that the four ad hoc pattern shapes now share one
representation instead of four separately-threaded tuples. This is the
first instance of a rhythm the rest of the chapter keeps repeating:
feature, feature, refactor β clean up the accumulated mess only after
enough of it has accumulated to be worth cleaning, not before.
b25d9fc8: importing modules
This is the biggest commit in the range β a new file, Compile.hs, 91
lines β and what "module" means here is more substantial than "parse and
ignore": a module is a file, <Name>.uhs, whose own declared module name
must match the file name it was loaded from
(b25d9fc8:Compile.hs lines 40β45, quoted whole):
compileModule :: IdentModule -> StateT Cache IO CModule
compileModule nm = do
let fn = nm ++ ".uhs"
mdl@(Module nm' defs) <- parseDie pTop fn <$> liftIO (readFile fn)
when (nm /= nm') $
error $ "module name does not agree with file name: " ++ show nm
import M (a new Def case, one keyword away from data/import's
neighbors in pDef) triggers compileModuleCached on M, which checks
a Map of already-compiled modules before doing any work, and tracks a
working list of modules currently mid-compile so that importing a
module that (transitively) imports itself is a hard error rather than an
infinite loop (b25d9fc8:Compile.hs lines 24β38) β genuine cycle
detection, not a crude stopgap. Every top-level name a module defines
ends up visible under two spellings in that module's own symbol table:
unqualified (i) and dotted (mn ++ "." ++ i) β both point at the same
qualified Var (b25d9fc8:Compile.hs line 53, quoted whole):
syms (mn, ds) = [ (s, [Var qi]) | (i, _) <- ds, let qi = mn ++ "." ++ i, s <- [i, qi] ]
which is why two imported modules defining the same unqualified name is
an ambiguity error at reference time (checkExpr's Just qis -> error $ "ambiguous: " ++ ..., unchanged from 17587b2d) rather than something
caught at import time. There's a smaller fix bundled into the same
commit that quietly closes a gap chapter 02's "Try it" ran straight into:
17587b2d's label format required a definition's :N to appear in the
printed text before any _N reference to it, so a main that textually
preceded the function it called crashed the C runtime with ERR: shared == NIL. b25d9fc8:eval.c makes both the _ and : cases allocate the
indirection placeholder unconditionally, whichever one is seen first,
instead of the _ case demanding the placeholder already exist
(b25d9fc8:eval.c, lines 336β343, quoted whole):
case '_' :
l = (int)parse_int(f); /* The label */
if (shared[l] == NIL) {
/* Not yet defined, so make it an indirection */
shared[l] = alloc_node(IND);
INDIR(shared[l]) = NIL;
}
return shared[l];
Forward references within one linked program now just work β not
because the linking hack from 17587b2d got redesigned, but because the
label format underneath it grew the one piece of order-independence it
was missing.
ff4622e8 and 693dc6ce: another refactor, then do-notation
ff4622e8 ("Refactor ident parsing.") is the second instance of the
feature-feature-refactor rhythm: identifiers, which had been three
separately-defined parsers (lowercase, uppercase, and an ad hoc character
set for "rest of identifier"), get unified around one pWord/pIdent
pair and a new pQIdent that threads .-separated segments together β
M.name, a qualified reference, is now representable at all, which
693dc6ce, twenty-two seconds later, immediately needs. do-notation
doesn't dispatch on a Monad type class β there isn't one yet β it
dispatches on an explicit qualifier written at the call site: pDo
parses <Uident>.do { ... }, and that identifier is threaded through
every desugared bind as the prefix of the operator name it calls
(ff4622e8:Parse.hs, lines 359β372, quoted whole):
pDo :: P Expr
pDo = EDo <$> pQualDo <*> pBlock pStmt
pStmt :: P Stmt
pStmt =
(Bind <$> (pLIdent <* pSymbol "<-") <*> pExpr )
<|> (Then <$> pExpr)
pQualDo :: P String
pQualDo = do
s <- pUIdent
_ <- char '.'
pKeyword' "do"
pure s
The desugaring, all of 693dc6ce:Desugar.hs's new lines (49β53, quoted
whole):
dsExpr (EDo _ []) = error "empty do"
dsExpr (EDo _ [Bind _ _]) = error "do without final expression"
dsExpr (EDo _ [Then e]) = dsExpr e
dsExpr (EDo n (Bind i e : ss)) = App2 (Var (n ++ ".>>=")) (dsExpr e) (Lam i $ dsExpr (EDo n ss))
dsExpr (EDo n (Then e : ss)) = App2 (Var (n ++ ".>>")) (dsExpr e) (dsExpr (EDo n ss))
M.do { x <- e; rest } becomes (M.>>=) e (\x -> rest'), where rest'
is rest recursively desugared the same way (still as an M.do block if
more than one statement remains); M.do { e; rest } becomes (M.>>) e rest' the same way β textbook do desugaring, except that M.>>= and
M.>> are not resolved through any type-class machinery at all, just
looked up in the symbol table like any other qualified name. Whatever
module defines (>>=) and (>>), its own
name is the qualifier that unlocks do-notation for it β at this commit
that's IO, wired in as two more entries in Compile.hs's still-hardcoded
primOps list (693dc6ce:Compile.hs lines 78β79: ("IO.>>=", Prim "IO.>>="), ("IO.>>", Prim "IO.>>")), but nothing in pQualDo or
dsExpr's EDo cases is specific to IO β any module that defines its
own (>>=)/(>>) gets its own usable do block for free, IO monad or
not ("Try it" exercise 1 uses exactly this to run do-notation without
touching IO at all).
95ff6a34: primitive, and decoupling the compiler from the runtime
Every runtime primitive up to this commit is a hardcoded entry in
Compile.hs's primOps list β one Haskell-source line per primitive,
maintained by hand, growing every time a new operator needs a runtime
counterpart. 95ff6a34 deletes that list outright and replaces it with
a new expression form that lets any module spell out its own primitive
bindings: primitive "tag" is new parseable syntax
(95ff6a34:Parse.hs line 291, one alternative added to pAExpr):
<|> (EPrim <$> (pKeyword "primitive" *> pString))
and it desugars, unsurprisingly given how mechanical the mapping is, to
exactly the Exp.hs constructor its old table used to build by hand
(95ff6a34:Desugar.hs line 54, quoted whole):
dsExpr (EPrim s) = Prim s
Three new source files land in the same commit β Prelude.uhs, IO.uhs,
and List.uhs (a List data declaration plus map, unrelated to
primitive). Prelude.uhs and IO.uhs are, line after line, nothing
but top-level bindings whose right-hand side is primitive "..." β
(+) = primitive "+", error = primitive "?", (>>=) = primitive "IO.>>=", readFile = primitive "IO.readFile", and so on. What used to
require editing Compile.hs (a compiler-internal, Haskell-source file)
to add a runtime primitive now requires only writing one line in a
.uhs module (an ordinary program source file) β the string inside
primitive "..." is the only thing
that has to match what eval.c actually understands, and the compiler
itself no longer needs to know the runtime's combinator vocabulary at
all. Exp.hs's flipOps table (used by the bracket-abstraction smart
constructor cC to recognize "flip this comparison instead of building a
C combinator around it") gets its entries requalified to
"Prelude.+", "Prelude.<", and so on in the same commit
(95ff6a34:Exp.hs lines 115β124) β a small, honest side effect of the
primitive names now living inside a real module instead of a hardcoded
list of bare strings.
Why it matters
Look at what each of these commits actually costs. dsDef (Sign _ _) = [] is one line. dsExpr (EStr cs) = dsExpr $ EList $ map EChar cs is
one line and doesn't even introduce a new case in the runtime β it just
routes through a desugaring that already exists. dsExpr (EList es) = foldr (App2 CO) CK $ map dsExpr es needs no new combinator either,
because O and K were already sitting in eval.c from chapter 02.
That's the payoff of compiling everything down to a handful of SKI-style
combinators before it ever reaches the runtime: most new surface syntax
is free at the back end β it's just a new way to build a term the
combinator core already knows how to run. The exceptions are
instructive precisely because they're rare: character literals needed a
real new runtime primitive (a CHAR node tag, a parser case, a GC-
visible representation in eval.c), and that's the one feature in this
chapter that took more than a few lines in two Haskell files. Everything
else β let, tuples, lists, strings, type signatures, do-notation β
is parser lines plus desugarer lines, no runtime change at all.
"Parse and ignore" is the same economy applied to time instead of code.
e96d04be doesn't need to decide what a type signature means to let
programmers write f :: Int -> Int above f's definition β accepting
the syntax and discarding it costs one line and buys months: every
commit between here and chapter 09 is free to use signatures as
documentation, or not use them at all, without the type checker existing
yet to enforce or even read them. Momentum matters more than
completeness at this stage of the project, and both habits β desugar to
a tiny core, accept syntax before you've decided its semantics β are how
a language grows fast without every commit becoming a small research
project.
Notebook margin
- Feature per commit, chronologically:
let+comments+error, infix operators, type signatures (ignored), tuples, list syntax, character literals, string literals, module imports,do-notation,primitive. Two commits in the range aren't features at all β4e4c5252andff4622e8are refactors, and both land only after enough ad hoc special-casing has piled up to be worth unifying (pattern-parsing after tuples and lists both bolted their own cases on; identifier- parsing right before qualified names become load-bearing fordo). Refactors are interleaved with feature work, not batched into their own commit or their own day. - The day's count: nine of this chapter's twelve commits β
5ccb1b2athroughb25d9fc8β land on 2023-05-30, the same day chapter 02 closes withb56ba28b. The remaining three βff4622e8,693dc6ce,95ff6a34β land on 2023-06-01, the next working session. - A hardcoded fixity ladder is still a fixity mechanism:
3aa12deb'spA/pB/.../pGchain has no operator table and no user-facing fixity declarations, but it correctly parses1 + 2 * 3as1 + (2 * 3)because*sits one level tighter than+in the chain β the entire precedence table is which function calls which, nothing more abstract than that.
Try it
Anchor: 693dc6ce ("Add do desugaring."). Convention (see
00-overview.md):
git worktree add /tmp/mhs-693dc6ce 693dc6ce
cd /tmp/mhs-693dc6ce
Like chapter 02's b56ba28b, this commit's Makefile has no target for
the parser-driven compiler (Main.hs) β only the old Lam.hs/eval.c
test target exists. Building Main.hs by hand hits the exact same
GHC-9.8/mtl issue chapter 02's Build note covers, plus one more that's
new to this chapter's range because it's the first commit in the range
where the new file the note is about, Compile.hs, exists at all.
Build note: building as written β
nix develop $MICROHS -c ghc Main.hs -o uhsfails first with chapter 02's
ap-not-in-scope error inParserComb.hs(Variable not in scope: ap :: Prsr s (a -> b) -> Prsr s a -> Prsr s b,ParserComb.hs:52:11) β apply the same one-line fix chapter 02 uses, adding it afterParserComb.hs's existingimport Control.Monad.State.Strict:import Control.Monad(ap)With that fix, the build gets further and hits a second, same-shaped problem in the module
b25d9fc8added,Compile.hs:Compile.hs:30:7: error: [GHC-88464] Variable not in scope: when :: Bool -> a2 -> StateT Cache IO a3 Suggested fix: Perhaps use data constructor βThenβ (imported from Parse) | 30 | when (nm `elem` ws) $ | ^^^^(a second, identically-shaped error follows at
Compile.hs:44:3, the function's otherwhencall β omitted above.)when(used twice inCompile.hs, both to raise anerroron a bad condition) isn't brought into scope byCompile.hs's ownimport Control.Monad.State.Stricton this GHC/mtl combination either β the same root cause as theapproblem, a different symbol, a different file. Add one line afterCompile.hs's existingimport Control.Monad.State.Strict:import Control.Monad(when)Then the build succeeds (harmless
-Wx-partialwarnings abouthead/tail, the same kind chapter 02 noted, omitted below):nix develop $MICROHS -c ghc Main.hs -o uhs[1 of 7] Compiling ParserComb ( ParserComb.hs, ParserComb.o ) [2 of 7] Compiling Parse ( Parse.hs, Parse.o ) [3 of 7] Compiling Exp ( Exp.hs, Exp.o ) [4 of 7] Compiling Desugar ( Desugar.hs, Desugar.o ) [5 of 7] Compiling Compile ( Compile.hs, Compile.o ) [6 of 7] Compiling Main ( Main.hs, Main.o ) [7 of 7] Linking uhsOne more piece is needed before the runtime can execute anything the new compiler produces:
eval.citself, which theMakefile'sevaltarget already knows how to build βnix develop $MICROHS -c gcc -Wall -O3 eval.c -o evalApply both one-line patches and build
evalbefore the exercises below.
Also note, since Compile.hs landed in b25d9fc8: a source file is
loaded by module name, not by file argument β Compile.hs's
compileModule reads <name>.uhs, not whatever path you pass on the
command line, and ./uhs Foo (not ./uhs Foo.uhs) is how the new
Main.hs invokes it ([mn] <- getArgs; ds <- compile mn, 693dc6ce:Main.hs).
Exercise 1: let, a list, and do-notation, without touching IO
do-notation at this commit doesn't require the IO module or any
built-in monad β M.do just needs a module M with its own (>>=) and
(>>) in scope, per "What happened" above. Write one small
self-contained module that supplies a trivial (>>=)/(>>) pair and
then uses them via do:
cat > Demo.uhs <<'EOF'
module Demo where
(>>=) x f = f x
(>>) x y = y
main = Demo.do
y <- 10
let
xs = [1, 2, 3]
in case xs of
[] -> 0
(:) a as -> a + y
EOF
nix develop $MICROHS -c ./uhs Demo
importing "Demo"
importing done "Demo"
Demo.>>= = C I
Demo.>> = T
Demo.main = Demo.>>= 10 (C' (P 0) (B (B K) (C +)) (O 1 (O 2 (O 3 K))))
((T :0 (C I)) ((T :1 T) ((T :2 ((_0 10) (((C' (P 0)) ((B (B K)) (C +))) ((O 1) ((O 2) ((O 3) K)))))) _2)))
Run the linked program:
nix develop $MICROHS -c ./eval out.comb
((T :99996 (C I)) ((T T) ((T :99989 ((_99996 10) (((C' (P 0)) ((B (B K)) (C +))) ((O 1) ((O 2) ((O 3) K)))))) _99989)))
11
node size=16, heap size=100000
56 reductions, 0 GCs
eval.c's main calls pp(stdout, n) twice, once right after parsing
and once after reduction (693dc6ce:eval.c lines 704 and 706 β
unchanged since chapter 02's 54806439) β the first line above is the
parsed-but-unreduced term, printed through pp's own independent
sharing-detection pass (hence label numbers like :99996 that don't
match the file's own :0/:1/:2), and the second line is the answer
after evali runs: 11. y is bound to 10 by the do-block's
Bind, xs is the list [1,2,3], the case on xs matches the
(:) a as arm with a = 1, and a + y = 11. Two dead ends worth
knowing about on the way here, both genuine limits of this commit's
parser, not typos in the example above:
-
let x = 5 in xon one line does not parse:cat > T1.uhs <<'EOF' module T1 where main = let x = 5 in x EOF nix develop $MICROHS -c ./uhs T1importing "T1" uhs: line 3, col 11: found: "x" expeced: let ' ' ...the block
letopens (viapBlock'ssoftLC) always takes its indentation from the next physical line, never the rest of the current one, so the bound variables have to start on a new, indented line. -
inhas to be indented to a column strictly between the enclosingdo-block's column and thelet-block's own column (here:dostarts its statements at column 2,let's own block is at column 4, soinsits at column 3). Indentinto column 2 or less and the layout algorithm's dedent-on-whitespace rule (pWhite,Parse.hs) closes theletblock correctly but then also reads the same dedent as the start of a newdo-statement, injecting a;in front ofinand breaking the parse. Acasewith only the(:)arm and no[]arm also silently fails to reduce β chapter 02'sOcombinator takes two continuations (nil-branch, then cons-branch) in that order, and a one-armcaseunder-applies it, leaving the result stuck as a partial application instead of a number (confirmed by running the one-arm version throughevaland getting an unreduced term back, not an error).
Exercise 2: read the desugared do-chain out of out.comb
Exercise 1's out.comb is 106 bytes, small enough to read whole:
((T :0 (C I)) ((T :1 T) ((T :2 ((_0 10) (((C' (P 0)) ((B (B K)) (C +))) ((O 1) ((O 2) ((O 3) K)))))) _2)))
:0, :1, :2 are the three top-level definitions in source order β
Demo.>>=, Demo.>>, Demo.main β each compiled to a combinator term
and linked by 17587b2d's label scheme (chapter 02). :0's term is C I β (>>=) x f = f x compiled by bracket abstraction, since C f x y = f y x, so C I x y = I y x = y x = f x with f and x renamed to
match. :2, Demo.main's term, is where the do-desugaring from
693dc6ce:Desugar.hs is directly visible: (_0 10) (...) is _0
(Demo.>>=, i.e. the bind operator) applied to 10 (the desugared
Bind "y" (EInt 10)) applied to a lambda-turned-combinator continuation
β exactly the shape dsExpr (EDo n (Bind i e : ss)) = App2 (Var (n ++ ".>>=")) (dsExpr e) (Lam i $ dsExpr (EDo n ss)) produces, with n = "Demo", i = "y", e = EInt 10, and ss the case-expression that
becomes the continuation's body. The continuation itself, (C' (P 0) (B (B K) (C +))) (O 1 (O 2 (O 3 K))), is the compiled case-on-a-list from
Exercise 1's dead-end note: O 1 (O 2 (O 3 K)) is [1,2,3]
(d91b9812:Desugar.hs's EList desugaring β see "What happened"), and
(P 0)/(B (B K) (C +)) are the compiled [] -> 0 and (:) a as -> a + y arms in that order, the two continuations O's reduction rule
expects.
Exercise 3: extend the parser β underscores in integer literals
pInt at this commit accepts one or more digits and nothing else
(693dc6ce:Parse.hs line 204, quoted whole):
pInt :: P Integer
pInt = (read <$> (skipWhite $ esome $ satisfy "digit" isDigit)) <?> "int"
Confirm that a Haskell-style digit-group separator doesn't parse today:
cat > Under.uhs <<'EOF'
module Under where
main = 1_000
EOF
nix develop $MICROHS -c ./uhs Under
importing "Under"
uhs: line 3, col 8:
found: "_000"
expeced: int digit
...
(pInt reads 1 and stops at the first non-digit; pExprApp then
looks for another atomic expression to apply 1 to and _000 isn't one
β it doesn't start with a letter, so it isn't an identifier either.)
Edit pInt (Parse.hs, the same one line quoted above) to accept _ as
a digit-group separator, requiring a leading digit and filtering
underscores out before read:
Before (Parse.hs line 204):
pInt = (read <$> (skipWhite $ esome $ satisfy "digit" isDigit)) <?> "int"
After:
pInt = (read . filter (/= '_') <$> (skipWhite $ (:) <$> satisfy "digit" isDigit <*> emany (satisfy "digit" isDigit <|> char '_'))) <?> "int"
Rebuild and rerun:
nix develop $MICROHS -c ghc Main.hs -o uhs
nix develop $MICROHS -c ./uhs Under
[2 of 7] Compiling Parse ( Parse.hs, Parse.o ) [Source file changed]
[7 of 7] Linking uhs [Objects changed]
importing "Under"
importing done "Under"
Under.main = 1000
((T :0 1000) _0)
nix develop $MICROHS -c ./eval out.comb
((T 1000) 1000)
1000
node size=16, heap size=100000
6 reductions, 0 GCs
(the first line is pp's pre-reduction echo, same as Exercise 1's
transcript above; the second, 1000, is the reduced answer.) 1_000
now parses as the integer 1000 and runs to 1000; a plain main = 42
(no underscore) still compiles and runs to 42 unchanged β confirmed by
rerunning it through the patched uhs the same way:
cat > Reg.uhs <<'EOF'
module Reg where
main = 42
EOF
nix develop $MICROHS -c ./uhs Reg
nix develop $MICROHS -c ./eval out.comb
((T 42) 42)
42
node size=16, heap size=100000
6 reductions, 0 GCs
Clean up:
git worktree remove --force /tmp/mhs-693dc6ce
04 β Talking to the world
Where we are
Chapter 03 leaves a language with let, infix operators, tuples, lists,
strings, modules, and do-notation β including an IO module,
IO.uhs, created by 95ff6a34 alongside Prelude.uhs and List.uhs.
IO.uhs at that commit is nothing but a list of primitive declarations
(95ff6a34:IO.uhs, quoted whole):
module IO where
(>>=) = primitive "IO.>>="
(>>) = primitive "IO.>>"
return = primitive "IO.return"
readFile = primitive "IO.readFile"
writeFile = primitive "IO.writeFile"
putChar = primitive "IO.putChar"
getChar = primitive "IO.getChar"
and because chapter 03's 693dc6ce made do-notation dispatch on
whatever module supplies (>>=)/(>>) rather than on any built-in
notion of "the IO monad", IO.do { putChar 'h'; putChar 'i' } parses,
desugars, and compiles cleanly through every stage that exists so
far β there is no type checker yet (chapter 03's e96d04be establishes
that a :: signature is parsed and thrown away) to notice or care that
IO is special. But compiling is not running. eval.c, unchanged
since chapter 02, is still the old character-dispatch parser and
reducer: its primitive table is a hardcoded switch over single
characters ('+', '-', '*', '/', '%', '=', '!', '<',
'>', '?') with no entry, and no mechanism for an entry, that could
ever recognize a multi-character name like IO.>>=. Verified directly:
building 834fd9ba (the range's first commit β still before monadic IO
lands, see below) with the same one-line ap/when patches chapter 03's
Build note uses, and running
module TestIO where
import Prelude
import IO
main = IO.do
putChar 'h'
putChar 'i'
compiles to a .comb file that literally contains the text IO.>>=,
IO.putChar, and so on as bare, unprefixed identifiers (Prim, at
this point, prints as its own raw string β the $-prefix convention
that lets the runtime recognize a primitive reference doesn't exist
yet either), and running that file crashes immediately:
$ nix develop $MICROHS -c ./eval out.comb
ERR: parse ')'
So "where we are" is precise: IO syntax, an IO module, and
generic do-notation that happens to work with it are all already in
place. The runtime underneath all of it is fictional β every IO
primitive is a name with no meaning below the compiler. This chapter's
first commit is the one that gives it a meaning.
What happened
3246e2a6: monadic IO, and what an IO value actually is
"Implement monadic IO." lands the evening of 2023-06-01, about four
hours and forty minutes after 95ff6a34 closed chapter 03 β not an empty
gap: 834fd9ba "Small improvements" and e2f3cf57 "More stuff" land
earlier the same day (12:09 and 12:32), both unnarrated warm-ups that
extend IO.uhs β one more primitive declaration (print) plus a first
batch of derived helpers (mapM, mapM_, putStr, putStrLn) built on
the existing primitives β still with no runtime behind any IO.* name.
Two things happen in the same commit, and it's worth keeping them
separate: eval.c
gains a general mechanism for named primitives (not IO-specific), and
on top of that mechanism, IO gets its first real implementation.
The general mechanism: the old NODEPTR combS, combK, ... roster of
individually-named global variables is replaced by one table, an array
of {name, tag, node} triples, quoted with elisions
(3246e2a6:eval.c, lines 168β210):
struct {
char *name;
enum node_tag tag;
NODEPTR node;
} primops[] = {
/* combinators */
{ "S", S },
{ "K", K },
...
{ "O", O },
/* primops */
{ "+", ADD },
...
{ "ord", ORD },
{ "IO.>>=", IO_BIND },
{ "IO.>>", IO_THEN },
{ "IO.return", IO_RETURN },
{ "IO.readFile", IO_READFILE },
{ "IO.writeFile", IO_WRITEFILE },
{ "IO.getChar", IO_GETCHAR },
{ "IO.putChar", IO_PUTCHAR },
{ "IO.print", IO_PRINT },
};
and the .comb-file parser's '+'/'-'/'?'/etc. single-character
cases are replaced by one '$' case that reads an arbitrary name and
looks it up (3246e2a6:eval.c, lines 365β381, quoted whole):
case '$':
/* A primitive, keep getting char's until end */
for (int j = 0;;) {
c = getc(f);
if (c == ' ' || c == ')') {
ungetc(c, f);
buf[j] = 0;
break;
}
buf[j++] = c;
}
for (int j = 0; j < sizeof primops / sizeof primops[0]; j++) {
if (strcmp(primops[j].name, buf) == 0)
return primops[j].node;
}
fprintf(stderr, "bad primop %s\n", buf);
ERR("no primop");
Every combinator and primitive, IO or not, now goes through this one
path β the $-prefix convention this chapter's transcripts show
everywhere ($K, $IO.>>=, $chr, ...) is this commit's doing, and it
is the piece of infrastructure that makes IO.>>= parseable at all,
independent of what IO.>>= then does.
What it does is eight new enum node_tag values (3246e2a6:eval.c,
lines 18β21, quoted whole):
enum node_tag { FREE, IND, AP, INT, CHAR, S, K, I, B, C, T, Y, SS, BB, CC, P, O,
ADD, SUB, MUL, QUOT, REM, SUBR, EQ, NE, LT, LE, GT, GE, ERROR, CHR, ORD,
IO_BIND, IO_THEN, IO_RETURN, IO_READFILE, IO_WRITEFILE, IO_GETCHAR, IO_PUTCHAR, IO_PRINT
};
An IO a value, at this commit and every later one in this chapter, is
not a distinct runtime type β it is an ordinary graph node whose tag
happens to be one of these eight. To the normal reduction loop,
eval(), these tags are opaque, already-a-value tags exactly like
INT or CHAR β the whole handling is one RET with no reduction
rule (3246e2a6:eval.c, lines 769β777, quoted whole):
case IO_BIND:
case IO_THEN:
case IO_RETURN:
case IO_READFILE:
case IO_WRITEFILE:
case IO_GETCHAR:
case IO_PUTCHAR:
case IO_PRINT:
RET;
Meaning lives entirely in a second interpreter, evalio, that main
calls once on the whole program instead of the old evali+print
pattern. evalio walks the same kind of graph eval does, but its
switch gives these eight tags actual behavior β sequencing is spelled
out directly as C control flow (3246e2a6:eval.c, lines 817β830):
case IO_BIND:
CHECKIO(2);
GCCHECK(1);
x = evalio(ARG(TOP(1))); /* first argument, unwrapped */
f = ARG(TOP(2)); /* second argument, the continuation */
n = new_ap(f, x);
POP(3);
goto top;
case IO_THEN:
CHECKIO(2);
(void)evalio(ARG(TOP(1))); /* first argument, unwrapped, ignored */
n = ARG(TOP(2)); /* second argument, the continuation */
POP(3);
goto top;
IO_BIND (>>=) recursively runs its left computation to get an
unwrapped result, applies the continuation to it, and loops (goto top) on whatever that produces β a genuine trampoline, not a recursive
descent, so a long do-chain doesn't grow the C stack. IO_THEN
(>>) is the same shape with the left result discarded. On the
Haskell/compiler side there is no analogous "IO type" either: EDo's
desugaring (chapter 03's 693dc6ce:Desugar.hs) turns M.do { x <- e; rest } into App2 (Var "M.>>=") e (Lam x rest) for any M, and by
the time that reaches Exp.hs it's an ordinary Prim/Var node
indistinguishable at the term level from + or error. "This is IO"
is a fact known only to evalio's switch, nowhere else in the
system β the same architecture that already ran nfib.
The same commit adds one more piece: a Let statement inside a
do-block (M.do { let x = e; rest }), desugaring to a plain,
non-recursive application, no different from chapter 03's expression-level
let at that point (3246e2a6:Desugar.hs, line 54, quoted with its two
neighbors for context):
dsExpr (EDo n (Bind i e : ss)) = App2 (Var (n ++ ".>>=")) (dsExpr e) (Lam i $ dsExpr (EDo n ss))
dsExpr (EDo n (Then e : ss)) = App2 (Var (n ++ ".>>")) (dsExpr e) (dsExpr (EDo n ss))
dsExpr (EDo n (Let i e : ss)) = App (Lam i $ dsExpr (EDo n ss)) (dsExpr e)
That line never changes for the rest of this chapter's range, even
after fe22efbd (below) makes expression-level let unconditionally
recursive β a let written inside a do-block stays simple,
non-recursive sugar for a lambda application all the way through
8c5fbb1a. Two lets, spelled the same way, with different desugaring
rules depending on which syntactic position they're in; nothing in this
range unifies them.
And one honest gap, shipped anyway: IO_READFILE and IO_WRITEFILE
are declared in the primop table, parseable, printable β and their
evalio bodies are stubs (3246e2a6:eval.c, lines 836β841, quoted
whole):
case IO_READFILE:
CHECKIO(1);
ERR("IO_READFILE");
case IO_WRITEFILE:
CHECKIO(2);
ERR("IO_WRITEFILE");
Console IO β getChar/putChar, hardcoded to stdin/stdout β works
immediately in this commit. File IO is declared and left to crash on
first use. 4c4229ec ("Fix bugs and improve.") and 7bb30b64 ("Minor
improvements.") land later the same evening β 4c4229ec generalizes
ELam to multi-argument lambdas and adds fix = primitive "Y" as a
named Prelude function (a second, explicit route to the Y tag,
alongside whatever let itself does β used immediately by List.uhs's
new map/filter/foldr, each written as fix (\ rec arg -> ...)
because plain recursive let doesn't exist as a language feature
yet) β but neither commit touches IO_READFILE/IO_WRITEFILE. They
stay broken until the next morning.
c5564e64: redo IO to allow files
Timestamps matter here: 3246e2a6 commits at 16:41 on 2023-06-01;
c5564e64 commits at 09:38 the next day, 2023-06-02 β about seventeen
hours, not "two days," and it's the very next work session, not a
later return to old business. "Redo IO to allow files" isn't a bug fix
to something that used to work; it's the first attempt to actually
implement the two primitives that shipped as ERR() stubs.
The problem the flat-primitive design runs into: IO.readFile/
IO.writeFile as declared have no place to put an open file β there is
no runtime representation for "a file that's been opened but not yet
fully read or closed." c5564e64 adds one: a new node tag, HDL,
whose payload is a raw C FILE * (c5564e64:eval.c, the union gains a
FILE *file field addressed as HANDLE(p)), joining INT/CHAR as
one more already-a-value tag the ordinary reducer just returns. Every
IO primitive that used to assume stdin/stdout now takes an explicit
handle argument β IO.getChar/IO.putChar/IO.print become
hGetChar/hPutChar/hPrint, opening a file produces one of these
handle nodes, and putChar/getChar/print survive as thin wrappers
applying the h-prefixed primitive to a built-in stdin/stdout node
(c5564e64:IO.uhs, quoted):
openFile :: FilePath -> IOMode -> IO Handle
openFile p m =
let
n = case m of
ReadMode -> 0
WriteMode -> 1
AppendMode -> 2
ReadWriteMode -> 3
in (primitive "IO.open") p n
putChar :: Char -> IO ()
putChar = hPutChar stdout
writeFile :: FilePath -> String -> IO ()
writeFile p s = IO.do
h <- openFile p WriteMode
hPutStr h s
hClose h
writeFile is no longer a primitive at all β it's openFile +
hPutStr (itself mapM_ (hPutChar h)) + hClose, three primitives
composed in the Prelude rather than one primitive doing everything.
IO_OPEN's C side needs a C string to hand to fopen, and the runtime
has no string type β a String is a Scott-encoded cons-list of Char
nodes, chapter 03's d91b9812/ddb1bb29 machinery, meaningful only by
convention. c5564e64 adds evalstring, a new helper that walks that
convention by hand, forcing each cons cell and copying its character
into a fixed 10000-byte C buffer, with its own comment admitting the
compromise (c5564e64:eval.c, quoted whole):
/* XXX this is cheating, should use continuations */
char *
evalstring(NODEPTR n)
{
size_t sz = 10000;
char *p, *name = malloc(sz);
if (!name)
ERR("evalstring malloc");
for (p = name;;) {
if (p >= name + sz)
ERR("evalstring too long");
n = evali(n);
if (TAG(n) == K)
break;
else if (TAG(n) == AP && TAG(FUN(n)) == AP && TAG(FUN(FUN(n))) == O) {
*p++ = evalchar(ARG(FUN(n)));
n = ARG(n);
} else {
ERR("evalstring not Nil/Cons");
}
}
*p = 0;
return name;
}
A file path longer than 10000 characters is an ERR, not handled
gracefully β a real, present limitation, left as-is. This is
design-by-rewrite in miniature: the first cut declared a shape for file
IO (readFile, writeFile, two primitives) without a representation
underneath it, discovered that on first real use the next morning, and
replaced the shape itself β handles as first-class values, every
console/file operation parameterized over one β rather than patching
around the gap. (readFile itself doesn't reappear until a3355632,
"More functions.", later the same day, built from openFile + a new
hGetContents that reads until IO.getChar returns a CHAR node
holding -1 β getc's own EOF sentinel, stored and compared with
nothing more than an Int equality check, ord c == negate 1; there
is no distinct end-of-file representation anywhere in this system.)
fe22efbd: recursive let, and where the "was it recursive" question actually gets answered
Chapter 03's 5ccb1b2a desugars let as strictly sequential,
non-recursive lambda application, with the comment "For now, just
sequential bindings." fe22efbd ("Make let bindings recursive, and
optimise the non-recursive case") makes good on that "for now" β every
let-bound name, without exception, is now visible to its own
right-hand side, via the fixed-point combinator
(aux/y-combinator-and-recursion.md).
The desugaring changes by inserting one Y per binding
(fe22efbd:Desugar.hs, line 41):
def (i, d) a = App (Lam i a) (App (Comb "Y") (Lam i d))
let i = d in a becomes (\i -> a) (Y (\i -> d)) β every binding,
whether or not d actually mentions i. This is the aux doc's
mechanism (Y f = f (Y f), one self-looped graph cell, case Y in
eval.c) applied uniformly, which means a non-recursive let β the
overwhelming common case β pays for a Y cell it doesn't need, unless
something notices. The same commit adds exactly one more rule to
Exp.hs's improveT, the post-compilation cleanup pass
(fe22efbd:Exp.hs, lines 159β165, quoted whole):
improveT :: Exp -> Exp
improveT (App f a) =
case (improveT f, improveT a) of
(CK, CI) -> CT
(CY, App CK e) -> e
(e1, e2) -> App e1 e2
improveT e = e
The interesting part is how "was d recursive" gets answered here β
not by a free-variable scan over the surface syntax before desugaring,
but for free, after the fact, from bracket abstraction's own smart
constructors. Bracket-abstracting \i -> d when d doesn't mention
i bottoms out in abstract's catch-all case, App CK d β and cS's
existing rule from chapter 02 (cS (App CK e1) (App CK e2) = cK (App e1 e2)) means any closed subterm collapses to a single top-level K
wrapper regardless of its internal shape. So a non-recursive binding's
compiled form is always, mechanically, Y (K d') for some d' β and
Y (K e) = K e (Y (K e)) = e by Y's own defining equation, one step,
no cycle needed. improveT's new line recognizes exactly that shape
and replaces it with e directly, so the runtime never builds the
self-looped cell for a binding that never needed one β the recursive/
non-recursive distinction is discovered, not declared.
967b92c0: more data types, same combinators
967b92c0 ("More data types.") adds Maybe and Either to
Prelude.uhs, plus showMaybe/showEither/showBool to
String.uhs β and touches nothing else. (git show 967b92c0 --stat
confirms: two .uhs files, zero lines of eval.c, Desugar.hs, or
Exp.hs.) That's possible because the mechanism a data declaration
compiles to hasn't changed since chapter 02, and this commit is a clean
instance of it. Desugar.hs's handling, unchanged at this commit
(967b92c0:Desugar.hs, lines 17β22, quoted whole):
dsDef (Data _ cs) = zipWith dsConstr [0..] cs
where
fs = [f i | (i, _) <- zip [0..] cs]
dsConstr i (c, ts) = (c, lams xs $ lams fs $ apps (Var (f i)) (map Var xs))
where xs = ["$x" ++ show (j::Int) | (j, _) <- zip [0..] ts]
f i = "$f" ++ show (i::Int)
Each constructor at declaration-index i, with fields ts, compiles
to a function of its own field values (xs) followed by one
continuation per constructor of the type (fs, one $f0, $f1, ...
per entry in cs) β which, once all of that is supplied, applies
its own continuation, f i, to its own fields and ignores every
other continuation entirely. data Maybe a = Nothing | Just a
compiles Nothing to \$f0 $f1 -> $f0 and Just to \x $f0 $f1 -> $f1 x β a value of the type doesn't carry a tag to inspect; it is a
dispatcher, waiting to be handed as many continuations as the type has
constructors and calling exactly one of them. This is Scott encoding,
the same trick chapter 02 used for hand-built tuple/list constructors
and chapter 03's 8939d55b/d91b9812 reused for the tuple and list
syntax β 967b92c0 is the first commit in this chapter's range to
apply it to genuinely new user-facing sum types, and it costs nothing
below Desugar.hs because the mechanism was already fully general.
ECase's desugaring is the dual of this: it applies the scrutinee to
its case arms' bodies, as continuations, in the order the arms are
textually written β a fact dsDef's constructor-index numbering
depends on silently matching (see Try It, Exercise 1, for what happens
when it doesn't).
d2956ee8: optimizing Y better, and closing the aux doc's promise
fe22efbd's improveT rule catches Y (K e) β but only when that
exact shape survives, structurally intact, all the way to the top of
a fully bracket-abstracted term, because improveT is a separate pass
that runs once, after compile finishes, pattern-matching on the
finished term (compileOpt = improveT . compile). A let sitting
inside an enclosing lambda β which is nearly every let in practice, a
top-level function with even one parameter already being one lambda β
doesn't reach improveT in that shape. The outer lambda's own
bracket abstraction absorbs the inner Y (K e) and restructures it
through cS/cB on the way to producing the outer function, and by
the time that's finished, the term is B Y (B K e), not App CY (App CK e) β a shape improveT's one rule never matches, so the Y cell
ships to the runtime unoptimized.
d2956ee8 ("Optimize Y better.") fixes this by moving the recognition
into bracket abstraction's own machinery, so it fires bottom-up, during
compilation, before the restructuring happens β one new line in cB,
the smart constructor for the B combinator (d2956ee8:Exp.hs, lines
99β104, quoted whole):
cB :: Exp -> Exp -> Exp
cB CC (App CC CI) = CP -- Pair
cB (App CB CK) CP = CO -- Cons
cB CY (App2 CB CK e) = e -- B Y (B K e) = e
cB CI e = e -- B I e = e
cB e1 e2 = App2 CB e1 e2
Verified directly: compiling the identical program
module YOpt where
import Prelude
import IO
f :: Int -> Int
f x =
let
y = x + 1
in y + y
main = print (f 10)
at 967b92c0 (before this optimization) and at d2956ee8 (after)
gives two different compiled bodies for f:
967b92c0: YOpt.f = B (S Prelude.+ I) (B Y (B K (Prelude.+ 1)))
d2956ee8: YOpt.f = B (S Prelude.+ I) (Prelude.+ 1)
The Y is gone entirely in the second β not shrunk, eliminated β and
both versions still run to the same answer, 22, with the d2956ee8
build doing it in fewer reductions (340 against 351 for the identical
computation, both measured with ./eval's reduction counter). The
non-recursive let inside f's body no longer costs a self-looped
graph cell at runtime, regardless of how deeply it's nested under other
lambdas β which is exactly what the aux doc, closing with "commit
d2956ee8 ... revisits this implementation. What it changes is out of
scope here; chapter 04 covers it.", promised. The same commit also
migrates List.uhs's map/filter/foldr and IO.uhs's mapM_
from the explicit fix (\ rec arg -> ...) idiom 4c4229ec/c5564e64
had been using to plain let rec = ... in rec β the library code
starts using the language feature the optimization just made free,
in the same commit that made it free.
b1e84cf7 and 8c5fbb1a: serialization and deserialization
Main.hs's writeFile "out.comb" $ toStringP res (chapter 02/03's
mechanism, unchanged) and eval.c's print/pp (used to echo a
program before and after reduction) both already exist. IO.print
(later renamed IO.serialize) is nothing more than wiring that same
print function up as a runtime IO primitive β dump any live graph
value, not just the whole compiled program at start-up, to a file
handle, using the identical textual format ($name primitive
references, :N/_N sharing labels). That wiring already existed
before this chapter's range (IO.print/hPrint, c5564e64); what's
new is making it survive contact with values that contain handles, and
adding the reverse direction.
b1e84cf7 ("Handle serialization of standard handles.") fixes a narrow
gap: before it, any HDL node reaching print's switch was an
outright error (1a2f7b7a:eval.c, line 468: case HDL: ERR("Cannot serialize handles");). b1e84cf7 recognizes exactly the three
well-known handles by C pointer identity and prints them as named
primitives instead (b1e84cf7:eval.c, lines 483β492, quoted whole):
case HDL:
if (HANDLE(n) == stdin)
fprintf(f, "$IO.stdin");
else if (HANDLE(n) == stdout)
fprintf(f, "$IO.stdout");
else if (HANDLE(n) == stderr)
fprintf(f, "$IO.stderr");
else
ERR("Cannot serialize handles");
break;
Any freshly openFiled handle is still unserializable β the commit
title says "standard handles," not "handles," and the code matches it
exactly.
8c5fbb1a ("Add deserialization.") adds the reverse primitive,
IO.deserialize, by calling parse() β the very function that reads
the compiled .comb program at start-up β directly, on a live file
handle, mid-run (8c5fbb1a:eval.c, lines 965β970, quoted whole):
case IO_DESERIALIZE:
CHECKIO(1);
hdl = evalhandle(ARG(TOP(1)));
gc(); /* parser runs without GC */
n = parse(hdl);
RETIO(n);
What "serialize a running program's graph" means concretely, then, is:
the exact same reader that bootstraps a program from its .comb file
is reachable as a primitive from inside a running program, pointed at
an arbitrary handle instead of the startup file. A value written with
IO.serialize and read back with IO.deserialize round-trips through
the same :N/_N label syntax chapter 02 established for
whole-program linking β which, as Try It below shows directly, is
also how a genuinely cyclic value survives the trip: :N/_N isn't
just an optimization for repeated subterms, it's the only way this
text format can express a graph that loops back on itself at all.
But parse()'s :/_ cases both read and write through one global,
NODEPTR *shared, and that global is allocated, zeroed, and then freed
β once, at start-up, by the function that reads the compiled program
(8c5fbb1a:eval.c, lines 447β456, quoted whole):
NODEPTR
parse_top(FILE *f)
{
shared = malloc(heap_size * sizeof(NODEPTR));
for(int i = 0; i < heap_size; i++)
shared[i] = NIL;
NODEPTR n = parse(f);
free(shared);
return n;
}
IO_DESERIALIZE calls parse() directly, not parse_top() β it never
reallocates shared, so it's dereferencing memory that was freed
before main ever started running evalio. Deserializing a value with
no :/_ labels at all (nothing shared, nothing cyclic) never touches
shared[] and works fine; deserializing anything that does use a
label β including every cyclic value, unavoidably β is a use-after-free.
Verified directly (Try It, Exercise 3): it segfaults, consistently,
every time.
Three more commits land the same day and are chapter 05's story, not
this one's: ef082bbe/8c66d4dd/c0fb9a91 add independent -v
verbosity flags to both uhs (the compiler) and eval (the runtime),
and b0915dcf moves the library .uhs modules into a lib/
subdirectory and adds a -i<dir> module search path β both show up
mechanically in Try It below (eval needs -v to print its
reduction-count trailer at all now; uhs needs -ilib to find
Prelude/List/IO/String), without being this chapter's subject.
Why it matters
Every extension in this chapter lands on the architecture chapters
01β03 already built, without changing that architecture's shape. An
IO a is not a new kind of runtime value β it's a graph node with
one of eight new tags, opaque to the ordinary reducer, meaningful only
to a second small interpreter (evalio) layered on top of the same
graph. A Maybe/Either value is not a new kind of runtime value
either β it's the same Scott-encoding trick chapter 02 already
generalized for tuples and lists, applied to two more sum types with
zero lines of eval.c changed (967b92c0's diff is Prelude.uhs and
String.uhs, nothing else). "More combinators, plus (for IO
specifically) one more small interpreter that gives some of those
combinators meaning" absorbs both features without touching the core
loop eval() that's been running since chapter 01's ccb31388.
Redoing IO seventeen hours after shipping it is what design-by-rewrite
looks like when it's working as intended: the first cut declared file
IO as two primitives (readFile, writeFile) it hadn't yet figured
out how to back with a real representation, shipped it anyway with
ERR() stubs rather than pretending it worked, and the very next
session β once files were actually needed β replaced the shape of
the design (flat primitives with an implicit stdin/stdout) with one
that had a place to put an open file (HDL nodes, every primitive
taking an explicit handle) rather than patching the old shape into
working. And d2956ee8's optimization is the same lesson at smaller
scale: fe22efbd shipped a correct but naive recursive let (Y
around every binding, no exceptions) with one narrow-scope cleanup
already in place; d2956ee8, in the same sitting that generalized the
cleanup, immediately went and rewrote List.uhs/IO.uhs's own
fix (\ rec -> ...) idioms to plain recursive let β proof, in the
same commit, that the newly-cheap feature was trusted enough to replace
the manual workaround it was cheap enough to obsolete.
Notebook margin
4c4229ec("Fix bugs and improve.") and7bb30b64("Minor improvements.") land the same evening as3246e2a6, about forty-four minutes and about an hour later respectively βELamgeneralized to multi-argument lambdas,fix = primitive "Y"added as a named function,print/pprefactored to share aprintrechelper. Neither touches theIO_READFILE/IO_WRITEFILEstubs; those wait for the next morning'sc5564e64.8b39ba55("Remove junk.") deletesLam.hsoutright β all 233 lines of it, the entire hand-embedded-Haskell prototype dating toccb31388(touched only lightly since, by two commits,6485a341anda9178695) that aux/y-combinator-and-recursion.md and chapters 01β02 walked through, superseded since chapter 03's95ff6a34/b25d9fc8gave the parser-drivenMain.hsa real Prelude to replace it with. It's its own commit, not bundled with any feature β cleanup gets to be first-class work, done once there's enough of it to be worth doing.- do-block
let(3246e2a6) and expressionlet(5ccb1b2a,fe22efbd) are spelled identically but desugar differently for the rest of this chapter's range: only the expression form goes throughfe22efbd's unconditionalY-wrap-then-optimize; aletwritten insideM.do { ... }stays the original, simple, non-recursive lambda application all the way through8c5fbb1a. Nothing in this range unifies the two. a3355632'sreadFilehas no distinct end-of-file value βhGetCharhands back whatever rawgetc()returned, including-1at EOF, packed into an ordinaryCHARnode, andhGetContentsstops whenord c == negate 1. The C convention leaks straight through.
Try it
Anchor: 8c5fbb1a ("Add deserialization."). Convention (see
00-overview.md):
git worktree add /tmp/mhs-8c5fbb1a 8c5fbb1a
cd /tmp/mhs-8c5fbb1a
Build note: this commit's
Makefilefinally has aMain:target (ghc Main.hs -o Main, added by3246e2a6), but it's still a bareghcinvocation and hits the same GHC-9.8/mtlissues chapters 02 and 03's Build notes cover, so build by hand with-o uhsto keep the binary name consistent with earlier chapters:nix develop $MICROHS -c ghc Main.hs -o uhsfails first with
ParserComb.hs'sap-not-in-scope error (same as chapters 02/03); add, afterParserComb.hs's existingimport Control.Monad.State.Strict:import Control.Monad(ap)then with
Compile.hs'swhen-not-in-scope error (same as chapter 03); add, afterCompile.hs's existingimport Control.Monad.State.Strict:import Control.Monad(when)With both one-line patches, the build succeeds (harmless
-Wx-partialwarnings abouthead/tailomitted):nix develop $MICROHS -c ghc Main.hs -o uhs[1 of 7] Compiling ParserComb ( ParserComb.hs, ParserComb.o ) [2 of 7] Compiling Parse ( Parse.hs, Parse.o ) [3 of 7] Compiling Exp ( Exp.hs, Exp.o ) [4 of 7] Compiling Desugar ( Desugar.hs, Desugar.o ) [5 of 7] Compiling Compile ( Compile.hs, Compile.o ) [6 of 7] Compiling Main ( Main.hs, Main.o ) [7 of 7] Linking uhsBuild the runtime the same way chapters 02/03 do:
nix develop $MICROHS -c gcc -Wall -O3 eval.c -o evalTwo more things are new to this commit's range and matter for every command below.
b0915dcfmovedPrelude.uhs/List.uhs/IO.uhs/String.uhsinto alib/subdirectory and added a-i<dir>module search path (Compile.hs'spathfield) β every./uhsinvocation below passes-ilibsoimport Prelude/import IO/etc. resolve. Andef082bbe/8c66d4dd/c0fb9a91gatedeval's own diagnostic trailer (node size=...,N reductions, M GCs) behind a-vflag that didn't exist in chapters 02/03 β by default./evalnow prints only the program's own output, nothing else; pass-v(as./eval -v out.comb) to see reduction counts.
Exercise 1: read a file, transform it, print the result
printf 'hello world\nfrom a file\n' > greeting.txt
cat > Shout.uhs <<'EOF'
module Shout where
import Prelude
import List
import IO
upc :: Char -> Char
upc c =
case (ord c >= ord 'a') && (ord c <= ord 'z') of
True -> chr (ord c - 32)
False -> c
main = IO.do
s <- readFile "greeting.txt"
putStr (map upc s)
EOF
nix develop $MICROHS -c ./uhs -ilib Shout
nix develop $MICROHS -c ./eval out.comb
hello worldοΏ½from a fileοΏ½
Not what was wanted, and not a typo above β a genuine dead end.
upc's case writes its arms True first, False second, but
Prelude.uhs declares data Bool = False | True, making False the
constructor at index 0 and True the constructor at index 1. Per
"What happened" above (967b92c0), dsExpr (ECase e as) = apps (dsExpr e) (map dsArm as) applies the scrutinee to its arms'
compiled bodies positionally, in the order they're written β nothing
checks that written order against declaration order. Writing True
first means the first continuation supplied is the one meant for
True, but a Bool value returns its declaration-index-0
continuation (False's) first β so every character, whatever upc's
condition actually evaluates to, runs the arm written first. Every
space (ord ' ' = 32) got chr (32 - 32) = the NUL byte; every
newline (ord '\n' = 10) got chr (10 - 32) = byte 0xEA β visibly
wrong once printed, confirmed with od:
$ nix develop $MICROHS -c ./eval out.comb | od -An -tx1 | head -1
68 65 6c 6c 6f 00 77 6f 72 6c 64 ea 66 72 6f 6d
Swap the arms to match Bool's actual declaration order β False
first, True second β and rebuild:
cat > Shout.uhs <<'EOF'
module Shout where
import Prelude
import List
import IO
upc :: Char -> Char
upc c =
case (ord c >= ord 'a') && (ord c <= ord 'z') of
False -> c
True -> chr (ord c - 32)
main = IO.do
s <- readFile "greeting.txt"
putStr (map upc s)
EOF
nix develop $MICROHS -c ./uhs -ilib Shout
nix develop $MICROHS -c ./eval out.comb
HELLO WORLD
FROM A FILE
Exercise 2: read the sharing labels out of out.comb
Exercise 1's out.comb (the corrected version) is 2792 bytes, one
line, 81 top-level definitions (Prelude, List, IO, and Shout
linked together). Its opening (byte-exact, ... eliding everything
after the fourth definition):
(($T :0 (($B $K) (($B $K) $K))) (($T :1 ($K (($B $K) $K))) (($T :2 ($K ($K $K))) (($T :3 ($K ($K $T))) (($T :4 $IO.>>=) ...
:0 through :3 and :4 ($IO.>>=) are top-level definitions in
source-module-linking order β exactly chapter 02's 17587b2d label
scheme, unchanged. A concrete back-reference, from further into the
same file:
... (($T :16 (_7 _13)) (($T :17 (_8 _12)) (($T :18 (_9 _13)) ...
:16 (which turns out to be IO.putChar's stdout-bound wrapper,
i.e. putChar) is defined as _7 _13 β a reference to definition 7
(IO.putChar) applied to a reference to definition 13 (IO.stdout),
reusing their labels rather than repeating their bodies.
It's worth being precise about what kind of "sharing" this is,
because this chapter also produces a different kind. Every one of
these :N labels is assigned unconditionally, one per top-level
definition, by Main.hs itself (def ((_, e), i) r = App2 CT (Lbl i (subst e)) r, iterating zip ds' [0..] over every definition
whether or not anything else ever references it) β a static,
positional scheme, decided at compile time from source order. Contrast
that with what the runtime's own IO.serialize produces, which uses
find_sharing's two-pass graph walk (mark on first visit, promote to
shared on second) to label only nodes actually visited more than
once while printing a live value β dynamically, from the graph's
actual shape, nothing to do with source order. A plain list with no
repeated substructure gets no labels at all:
cat > PlainSer.uhs <<'EOF'
module PlainSer where
import Prelude
import List
import IO
main = IO.do
writeSerialized "plain.ser" [1, 2, 3]
v <- readSerialized "plain.ser"
print v
EOF
nix develop $MICROHS -c ./uhs -ilib PlainSer
nix develop $MICROHS -c ./eval out.comb
echo "exit: $?"
cat plain.ser
(($O 1) (($O 2) (($O 3) $K)))
exit: 0
(($O 1) (($O 2) (($O 3) $K)))
(the run exits cleanly, and both the runtime's own printed result and
the file content are the same text, $O/$K all the way down, no
:/_ anywhere.) Reusing the same let-bound list twice, so the traversal
that writes it actually visits one node more than once, does produce a
label:
cat > ShareDemo.uhs <<'EOF'
module ShareDemo where
import Prelude
import List
import IO
main = IO.do
let
xs = [1, 2, 3]
in writeSerialized "share.ser" (xs, xs)
EOF
nix develop $MICROHS -c ./uhs -ilib ShareDemo
nix develop $MICROHS -c ./eval out.comb
cat share.ser
(($P :99474 (($O 1) (($O 2) (($O 3) $K)))) ($I _99474))
One label, :99474 (a heap-cell index, hence the large arbitrary
number β nothing like the small sequential :0/:1/... Main.hs
assigns), marking the list xs the first time the pair's traversal
reaches it; _99474 the second time, because both halves of (xs, xs) are the same heap cell (one let-bound thunk, used twice), not
two equal-looking copies.
Exercise 3: tie a knot
take exists as of 72d4d093 (List.uhs), so a cyclic list can be
observed through a finite prefix:
cat > Knot.uhs <<'EOF'
module Knot where
import Prelude
import List
import String
import IO
main = IO.do
let
xs = 1 : xs
in putStrLn (showList showInt (take 5 xs))
EOF
nix develop $MICROHS -c ./uhs -ilib Knot
nix develop $MICROHS -c ./eval out.comb
[1,1,1,1,1]
fe22efbd's unconditional Y-wrap applies to any let, not just
function definitions β xs = 1 : xs builds a genuinely self-looped
graph cell the same way nfib's Y does in
aux/y-combinator-and-recursion.md,
and take 5 walks five steps into it and stops, never noticing the
list has no end. Serializing xs directly (not through take, the
whole infinite structure) shows the cycle in the label syntax itself:
cat > KnotSer.uhs <<'EOF'
module KnotSer where
import Prelude
import List
import IO
main = IO.do
let
xs = 1 : xs
in writeSerialized "knot.ser" xs
EOF
nix develop $MICROHS -c ./uhs -ilib KnotSer
nix develop $MICROHS -c ./eval out.comb
cat knot.ser
:99731 (($O 1) _99731)
This terminates β find_sharing's mark-based traversal handles a
cyclic graph the same way eval.c's garbage collector always has,
stopping at nodes it's already visited rather than following AP
pointers forever β and the result says exactly what the graph is:
definition :99731 is the cons cell (O 1 _99731), and _99731
inside it refers back to the very node being defined. Not two equal
cons cells; one cell, pointing at itself.
Reading it back is where this commit's range runs out of runway. Deserializing this exact file segfaults:
cat > KnotRead.uhs <<'EOF'
module KnotRead where
import Prelude
import List
import String
import IO
main = IO.do
ys <- readSerialized "knot.ser"
putStrLn (showList showInt (take 5 ys))
EOF
nix develop $MICROHS -c ./uhs -ilib KnotRead
nix develop $MICROHS -c ./eval out.comb
echo "exit: $?"
exit: 139
(139 = 128 + SIGSEGV.) Per "What happened" above, this isn't specific
to cycles β it's any use of the :/_ label syntax at all.
IO_DESERIALIZE calls parse() directly, and parse()'s :/_
cases read and write the global shared pointer that parse_top()
allocated once, at start-up, and already free()d before evalio
ever ran. Confirmed by trying the non-cyclic share.ser from
Exercise 2 the same way β one shared node, no cycle, same crash:
cat > ShareRead.uhs <<'EOF'
module ShareRead where
import Prelude
import List
import IO
main = IO.do
v <- readSerialized "share.ser"
print v
EOF
nix develop $MICROHS -c ./uhs -ilib ShareRead
nix develop $MICROHS -c ./eval out.comb
echo "exit: $?"
exit: 139
versus the earlier, un-shared [1,2,3] value from writeSerialized
(no :/_ in its file at all β see Exercise 2), which deserializes
without incident. At 8c5fbb1a, IO.serialize can write a genuine
cycle to a file and IO.deserialize exists as a primitive β but
reading anything that actually needs the label mechanism back crashes
the runtime, every time, reproducibly. That is the real, current state
of this feature at this commit, not a hypothetical.
Clean up:
git worktree remove --force /tmp/mhs-8c5fbb1a
05 β Lab notebook: the working habits
Where we are
Chapter 04 ends at 8c5fbb1a ("Add deserialization.") with monadic IO, a
working IO.serialize/IO.deserialize pair, and a documented,
reproducible crash the moment deserialization touches a shared or
cyclic label. But 8c5fbb1a is not the first commit of this session β
by the time it lands (16:17:09 on 2023-06-02), four other commits have
already gone in, earlier the same afternoon: ef082bbe (14:23:17),
8c66d4dd (14:28:31), c0fb9a91 (14:30:13), and b0915dcf
(16:16:53, sixteen seconds before 8c5fbb1a itself). Chapter 04 named
all four and explicitly deferred them: "Three more commits land the
same day and are chapter 05's story, not this one's: ef082bbe/8c66d4dd/c0fb9a91
add independent -v verbosity flags to both uhs (the compiler) and
eval (the runtime), and b0915dcf moves the library .uhs modules
into a lib/ subdirectory and adds a -i<dir> module search path
..." None of the four touch IO, serialization, or any
other feature chapter 04 was tracking β they're tooling, and this
chapter is where tooling gets its due.
So this chapter's material spans two things at once: those four
commits (which precede 8c5fbb1a in the commit graph, git log --oneline --reverse ccb31388..8018eec8 puts them at positions 40β43,
8c5fbb1a at 44), and the fifteen commits of the range
00-overview.md's timeline table actually assigns this
chapter, 8c5fbb1a..8018eec8
(6c59dd36 through 8018eec8, all landing between 16:23 and 19:10 the
same evening β under three hours). By the end of that evening the
project has bin/uhs and bin/eval as the binaries' real names
(no longer Main), a lib/ directory holding four modules with a
-i search path to find them, .hs extensions throughout (renamed
from .uhs), a three-module automated test harness wired into make test, a first README.md, and a -H<N> flag to size the heap at
run time instead of only at compile time. None of it is a new language
feature. All of it is what makes the previous four chapters' features
usable, checkable, and explicable to someone who isn't the author β and
that shift, from "make it work" to "make it a project," is what this
chapter tracks.
What happened
Habit 1: observability first β ef082bbe, 8c66d4dd, c0fb9a91
Three commits, seven minutes apart, all on the afternoon of 2023-06-02, each adding one more layer of "show me what's happening":
ef082bbe ("Use a flag to be verbose.") gates eval's existing
diagnostic trailer β the main returns ... dump, the node size=...
line, the reduction/GC/cell counters β behind a new -v flag, off by
default. Before this commit that trailer printed unconditionally on
every run; a program's own output (its actual putChar/print
effects) was always followed by internal instrumentation nobody but the
author, mid-debugging, wanted to see every time. The whole change is
one main, quoted whole (ef082bbe:eval.c, lines 989β1018):
int
main(int argc, char **argv)
{
int verbose = 0;
if (argc > 1 && strcmp(argv[1], "-v") == 0) {
argc--;
argv++;
verbose = 1;
}
if (argc != 2)
ERR("no file");
init_nodes();
FILE *f = fopen(argv[1], "r");
if (!f)
ERR("file not found");
NODEPTR n = parse_top(f);
fclose(f);
PUSH(n); gc(); n = TOP(0); POP(1);
int start_size = num_marked;
//pp(stdout, n);
n = evalio(n);
if (verbose) {
printf("\nmain returns ");
pp(stdout, n);
printf("node size=%ld, heap size=%ld\n", NODE_SIZE, heap_size);
printf("%d reductions, %d GCs, max cells used %d\n", num_reductions, num_gc, max_num_marked);
printf("%d cells at start\n", start_size);
}
exit(0);
}
Five minutes later, 8c66d4dd ("Add a -v flag.") does the same thing
on the compiler side, but it's a bigger change because compile never
had a verbosity parameter to gate on β it always printed. Compile.hs
grows a Bool argument threaded through three functions
(8c66d4dd:Compile.hs, lines 18β19 and 24, quoted):
compile :: Bool -> IdentModule -> IO [LDef]
compile verbose nm = do
compileModuleCached :: Bool -> IdentModule -> StateT Cache IO CModule
with every one of its three putStrLn "importing ..." trace lines
wrapped in when verbose $. Main.hs picks the flag off the command
line and passes it through (8c66d4dd:Main.hs, lines 8β15, quoted
whole):
main :: IO ()
main = do
args <- getArgs
let verbose = "-v" `elem` args
mn = case filter ((/= "-") . take 1) args of
[s] -> s
_ -> error "Usage: compile [-v] ModuleName"
ds <- compile verbose mn
and the same flag now also gates the pre-8c66d4dd behavior of dumping
every compiled definition's combinator form and the whole linked
program to stdout before writing out.comb β previously unconditional,
now when verbose $ do .... Two minutes after that, c0fb9a91 ("More
verbosity.") turns eval's one-shot -v into a counter and adds a
second level: the if (argc > 1 && ...) becomes a while loop so
-v -v is legal and increments verbose twice, and verbose > 1
gates a call to pp(stdout, n) β printing the entire loaded program
graph, before any reduction happens β that the previous commit had
left as a commented-out //pp(stdout, n); one line above where it now
runs uncommented (c0fb9a91:eval.c, lines 989β1008, quoted; the
if (verbose > 1) pp(stdout, n); on the last two lines is the whole
addition beyond widening verbose to an int):
int
main(int argc, char **argv)
{
int verbose = 0;
while (argc > 1 && strcmp(argv[1], "-v") == 0) {
argc--;
argv++;
verbose++;
}
if (argc != 2)
ERR("no file");
init_nodes();
FILE *f = fopen(argv[1], "r");
if (!f)
ERR("file not found");
NODEPTR n = parse_top(f);
fclose(f);
PUSH(n); gc(); n = TOP(0); POP(1);
int start_size = num_marked;
if (verbose > 1)
Three commits, three tools (eval twice, uhs once), one
pattern each time: something that used to print always, or never, now
prints on request, at a level the caller controls. Try It, Exercise 1
runs all three levels on the same program and shows exactly what each
one adds.
Habit 2: tests as you go β 6ac9c791, with roots two commits earlier
6ac9c791 ("Add some testing.") is not where automated testing in this
project starts from nothing β 9b6e23c8, two commits earlier the same
evening, already added tests/Fac.uhs (soon renamed tests/Fac.hs by
73a427d7), a small module computing fac 6 and fst res + snd res
where res = (fac 10, nfib 30). What 6ac9c791 actually is: a
Makefile loop that compiles each test module, runs it, and diffs the
output against a checked-in golden file β nothing more elaborate than
that. The whole harness, tests/Makefile at this commit, quoted whole:
UHS=../bin/uhs -i../lib
EVAL=../bin/eval
tests:
$(UHS) IOTest && (echo q | $(EVAL) out.comb > IOTest.out) && diff IOTest.ref IOTest.out
$(UHS) StringTest && $(EVAL) out.comb > StringTest.out && diff StringTest.ref StringTest.out
$(UHS) Fac && $(EVAL) out.comb > Fac.out && diff Fac.ref Fac.out
Three lines, three modules, three reference files β IOTest.ref,
StringTest.ref, and Fac.ref are all new in this commit, checked-in
alongside the Makefile lines that exercise them.
Each line compiles a module to out.comb, runs it, redirects stdout to
a .out file, and diffs that against the .ref file checked into
git; make's own && chaining means the recipe line β and the whole
make test β fails loudly the moment either the compile or the diff
does. There is no test framework: no assertion library, no test
runner beyond make itself, no structured pass/fail report beyond
diff's exit code and whatever it prints. IOTest's line has one
extra piece worth noticing, (echo q | $(EVAL) out.comb > IOTest.out)
β IOTest.hs calls getChar once, so running it non-interactively
needs something on stdin, and echo q supplies it (6ac9c791 also
renames IOTest.hs's scratch files, test.txt/test2.txt, to
test.tmp/test2.tmp, matching a .gitignore pattern for build
artifacts the next commit, 110bcc94, adds). The .ref/.out/diff
shape here β golden output, byte comparison, no tolerance for drift β
is exactly the harness Try It, Exercise 2 extends with one more test in
the same style.
Habit 3: write the README when the project becomes real β 37e44acd and same-day "More text"
37e44acd ("Add README") is the first README.md this project has
ever had β fifty commits and just under eight days after ccb31388. It is
three lines, quoted in full (37e44acd:README.md):
# Micro Haskell
This directory contains an implementation of a very small subset of Haskell.
It uses combinators for the runtime execution.
That's the whole commit β one new file, three insertions, nothing else
touched. What's notable is less the content than the timing: it lands
at 16:44:57, immediately after the test harness (6ac9c791,
110bcc94) is in place and passing, and well after the language,
compiler, and runtime already do real work. The README doesn't
introduce the project β it documents a project that has, by this point,
already earned documentation.
It then grows in four more commits the same evening, each named "More text" or its close variants, and the order the author fills it in is itself informative:
aae0eee2(17:01:29, "More text.") adds## Language(a bullet list of what the language has: variables, application, lambda without pattern matching,case/letwith their own listed restrictions, tuples, list syntax, qualifieddo,datadeclarations, "type signatures that are ignored") and## Compilerwith a one-line description of each of the six compiler modules. The very first substantive content after the three-line intro is a list of what the language can't do yet, stated as plainly as what it can.04f0840b(17:09:37, "More text.") adds a new file,Example.hs(afacfunction plus amainthat prints four factorials), and a## Examplesection in the README that reproduces that file's source verbatim inside a fenced code block, followed by build instructions ("make sure the binaries are built... compile the file bybin/uhs Example... run the combinator file bybin/eval out.comb").91a8ff34(17:11:02, "More text") tightens that prose ("The compile" β "Then compile", "Run" β "Finally, run") and adds the one thing the previous commit left out: the actual expected output,Some factorials/[1,2,6,3628800].0bfdcd18(17:14:21, "Fix some mistakes.") fixes the build command itself βbin/uhs Examplebecomesbin/uhs -ilib Example, because without-ilibthe compiler can't findPrelude/List/IO/Stringin their newlib/home (b0915dcf's path flag, this chapter's opening habit). The README's own worked example didn't actually run as first written; the fix lands under five minutes later, the next commit touching the file.
Two more "More text" commits land later that evening once other
feature work (the heap-size flag, below) is done: 60343934
(18:35:10) adds a ### Features note about serialize/deserialize
preserving sharing and cycles, closing on its one caveat ("The
only exception to this is file handles, which cannot be serialized");
3cdbb820 (18:51:39) adds a ## Libraries paragraph. So the order
the author wrote these sections in β commit order, not the order
they sit in the finished README β is: what the language is
β how the compiler is organized β a runnable worked example, corrected
once it's shown to not actually run β what the runtime can serialize,
caveat included β what the libraries cover. Language and workflow
first; the newest, most delicate feature (cyclic serialization) last,
and honestly caveated rather than oversold.
Habit 4: rename late β 73a427d7
Before 73a427d7 ("Rename files to .hs"), every source file in this
project β Prelude, List, IO, String, the test modules β carries
the extension .uhs, not .hs. 73a427d7's diffstat is the whole
story (73a427d7 --stat, quoted):
Compile.hs | 2 +-
Makefile | 5 ++++-
lib/{IO.uhs => IO.hs} | 0
lib/{List.uhs => List.hs} | 0
lib/{Prelude.uhs => Prelude.hs} | 0
lib/{String.uhs => String.hs} | 0
tests/{Fac.uhs => Fac.hs} | 0
tests/{IOTest.uhs => IOTest.hs} | 0
tests/{ListTest.uhs => ListTest.hs} | 0
tests/Makefile | 5 +++++
tests/{StringTest.uhs => StringTest.hs} | 0
11 files changed, 10 insertions(+), 2 deletions(-)
Eight files renamed with 0 lines changed β pure git mv, no content
touched. The other three lines in the diffstat, Compile.hs,
Makefile, and tests/Makefile, carry the actual code: one line in
Compile.hs, where the hardcoded extension the module loader appends
to a module name flips from .uhs to .hs (compileModule's let fn = nm ++ ".uhs" becomes let fn = nm ++ ".hs"), plus a test: target
added to the top-level Makefile; tests/Makefile itself is new here
too β a five-line file with one rule, $(UHS) IOTest.hs && $(EVAL) out.comb > IOTest.out && diff IOTest.ref IOTest.out β the test
harness's very first form, refined into the three-module loop Habit 2
describes by the very next commit, 6ac9c791.
uhs β "micro Haskell," or close to it, the name
the compiler binary itself keeps for the rest of this chapter's range
(bin/uhs, set one commit earlier by 9b6e23c8) β was, until this
commit, also the extension every source file carried, a private
in-house dialect marker. The rename happens after do-notation, IO,
serialization, and Maybe/Either are all in place β after four
chapters of "how close is this to Haskell" have already been answered,
mostly, "close" β and, within this chapter's own session, before the
README (37e44acd, eleven minutes later) that will go on to describe
the result. The file extension is the last thing about the language
itself to change, not the first: it follows the language's own claim
on the name, rather than asserting it in advance.
Habit 5: knobs for real constraints β 0b97ab3e
0b97ab3e ("Add a flag to set the heap size.") turns eval's heap
capacity from a value fixed at compile time β #define HEAP_CELLS 100000, unchanged by this commit, still the default β into something
overridable per run, with a new -H<N> flag (0b97ab3e:eval.c, lines
1005β1021, quoted):
int
main(int argc, char **argv)
{
int verbose = 0;
char *fn = 0;
argc--, argv++;
while (argc > 0 && argv[0][0] == '-') {
if (strcmp(argv[0], "-v") == 0)
verbose++;
else if (strncmp(argv[0], "-H", 2) == 0)
heap_size = atoi(&argv[0][2]);
else
ERR("Bad flag");
argc--;
argv++;
}
The same commit also generalizes argument parsing from "exactly one
positional argument, the file name" to "zero or one, defaulting to
out.comb" β a second, smaller convenience change riding along with
the flag.
What actually motivated it is less certain than the mechanism, and it's
worth being honest about that rather than inventing a tidy story: the
commit message is bare, and nothing in the diff or the surrounding
commits states a specific failure it fixes. Checked directly β every
run in this chapter's Try It and in the existing test suite stays well
under the 100000-cell default: the Try It program below (Exercise 1)
peaks at 140 cells; the harness's own Fac test, run separately at
this commit with -v, reports 164 cells at start and a peak of 202
cells mid-run, for its whole fac/nfib 35 computation β so nothing
in the committed test suite was visibly hitting the ceiling. Pushing on
it directly, in
a throwaway build at this commit, does turn up a real limit, just not
this one: a small Haskell-side program that eagerly builds a list via
ordinary self-recursion (n : upto (n - 1)) and sums it runs fine at
1,000 elements (500500, correct) and segfaults, reproducibly, at
5,000 (the exact threshold between the two wasn't narrowed further) β
a C call-stack overflow from non-tail recursion in eval's own graph
walk, not a heap-cell shortage, and -H does nothing for it. The
one connection the timeline does support: -H lands at 18:34:52, and
seventeen minutes later 3cdbb820 adds tests/Nfib.hs, a
deliberately heavier benchmark than anything run before it ("Typical
nfib/s is 10M", main = print (nfib 38), with a new make time target
built for it). Whether -H was written because of that heavier
workload or just shortly before planning it isn't something the
commits themselves settle β but a compile-time-only heap constant is
exactly the kind of thing that stops being convenient the moment the
programs being run start actually growing, and this session is visibly
in the middle of making them grow.
Habit 6: cleanup as first-class work β 6c59dd36, 9b6e23c8, 28d53dab
Three separate commits this evening do nothing but tidy, each on its own, not bundled with any feature:
6c59dd36 ("Update"), the very first commit of this chapter's
git-range proper, rewrites the top-level Makefile end to end. The
Makefile it replaces still reads (6c59dd36^:Makefile, quoted whole):
test: eval nfib.comb
./eval nfib.comb
eval: eval.c
gcc -Wall -O3 eval.c -o eval
nfib.comb: Lam
./Lam > nfib.comb
Main: *.hs
ghc Main.hs -o Main
clean:
rm -f *.hi *.o eval Lam *.comb
nfib.comb: Lam builds nfib.comb by running ./Lam, the
hand-embedded-Haskell prototype chapter 04's notebook margin records as
deleted outright by 8b39ba55 ("Remove junk.") β but the rule was
already dead before that deletion, and for longer: 3246e2a6
("Implement monadic IO."), the commit that opens chapter 04, had
already replaced the Makefile's Lam: Lam.hs / ghc Lam.hs -o Lam rule
with Main: *.hs / ghc Main.hs -o Main (visible above, both still
present) without touching or removing the nfib.comb: Lam line that
depended on the rule it had just replaced. From that moment β about
twenty-one hours before 8b39ba55 deletes Lam.hs itself, and just
under twenty-four hours before 6c59dd36 β there was no recipe left in the
Makefile that could produce a Lam binary at all; 8b39ba55 only
removed the source the (already unreachable) rule would have compiled.
6c59dd36 is the first commit to notice and remove the dead
nfib.comb/Lam lines, in the course of introducing a bin/ output
directory and moving the Main/eval targets under it as
$(BIN)/Main/$(BIN)/eval β the Mainβuhs rename itself is the
next commit's work.
9b6e23c8 ("Better names.") follows two minutes later and is the
commit that actually picks the name: the Makefile's $(BIN)/Main
target becomes $(BIN)/uhs; Main.hs's usage-error string, generic
and stale ("Usage: compile [-v] ModuleName" β "compile," not any
name this project's binaries actually carry), becomes "Usage: uhs [-v] [-iPATH] ModuleName" β naming the just-chosen binary, and
picking up the -iPATH flag b0915dcf had already added without
updating this message β and .gitignore drops its now-stale
Lam/eval/Main entries in favor of bin/*.
28d53dab ("Clean up"), the second-to-last commit of the evening, at
19:10:00, removes one function, restOfInput, from ParserComb.hs β
four lines plus its export β with no evidence anywhere in this
session's diffs that it was ever called. Its own commit, its own line
in the log, about eighteen minutes after the last feature commit
(3cdbb820) and seven seconds before the last one (8018eec8,
"Add null") β dead
code gets removed once there's a moment free to remove it, not folded
silently into whatever feature commit happened to be nearby.
Why it matters
None of the six habits above are specific to Micro Haskell, or to
compilers, or even to this particular language of choice. A verbosity
flag that's off by default and stacks with repetition (-v, -v -v)
is a decision anyone writing a CLI tool can make on day one, for free,
before there's anything worth being verbose about yet. A test harness
that's nothing more than "compile, run, diff against a checked-in
file, let make's own exit-code chaining decide pass or fail" needs no
framework, no dependency, and no more code than 6ac9c791's three
Makefile lines β the bar for "have automated tests at all" is far lower
than the bar most projects set for themselves before clearing it. A
README written after the thing it describes already works, rather than
as an aspirational plan beforehand, is a small discipline with an
outsized effect on whether the documentation stays honest β and this
session shows it isn't automatically honest even then: the worked
example in 04f0840b didn't actually run until 0bfdcd18 fixed it
under five minutes later, caught (one has to assume) by someone
actually trying the command as written. A file extension that only
changes once the code underneath it has grown into what the extension
claims is a small act of intellectual honesty most projects skip,
naming things aspirationally from the start instead. A knob for a
constant that used to require a rebuild to change costs a handful of
lines and pays for itself the first time someone needs a bigger number
without needing a compiler on hand. And cleanup β a stale Makefile
rule, a leftover .gitignore entry, one unused function β treated as
its own commit rather than swept in wherever convenient, is what keeps
a fast-moving project's incidental debt from compounding silently.
Every one of these is adoptable, unchanged, on the first day of any
project, in any language β which is the entire reason this chapter
exists in a book that is otherwise about how a specific compiler got
built.
Notebook margin
This chapter's margin is a fact about the whole project's history rather than a note on any one commit: which commit messages recur, and how. The exact command, and its real output:
$ git log --format=%s ccb31388..8018eec8 | sort | uniq -c | sort -rn | head
3 More text.
2 New optimiser.
1 Various small improvements.
1 Various little fixes,
1 Use 'primitive'
1 Use file handle for printing.
1 Use a flag to be verbose.
1 Update
1 Small improvements
1 Rename files to .hs
Every one of "More text."'s three occurrences is this chapter's README
sequence (aae0eee2, 04f0840b, 60343934 β the fourth README commit,
91a8ff34, is spelled without the trailing period and so doesn't
collapse into the same bucket by this exact-string count, a reminder
that sort | uniq -c counts strings, not intent). "New optimiser."
(b4e44ded, 9cf1e937) is earlier work, outside this chapter's range,
turned up only because the command runs over the whole book so far
(ccb31388..8018eec8, chapters 01β05). "Use 'primitive'" is 95ff6a34,
chapter 03's closer; "Various little fixes," β with the trailing comma
left in, 1a2f7b7a β is chapter 04's. Nowhere in this list, or in the
full log this table samples from, is there a feat:/fix:/chore:
prefix of any kind: every message here is a plain, present-tense or
past-tense English sentence, occasionally without a full stop,
occasionally (see above) inconsistently punctuated even by its own
author across near-identical messages. The habits this chapter
documents don't show up as a taxonomy in the log; they show up in what
the commits actually touch.
Try it
Anchor: 37e44acd ("Add README"), the commit that gives this session
its first README and, by this point, already has the .hs rename
(73a427d7), the test harness's Fac/IOTest/StringTest trio
(6ac9c791), and the lib/-plus--i module path (b0915dcf) all in
place. Convention (see
00-overview.md):
git worktree add /tmp/mhs-37e44acd 37e44acd
cd /tmp/mhs-37e44acd
Build note: same GHC-9.8/
mtlissues chapters 02β04 hit. Build by hand, intobin/, matching this commit's ownMakefilelayout:mkdir -p bin nix develop $MICROHS -c ghc Main.hs -o bin/uhsfails first with
ParserComb.hs'sap-not-in-scope error; add, afterParserComb.hs's existingimport Control.Monad.State.Strict:import Control.Monad(ap)then with
Compile.hs'swhen-not-in-scope error; add, afterCompile.hs's existingimport Control.Monad.State.Strict:import Control.Monad(when)With both one-line patches, the build succeeds (harmless
-Wx-partialwarnings abouthead/tailomitted):[1 of 7] Compiling ParserComb ( ParserComb.hs, ParserComb.o ) [2 of 7] Compiling Parse ( Parse.hs, Parse.o ) [3 of 7] Compiling Exp ( Exp.hs, Exp.o ) [4 of 7] Compiling Desugar ( Desugar.hs, Desugar.o ) [5 of 7] Compiling Compile ( Compile.hs, Compile.o ) [6 of 7] Compiling Main ( Main.hs, Main.o ) [7 of 7] Linking bin/uhsBuild the runtime the same way:
nix develop $MICROHS -c gcc -Wall -O3 eval.c -o bin/evalEvery
bin/uhsinvocation below needs-ilibto findPrelude/List/IO/Stringunderlib/(b0915dcf's path flag, already in place at this commit, discussed in "Where we are" above).
Exercise 1: run the compiler and the runtime at several verbosity levels
A small program, exercising both a string and an arithmetic effect:
cat > Greet.hs <<'EOF'
module Greet where
import Prelude
import IO
main = IO.do
putStrLn "hello"
print (6 * 7)
EOF
nix develop $MICROHS -c ./bin/uhs -ilib Greet
Compiler-side verbosity is a single flag (on or off). Without -v,
bin/uhs prints nothing at all β it just writes out.comb. With
-v, it traces every module import (including cache hits) and dumps
every compiled definition's combinator form before writing the file:
nix develop $MICROHS -c ./bin/uhs -v -ilib Greet
importing "Greet"
importing "Prelude"
importing done "Prelude"
importing "IO"
importing cached "Prelude"
importing "List"
importing cached "Prelude"
importing done "List"
importing done "IO"
importing done "Greet"
Greet.main = IO.>> (IO.putStrLn (O 'h' (O 'e' (O 'l' (O 'l' (O 'o' K)))))) (IO.print (Prelude.* 6 7))
IO.ReadMode = B K (B K K)
...
(... elides the rest β every remaining Prelude/List/IO
definition's combinator form, followed by the entire linked program on
one final line. importing cached "Prelude" appears twice: IO and
List both import Prelude, and the second import is served from
Compile.hs's module cache rather than re-parsed β visible only
because -v traces it.)
Runtime verbosity is graded. Level 0 is the program's own output and nothing else:
nix develop $MICROHS -c ./bin/eval out.comb > /tmp/greet-lvl0.txt
nix develop $MICROHS -c ./bin/eval -v out.comb > /tmp/greet-lvl1.txt
nix develop $MICROHS -c ./bin/eval -v -v out.comb > /tmp/greet-lvl2.txt
nix develop $MICROHS -c diff /tmp/greet-lvl0.txt /tmp/greet-lvl1.txt
2a3,7
>
> main returns $I
> node size=16, heap size=100000
> 260 reductions, 1 GCs, max cells used 140
> 140 cells at start
Level 1 (-v) adds exactly the post-run diagnostic trailer ef082bbe
gated: what main returned ($I, the identity combinator β IO.>>'s
final result, discarded), node/heap size, and reduction/GC/cell
counts. Level 2 (-v -v) adds one more thing, and it comes before
the program's own output rather than after it:
nix develop $MICROHS -c diff /tmp/greet-lvl1.txt /tmp/greet-lvl2.txt
0a1
> (($IO.>> ((((($S' ($C' $IO.>>)) (($B (($B $Y) (($B ($B ($P ($IO.return $I)))) (($B ($C' $B)) ($B $IO.>>))))) $IO.putChar)) (($C $IO.putChar) '
)) $IO.stdout) (($O 'h) (($O 'e) (($O 'l) (($O 'l) (($O 'o) $K))))))) (($IO.serialize $IO.stdout) (($* 6) 7)))
That's c0fb9a91's pp(stdout, n) call: the entire loaded program
graph, printed once, before evalio runs β the same $T/$K/$O
textual form Try It exercises in earlier chapters read out of
out.comb files, but here it's the in-memory graph right after
loading and garbage collection, not the file on disk (the two happen
to look alike because IO.serialize and pp share the same printer,
per chapter 04's "What happened"). Three levels, three different
questions answered: level 0, "what does the program do"; level 1,
"how much work did that take"; level 2, "what does the compiled
program actually look like before it runs."
Exercise 2: add one more test to the harness, in its own style
Follow 9b6e23c8+6ac9c791's pattern exactly: a module, a .ref
file holding its real output, one more line in tests/Makefile.
cat > tests/ArithTest.hs <<'EOF'
-- Arithmetic test
module ArithTest where
import Prelude
import IO
main = IO.do
print (2 + 3)
print (10 - 4)
print (6 * 7)
EOF
Run it once by hand to find out what it actually prints β the harness records real output, not predicted output:
cd tests
nix develop $MICROHS -c ../bin/uhs -i../lib ArithTest
nix develop $MICROHS -c ../bin/eval out.comb
5
6
42
Save that as the reference file:
nix develop $MICROHS -c ../bin/eval out.comb > ArithTest.ref
cat ArithTest.ref
5
6
42
Add one line to tests/Makefile, in the same style as Fac's:
$(UHS) ArithTest && $(EVAL) out.comb > ArithTest.out && diff ArithTest.ref ArithTest.out
Run the whole harness from the top level, exactly as make test runs
it for a reader who hasn't touched anything above:
cd ..
nix develop $MICROHS -c make test
ghc Main.hs -o bin/uhs
cd tests; make
../bin/uhs -i../lib IOTest && (echo q | ../bin/eval out.comb > IOTest.out) && diff IOTest.ref IOTest.out
../bin/uhs -i../lib StringTest && ../bin/eval out.comb > StringTest.out && diff StringTest.ref StringTest.out
../bin/uhs -i../lib Fac && ../bin/eval out.comb > Fac.out && diff Fac.ref Fac.out
../bin/uhs -i../lib ArithTest && ../bin/eval out.comb > ArithTest.out && diff ArithTest.ref ArithTest.out
The leading ghc Main.hs -o bin/uhs is real, and it's Exercise 1's
doing, not Exercise 2's: the top-level Makefile's $(BIN)/uhs: *.hs
rule matches every .hs file in the top-level directory, including
Greet.hs, left there by Exercise 1. Greet.hs is newer than
bin/uhs, so make reruns the link step β harmlessly (nothing about
Main.hs or its dependencies actually changed, so the binary itself
comes out identical) but visibly, on every subsequent make test too,
since a relink that changes nothing doesn't update bin/uhs's own
timestamp. Four tests, no diff output from any of them β silence is
the harness saying every one of them matched its golden file,
ArithTest included.
Exercise 3: classify every commit in this chapter's range
git log --stat 8c5fbb1a..8018eec8, read commit by commit, classified
by what the diff actually contains rather than by the commit's own
title β the fifteen commits from 6c59dd36 through 8018eec8.
Feature/fix/cleanup/docs are one reasonable four-way split; a fifth
bucket, "mixed," is used once below rather than forcing a commit that
genuinely touches several of the other four into just one of them.
Disagreement is invited β several of these could be argued either way.
| Commit | Subject | Files touched | Class |
|---|---|---|---|
6c59dd36 | Update | Makefile | cleanup |
9b6e23c8 | Better names. | .gitignore, Main.hs, Makefile, tests/Fac.uhs | cleanup |
73a427d7 | Rename files to .hs | 8 renames, Compile.hs, Makefile, tests/Makefile | cleanup |
6ac9c791 | Add some testing. | tests/*.ref, tests/IOTest.hs, tests/Makefile | feature |
110bcc94 | Fix minor issues. | .gitignore, Makefile, tests/Makefile | fix |
37e44acd | Add README | README.md | docs |
aae0eee2 | More text. | README.md | docs |
04f0840b | More text. | Example.hs, README.md | docs |
91a8ff34 | More text | README.md | docs |
0bfdcd18 | Fix some mistakes. | README.md | fix |
0b97ab3e | Add a flag to set the heap size. | eval.c | feature |
60343934 | More text. | README.md | docs |
3cdbb820 | Various small improvements. | Compile.hs, Makefile, README.md, eval.c, lib/IO.hs, tests/Fac.hs, tests/Fac.ref, tests/Makefile, tests/Nfib.hs | mixed |
28d53dab | Clean up | ParserComb.hs | cleanup |
8018eec8 | Add null | lib/List.hs | feature |
9b6e23c8 is arguably itself mixed β most of its diff is renaming and
gitignore cleanup, but it also introduces tests/Fac.uhs, genuinely
new content that Habit 2's test harness later depends on; it's tabled
as cleanup because that's the commit's own stated intent ("Better
names.") and the larger share of its diff. 3cdbb820 is the one entry
that resists a single label outright: a real bug fix (Compile.hs's
module-path lookup, dots to slashes, for names it can't yet actually
produce), a feature (tests/Nfib.hs, a new heavier benchmark, plus a
make time target), a cleanup (lib/IO.hs's mapM moves from the
fix (\ rec -> ...) idiom to plain recursive let, and tests/Fac.hs
drops the nfib 35 line Nfib.hs now supersedes), and a docs addition
(the README's ## Libraries paragraph) β four of the other four
categories, in one commit (3cdbb820 --stat's own total: "9 files
changed, 45 insertions(+), 18 deletions(-)").
Clean up:
cd tests && rm -f ArithTest.hs ArithTest.ref *.out *.tmp && cd ..
git worktree remove --force /tmp/mhs-37e44acd
06 β Becoming Haskell
Where we are
Chapter 05 closes at 8018eec8 ("Add null"), 19:10:07 on 2023-06-02 β a
working little language with monadic IO, a working
IO.serialize/IO.deserialize pair, a three-module golden-file test
harness wired into make test, and a first README.md. The module
system that exists at that point is minimal: lib/Prelude.hs,
lib/IO.hs, lib/List.hs, and lib/String.hs sit as four flat files,
none of them carrying an export list, none of them importable with a
qualifier or an as-alias, and every module's every top-level name
floods into one shared unqualified symbol table β two imports defining
the same name collide as a runtime "ambiguous" error, not something a
programmer reaches for on purpose. do-notation still needs an explicit
qualifier at every call site (IO.do, not do). There is no if, no
list comprehensions, no operator sections. This chapter's first commit,
5f08e33b, lands seven seconds after 8018eec8 β the same sitting,
not a new session.
What happened
The range 8018eec8..41d6a7e8 is 30 commits, and they land on four
calendar days out of the six the range spans: one commit late on
2023-06-02 (a continuation of chapter 05's own evening), 22 commits in
one sitting on 2023-06-03 (11:33 to 15:02, roughly three and a half
hours β nothing on 06-04), 4 commits on 2023-06-05 (11:17 to 15:17, with
a three-and-a-half-hour gap inside that day β nothing on 06-06), and 3
commits on 2023-06-07 (10:51 to 14:13). Then the log goes quiet: the
next commit dated after 41d6a7e8 (2023-06-07 14:13:56) is 44004020
("Small fixes."), 2023-07-01 15:41:40 β 24 days later, and, per this
book's own overview, "[n]othing in this repo's history explains the
24-day gap β the log simply resumes." One more wrinkle, visible only in
the commit graph, not the flat git log the overview's table reads
from: 44004020 isn't actually a child of 41d6a7e8. Its own parent
is 53d4bfdc (06-03 15:02:16, this chapter's next-to-last 06-03
commit) β a checkout that predates every commit from d5bac600
onward, missing if, list comprehensions, both file reorganizations,
and the CHAR removal entirely. About six minutes after 44004020, a
merge commit, d128fb6f ("Merge branch 'master' of
github.com:augustss/MicroHs", also 2023-07-01), reconciles that stale
branch with 41d6a7e8. Read as a graph rather than a date-sorted list,
the honest picture is two checkouts that fell out of sync on 06-03 and
were stitched back together on 07-01 β not a single line of work that
simply paused for 24 days and resumed. That gap, and what resumes on
the other side of it, is the next chapter's story, not this one's.
Inside the six working days that do have commits, three things happen
at once: the module system grows qualification, aliasing, and required
export lists; the file layout is reorganized twice to mirror Haskell's
own directory conventions; and the surface syntax picks up if,
operator sections, and list comprehensions. A fourth, smaller thread β
scope checking moving into the desugarer, and Char losing its own
runtime representation β is where the compiler's internals catch up
with everything the other three threads asked of it.
The module system becomes Haskell-shaped
06-03 opens with two structural preludes before the module-system work
proper starts. 35ef759d ("Refactor module desugaring.") replaces the
bare (IdentModule, [LDef]) tuple chapter 03's b25d9fc8 used for a
compiled module with a real Module type carrying its own export list
and constructor-arity table (35ef759d:Desugar.hs line 11, quoted
whole):
data Module = Module IdentModule [Export] [TypeDef] [LDef]
69437754 ("Use a map for final lookup"), fifteen minutes later, swaps
Main.hs's final-linking lookup from a linear lookup over a list β
one list scan per reference, into every earlier definition β to a
Data.Map (69437754:Main.hs lines 21β23, quoted whole):
defs :: M.Map Ident Exp
defs = M.fromList [ (n, ref i) | ((n, _), i) <- zip ds' [0..] ]
findIdent n = fromMaybe (error $ "undefined: " ++ show n) $ M.lookup n defs
Neither commit is user-visible; both are the kind of "clean the load- bearing plumbing before building more on it" move chapter 03 already named a rhythm.
Then, in quick succession: 155e9f5a ("Add 'qualified' to imports.")
adds the qualified keyword, threading a Bool through ImportSpec so
a qualified import's names only enter the symbol table under their
dotted form, never bare (155e9f5a:Parse.hs lines 279β280, quoted
whole):
<|> (pKeyword "import" *> (Import <$> pQua <*> pUIdent))
where pQua = (True <$ pKeyword "qualified") <|< pure False
cfbf3c7d ("Implement 'as' in 'import' statement"), 28 minutes later,
adds the alias itself β ImportSpec grows a third field, Maybe Ident, and pImportSpec becomes one self-contained parser instead of
being spelled out inline in pDef (cfbf3c7d:Parse.hs lines 280β282,
quoted whole):
pImportSpec :: P ImportSpec
pImportSpec = ImportSpec <$> pQua <*> pUIdent <*> optional (pKeyword "as" *> pUIdent)
where pQua = (True <$ pKeyword "qualified") <|< pure False
Seven minutes after that, bd28b65c ("Avoid errors on multiple imports
of the same module.") fixes a bug the previous two commits' own symbol-
table plumbing exposes: when the same module is reachable by two import
paths (directly, and transitively through another import), its
qualified names were being inserted twice via (++), so the "ambiguous"
check chapter 03 built (checkExpr's Just qis -> error "ambiguous: ")
fired against a name that wasn't actually ambiguous β it just appeared
in its own symbol-table bucket twice. The fix is switching the
map-merge function from list concatenation to Data.List.union, which
already dedupes (bd28b65c:Desugar.hs lines 39β44, quoted whole):
allSyms :: SymTable
allSyms = M.fromListWith union $ concatMap syms (imdl : mdls)
where syms (is, Module mn qis _ _) = [ (v, [Var qi]) | (i, qi) <- qis, v <- qns is mn i ]
allTypes :: TypeTable
allTypes = M.fromListWith union $ concatMap types (imdl : mdls)
where types (is, Module mn _ tds _) = [ (v, [td]) | td@(TypeDef _ cs) <- tds, (c, _) <- cs, v <- qns is mn c ]
Between cfbf3c7d and bd28b65c, two file-reorganization commits land
(16c75970, 4f3b8f8d β covered below under "Files reorganize"), and
then 7c23e672 ("Make 'do' qualifier optional.") lets do stand alone:
EDo's qualifier becomes Maybe Ident instead of a required Ident,
and an unqualified do looks up (>>=)/(>>) unqualified rather than
demanding a module prefix at every call site β the change chapter 03's
"Try it" exercise had to route around by hand is now built in, and
Example.hs's own main drops its IO.do down to plain do in the
same commit.
The last piece of the module-system thread is 1136f265 ("Require an
export list."), 06-03 at 14:10:46. Before this commit module M where
parses; after it, pModule demands a parenthesized export list between
the module name and where, with only one kind of export item
implemented β module M, a whole-module re-export, nothing selective
(1136f265:Parse.hs lines 155β161, quoted whole):
pModule :: P EModule
pModule = EModule <$> (pKeyword "module" *> pUIdent) <*>
(pSym '(' *> many pExportSpec <* pSym ')') <*>
(pKeyword' "where" *> pBlock pDef)
pExportSpec :: P ExportSpec
pExportSpec = ExpModule <$> (pKeyword "module" *> pUIdent)
Running a module with no export list at all through the compiler (a
build later in this chapter reproduces the exact transcript β see "Try
it" Exercise 3) now fails to parse right after the module name, before
where is even reached β module NoExport where gets rejected with
expeced: '(' (the source's own spelling, kept verbatim below). The
same commit updates every existing library and test module to the only
form the new grammar accepts, module M(module M) where β a
whole-module self-re-export, which is the sole way to write "export
everything" until selective export lists exist. One of those mechanical
edits ships with a real typo: tests/ListTest.hs gets module ListTest(Module ListTest) where β a capitalized Module that isn't
the module keyword pExportSpec looks for, so it wouldn't have
parsed. The very next commit, dba211e1 ("More tests"), three minutes
later, fixes it to lowercase along with unrelated content changes to
the same test β a small, honest reminder that a 30-commit, six-day
sprint still ships the occasional bug it catches almost immediately.
Files reorganize, in commits of their own
Two separate reorganizations happen in this range, each split cleanly from any commit that changes behavior.
The first: 16c75970 ("Reorganize modules to be more like Haskell.")
deletes the bodies of the flat lib/IO.hs and lib/List.hs (191 lines
removed, 9 files touched, 13 insertions β mostly import-line updates in
Desugar.hs and the test modules) and, 47 seconds later, 4f3b8f8d
("Moved files.") adds lib/Data/List.hs and lib/System/IO.hs back
with that same content, now living under Haskell-shaped paths (178
lines, pure additions, two files). The pair of commits is a manual
git mv β content deleted in one commit, recreated verbatim (module
name and all) in the very next β rather than a single commit doing both
at once.
The second reorganization is bigger and, read literally against the
tree at each commit, leaves the project unbuildable for a stretch.
34c8965e ("Rearrange files."), 06-05 at 15:17:24, deletes
Compile.hs, Desugar.hs, Exp.hs, Main.hs, Parse.hs, and
ParserComb.hs outright β 975 lines of Haskell source gone, with no
replacement for any of it anywhere in the same commit (the commit's
--stat totals 980 deletions across 8 files; the other 5 are Makefile
lines rewritten below) β while renaming eval.c to
src/runtime/eval.c and repointing the Makefile at paths that, at
that exact commit, don't exist (34c8965e:Makefile, quoted in full):
BIN=bin
.PHONY: all test time example
all: $(BIN)/eval $(BIN)/uhs
$(BIN)/eval: src/runtime/eval.c
@mkdir -p bin
gcc -Wall -O3 src/runtime/eval.c -o $(BIN)/eval
$(BIN)/uhs: src/*/*.hs
@mkdir -p bin
ghc -Wall -O -isrc src/MicroHs/Main.hs -o $(BIN)/uhs
test: $(BIN)/eval $(BIN)/uhs tests/*.hs
cd tests; make test
time: $(BIN)/eval $(BIN)/uhs tests/*.hs
cd tests; make time
example: $(BIN)/eval $(BIN)/uhs Example.hs
$(BIN)/uhs -ilib Example && $(BIN)/eval
clean:
rm -f src/*/*.hi src/*/*.o eval Main *.comb *.tmp *~ $(BIN)/*
cd tests; make clean
git ls-tree -r 34c8965e --name-only confirms it directly: no file
under src/MicroHs/ exists at this commit β the Makefile's own
$(BIN)/uhs: src/*/*.hs rule has nothing to match. ac6f377c ("Make
files a bit more like Haskell."), the next commit touching the tree,
lands almost two days later (2023-06-07 10:51:20) and only edits
lib/System/IO.hs and src/runtime/eval.c β the compiler sources
stay missing. Only 2f89971e ("Move files."), 06-07 at 14:13:33,
restores them, under src/MicroHs/*.hs and src/Text/ParserComb.hs,
each file's own module line requalified to match its new path
(module Compile becomes module MicroHs.Compile, and so on). But
"Move files." under-describes its own diff, the same way ac6f377c
(next paragraph) does, in two separate respects. First, diffing the
restored files against the content 34c8965e deleted shows four of the
six are indeed pure moves (module/import lines only) β but Desugar.hs
and Exp.hs come back changed: Exp's Chr Char constructor is
gone, Int Integer narrows to Int Int, and EChar c now desugars to
Int (fromEnum c) β the compiler-side half of the CHAR removal,
landing inside the "move" commit 23 seconds before 41d6a7e8 (below)
deletes the runtime-side half. Second, the commit adds seven files,
and the seventh, src/MicroHs/Translate.hs (78 lines),
is not a moved file at all β it has no earlier counterpart anywhere in
the project's history. It's the first draft of something new: a
translator from the compiler's Exp combinator terms straight into
native GHC values (trans :: Exp -> Any, built on unsafeCoerce,
with a primOps table pairing each combinator name with an actual
Haskell function β comb "S" (\ f g x -> f x (g x)), comb "Y" (let fix f = f (fix f) in fix), and so on) β that is, a way to run
compiled programs inside GHC's own runtime instead of shipping them to
eval.c (and the reason Exp's integers just became machine-width
Int: Translate.hs's arithmetic table is typed Int -> Int -> Int).
It's also visibly unfinished: line 52 of the committed file
reads , comb "IO.h β an unterminated string literal, a file frozen
mid-keystroke, which can't even lex. It doesn't break the build only
because nothing imports it yet (2f89971e:src/MicroHs/Main.hs imports
Parse/Exp/Compile, not Translate, and ghc -isrc only compiles
what Main reaches). Where this thread goes is the next chapter's
material; here it's a stowaway in a commit titled like pure
housekeeping. Meanwhile the headline fact about the pair of commits
stands: for roughly 47 hours, spanning the boundary
between this chapter's 06-05 and 06-07 sessions, this repository's own
history has no commit from which uhs can be built at all β a genuine
gap in buildability, not an artifact of this book's worktree
convention, and the reason this chapter's own "Try it" anchors before
it (see below).
ac6f377c's own diff, landing inside that gap, is worth one aside:
its title reads as pure cosmetic rename work, but the diff to
lib/System/IO.hs adds real error handling β hGetChar and
openFile now raise a proper error on EOF or a failed open instead
of silently handing back a sentinel value β and the diff to
src/runtime/eval.c fixes a genuine garbage-collector bug: scan's
sweep was closing stdin/stdout/stderr as unreachable file
handles whenever nothing else pointed at them from the program graph,
which the commit corrects by excluding those three handles from the
close-on-collect check. A commit named for tidying carries a real bug
fix and a real behavior change alongside it β the same "commit titles
don't fully describe their diffs" pattern chapter 05 flags for
3cdbb820.
Scope checking moves into desugaring
0cc677d5 ("Change desugaring to do scope checks. Add case error
checks."), 06-03 at 12:30:50, is the biggest single-commit change in
this thread. Before it, Compile.hs desugars a module first and then
walks the already-desugared Exp tree in a separate pass,
checkExpr (chapter 03's b25d9fc8), resolving each bare Var
against a symbol table built from every imported module's exports.
0cc677d5 deletes that separate pass and folds its job straight into
dsExpr, which now
takes the symbol table (and a matching constructor-arity table) as an
argument and resolves every EVar the moment it's desugared, not
afterward (0cc677d5:Desugar.hs lines 53β57, quoted whole):
dsExpr syms _ (EVar i) =
case M.lookup i syms of
Nothing -> error $ "undefined: " ++ show i
Just [qi] -> qi
Just qis -> error $ "ambiguous: " ++ show i ++ ", " ++ show qis
The same commit adds the "case error checks" its message promises: a
new reorderArms pass that, given a case's arms and the constructor
table, checks a constructor case against the actual data declaration
it belongs to β not just parses, but validates β and reorders the arms
into declaration order so dsExpr's Scott-encoding case compiler
(chapter 02's O/P-combinator trick) always sees them in the order
the runtime expects (0cc677d5:Desugar.hs lines 92β108, quoted whole):
reorderArms :: TypeTable -> [(EPat, Expr)] -> [(EPat, Expr)]
reorderArms _ [] = error "case has no arms"
reorderArms _ as@[(PTuple _, _)] = as
reorderArms tys as@((PConstr con _, _) : _) =
let arms = [(c, a) | a@(PConstr c _, _) <- as] in
if length arms /= length as then error "bad tuple pattern" else
case M.lookup con tys of
Nothing -> error $ "undefined constructor: " ++ show con
Just [TypeDef _ cs] -> map arm cs
where arm (c, k) =
case lookup c arms of
Nothing -> error $ "constructor missing: " ++ show (c, con)
Just a@(PConstr _ vs, _) ->
if length vs == k then a else error $ "bad contructor arity: " ++ show a
_ -> undefined
Just _tds -> error $ "ambiguous constructor: " ++ show con
reorderArms _ _ = undefined
Six distinct failure modes in one 16-line function: an empty case, a mixed
tuple/constructor pattern set, an unknown constructor, a constructor
present in the data declaration but missing from the case, an arm
whose pattern binds the wrong number of fields for that constructor's
declared arity (misspelled in the source as "contructor," kept
verbatim above), and a constructor name that's ambiguous across two
imported types. None of these were checked before this commit β a
case with a typo'd constructor name, or the wrong number of bound
variables, either crashed the C runtime with an unhelpful message or
silently ran into undefined behavior; after 0cc677d5, all six are
caught in the desugarer, at compile time, in Haskell, with a message
naming what's wrong.
The Prelude splits into Data.*-style modules
3a9c0edb ("Split up Prelude") and 92960261 ("More stuff in
Prelude"), 17 and 26 minutes after 1136f265, break the single
lib/Prelude.hs into one file per concern and re-export all of them
through Prelude. By the end of 92960261, the module map is
(92960261:lib/Prelude.hs, quoted whole):
module Prelude(
module Control.Error,
module Data.Bool,
module Data.Either,
module Data.Function,
module Data.Int,
module Data.List,
module Data.Maybe,
module Data.Tuple,
module System.IO,
module Text.String
) where
import Control.Error
import Data.Bool
import Data.Either
import Data.Function
import Data.Int
import Data.List
import Data.Maybe
import Data.Tuple
import System.IO
import Text.String
Ten modules, all under hierarchical Data.*/Control.*/System.*/
Text.* prefixes, most at the exact paths where the same names live in
real Haskell's base package β Data.Bool, Data.Either,
Data.Maybe, Data.Tuple
for the small ADTs and their functions; Data.Function for id/./
flip; Data.Int for arithmetic and the (soon-to-be-identity)
chr/ord
pair; Data.List for the list type itself and its functions;
System.IO for handles and IO primitives; and two Haskell-flavored
names that aren't actually base modules, Text.String for string
formatting and Control.Error for error and undefined. None of this
is a new feature β every name that moves keeps working exactly as it
did in the flat file β but it's the module system finally being
exercised at the scale it was built for: real code, split across real
files, glued back together by real imports.
Surface syntax catches up
178f43d0 ("Better 'let' in 'do'") lets a do-block's let bind more
than one name at once β Let Ident Expr becomes Let [EDef], and
pStmt's let parses a whole pBlock pDef instead of one name = expr pair β closing a gap the previous commit's own comment had
flagged ("could be better").
1a0ac4c6 ("Add operator sections."), nine minutes later, adds
(e op) and (op e) as expressions β left and right sections. A left
section, (e op) (e.g. (5 <)), fixes e as the operator's first
argument, so it's just op e applied to whatever comes next β a plain
App. A right section, (op e) (e.g. (< 5)), needs the next
argument to land in the operator's first position instead, with e
staying fixed in the second β exactly what C (chapter 03's
argument-flipping combinator, C f x y = f y x) is for
(1a0ac4c6:Desugar.hs lines 107β110, quoted whole):
dsExpr syms tys (ESectL e op) =
App (dsExpr syms tys (EVar op)) (dsExpr syms tys e)
dsExpr syms tys (ESectR op e) =
App2 CC (dsExpr syms tys (EVar op)) (dsExpr syms tys e)
with the parser side one alternative each in pAExpr
(1a0ac4c6:Parse.hs lines 311β312, quoted whole):
<|> (ESectL <$> (pSym '(' *> pExprArg) <*> (pOper <* pSym ')'))
<|> (ESectR <$> (pSym '(' *> pOper) <*> (pExprArg <* pSym ')'))
Then a two-day break (06-03 evening to 06-05 morning). The session
resumes with d5bac600 ("Some compatibility fixes.") β a small,
unglamorous commit: Flags' path field is renamed to paths
throughout Compile.hs/Main.hs, eval.c's printrec switches an
INT format string from %ld to %lld, and both Makefile build
rules grow @mkdir -p bin so a clean checkout's missing bin/
directory doesn't fail the build β the kind of small friction a second
sitting turns up immediately. Eleven minutes later comes if β was
there really no if at all before this range? A search across the
entire project history for EIf (the constructor a working if would
need) turns up nothing before 1baaf7b6 β the commit is genuinely the
first time if/then/else become parseable syntax anywhere in this
project's git history, not just this chapter's range. Before it, a
conditional could only be written as case cond of { True -> a; False -> b }, and Example.hs's own fac uses exactly that form right up
until this commit rewrites it to if n <= 0 then 1 else n * fac(n-1).
1baaf7b6 ("Add 'if'"), 06-05 at 11:28:53, adds three new keywords
(if/then/else, alongside the existing list β 1baaf7b6:Parse.hs
lines 201β203, quoted whole):
keywords :: [String]
keywords = ["case", "data", "do", "else", "if", "import",
"in", "let", "module", "of", "primitive", "then", "where"]
and a desugaring that reuses the same case-on-Bool trick the
rewritten Example.hs had been spelling out by hand β if compiles to
an application of the (Scott-encoded) condition to its else- and then-
branches, in that order (1baaf7b6:Desugar.hs lines 111β112, quoted
whole):
dsExpr syms tys (EIf e1 e2 e3) =
App2 (dsExpr syms tys e1) (dsExpr syms tys e3) (dsExpr syms tys e2)
Nineteen minutes later, 0eb369f9 ("Add list comprehensions.") adds
[e | q1, q2, ...] as new pAExpr syntax, reusing the exact same
EStmt type do-notation already parses (Bind, Then, Let) for
its qualifiers, and desugars each of the three qualifier kinds by
recursion on the rest of the comprehension (0eb369f9:Desugar.hs
lines 113β119, quoted whole):
dsExpr syms tys (ECompr e []) = dsExpr syms tys (EList [e])
dsExpr syms tys (ECompr e (Bind i b : ss)) =
App2 (Var "Data.List.concatMap") (dsExpr syms tys (ELam [i] (ECompr e ss))) (dsExpr syms tys b)
dsExpr syms tys (ECompr e (Then c : ss)) =
dsExpr syms tys (EIf c (ECompr e ss) (EList []))
dsExpr syms tys (ECompr e (Let d : ss)) =
dsExpr syms tys (ELet d (ECompr e ss))
Four rules, each one line: no qualifiers left ([]) is a singleton
list; a generator (Bind i b) becomes concatMap (\i -> rest) b β
the hard-coded name "Data.List.concatMap", not a re-desugared EVar
lookup, so this rule depends on Data.List (or something re-exporting
concatMap under that exact qualified name) being in scope wherever a
comprehension is used; a guard (Then c) becomes if c then rest else [], reusing 1baaf7b6's just-added if; and a let qualifier
becomes an ordinary let wrapping the rest. [x*x | x <- xs, x > 2]
is, by this recipe, concatMap (\x -> if x > 2 then [x*x] else []) xs
β textbook comprehension desugaring, built from pieces (concatMap,
if, let) that all already existed by the time this commit needed
them.
41d6a7e8: getting rid of CHAR
The chapter's last commit, 06-07 at 14:13:56, deletes the runtime's
dedicated character representation outright β completing what
2f89971e, 23 seconds earlier, had already started on the compiler
side by deleting Exp's Chr constructor (see "Files reorganize"
above). Before it, eval.c β
living at src/runtime/eval.c since 34c8965e's move β has a
CHAR node tag distinct from INT, a 'c literal syntax in the
runtime's own combinator-file parser, and two primitives, ORD/CHR,
that convert between the two tags. 41d6a7e8 removes all of it β
CHAR disappears from the tag enum (compare the two states directly;
41d6a7e8~1:src/runtime/eval.c line 18 still has it,
41d6a7e8:src/runtime/eval.c line 18 doesn't):
enum node_tag { FREE, IND, AP, INT, HDL, S, K, I, B, C, T, Y, SS, BB, CC, P, O,
β along with the '\'' literal parser case, the evalchar function,
the printrec/eval/evalio cases for CHAR/ORD/CHR, and
evalstring's call through evalchar (replaced with a plain
evalint plus a 0 <= c <= 127 range check). On the Haskell side,
chr/ord β previously primitive "chr"/primitive "ord", calling
into the now-deleted runtime primitives β become plain identity
functions, because a Char and its Int ordinal are now, at the
runtime level, literally the same representation
(41d6a7e8:lib/Data/Int.hs lines 27β28, quoted whole):
chr x = x
ord x = x
What dies is a whole node tag and its associated primitives β real
simplification, not a rename β and what makes it safe to delete is that
nothing above the runtime actually needed Char to be its own tag:
strings were already lists of something being printed as characters
(chapter 03's EStr-to-EList desugaring), and once the runtime
treats that "something" as a plain integer end to end, chr/ord
have nothing left to do.
Why it matters
None of the individual pieces in this chapter are large. Qualified
imports are a Bool field. as is a Maybe Ident. A required export
list is one new production in pModule with exactly one export-item
form implemented. Operator sections are two lines of desugaring each.
List comprehensions are four one-line desugaring rules that lean on
concatMap and if rather than inventing new machinery. What's
notable is less any single commit and more what the set, taken
together, is aimed at: the module system, the Prelude's shape, and the
surface syntax all move toward looking like real Haskell in the same
six days, not because completeness was the goal, but because a
specific, concrete target sits a few weeks out β getting this compiler
to eventually compile a real Haskell program, and eventually, itself,
once it's written in Haskell rather than in the tiny dialect the
runtime speaks. (The self-hosting push is this book's next chapter's
story.) Every feature this chapter adds is one that real Haskell code
written the ordinary way would use without a second thought β if,
list comprehensions, qualified imports to avoid Prelude clashes,
export lists to control what a module exposes β and nothing here is
speculative: there's still no fixity declaration mechanism, no
user-defined operators, no type classes, no selective export (only
whole-module re-export). YAGNI cuts both ways in this sprint: build
what real code will need soon, and nothing else yet.
Notebook margin
- "Update docs." commits land right after the user-visible change they
document, not batched at the end of a session:
51a56220("Update docs.") follows92960261(the Prelude split finishing) five minutes later;263d2f87("Update docs") follows1a0ac4c6(operator sections) three minutes later. - File moves get their own commits, mostly separate from commits that
change behavior β twice in this range, at two different scales.
16c75970/4f3b8f8dsplitlib/IO.hs/lib/List.hsintolib/System/IO.hs/lib/Data/List.hsin a clean delete-then-recreate pair, 47 seconds apart.34c8965e/2f89971edo the same for the compiler's own six top-level.hsfiles andParserComb.hs, moving them undersrc/MicroHs//src/Text/β except this time the "moved files" commit isn't the very next commit (it's two calendar days and one unrelated commit,ac6f377c, later, and the repository genuinely has no compiler source tree in between), and it isn't purely a move either:2f89971esmuggles in the brand-new, half-finishedTranslate.hsalongside the six restored files (see "Files reorganize"). The discipline is real but not absolute β worth knowing before trusting any commit title over its diff. - The rhythm across the whole range is sprint-then-gap at two nested scales: within the six working days, commits cluster tightly (22 in one afternoon on 06-03) and then jump a day (06-04, 06-06 both idle); across the whole book so far, this chapter's close is followed by the 24-day silence the overview's own timeline table calls out before chapter 07 resumes on 07-01.
Try it
Anchor: 0eb369f9 ("Add list comprehensions."), 2023-06-05 11:48:09 β
chosen deliberately from before 34c8965e's file move, so the compiler
sources this chapter's Build note patches are still at the top level,
the same layout chapters 02β05 already build from. (Checking out
anything between 34c8965e and 2f89971e instead would leave you with
no Compile.hs/Desugar.hs/Exp.hs/Main.hs/Parse.hs/
ParserComb.hs to build at all β see "Files reorganize," above.)
Convention (see
00-overview.md):
git worktree add /tmp/mhs-0eb369f9 0eb369f9
cd /tmp/mhs-0eb369f9
Build note: this commit's
Makefilestill buildsbin/uhsfrom*.hsat the top level andbin/evalfromeval.c, same shape as chapter 05's anchor. Building as written hits the same two GHC-9.8/mtlgaps chapters 02β05 hit, unchanged in this range:mkdir -p bin nix develop $MICROHS -c ghc Main.hs -o bin/uhsfails first with
ParserComb.hs'sap-not-in-scope error; add, afterParserComb.hs's existingimport Control.Monad.State.Strict:import Control.Monad(ap)then with
Compile.hs'swhen-not-in-scope error (five call sites this time,Compile.hshaving grown since chapter 05's anchor β all five are the same error, only the first is shown):Compile.hs:33:7: error: [GHC-88464] Variable not in scope: when :: Bool -> a5 -> StateT Cache IO a6 Suggested fix: Perhaps use data constructor βThenβ (imported from Parse) | 33 | when (nm `elem` ws) $ | ^^^^add, after
Compile.hs's existingimport Control.Monad.State.Strict:import Control.Monad(when)With both one-line patches, the build succeeds (harmless
-Wx-partialwarnings abouthead/tailomitted):[1 of 7] Compiling ParserComb ( ParserComb.hs, ParserComb.o ) [2 of 7] Compiling Parse ( Parse.hs, Parse.o ) [3 of 7] Compiling Exp ( Exp.hs, Exp.o ) [4 of 7] Compiling Desugar ( Desugar.hs, Desugar.o ) [5 of 7] Compiling Compile ( Compile.hs, Compile.o ) [6 of 7] Compiling Main ( Main.hs, Main.o ) [7 of 7] Linking bin/uhsBuild the runtime the same way (one harmless
-Wformatwarning about%lldvs.value_tomitted):nix develop $MICROHS -c gcc -Wall -O3 eval.c -o bin/evalEvery
bin/uhsinvocation below needs-ilibto findPreludeand the other library modules underlib/.
Exercise 1: export list, qualified import, and shadowing made visible
M1 exports everything via the only export-list form this commit's
parser accepts (module M(module M) where β see "What happened,"
1136f265), and defines double:
cat > M1.hs <<'EOF'
module M1(module M1) where
import Prelude
double :: Int -> Int
double x = x + x
EOF
M2 imports M1 qualified and defines its own double, a name that
would otherwise collide:
cat > M2.hs <<'EOF'
module M2(module M2) where
import Prelude
import qualified M1
double :: Int -> Int
double x = x * 3
main = do
print (double 5)
print (M1.double 5)
EOF
nix develop $MICROHS -c ./bin/uhs -ilib M2
nix develop $MICROHS -c ./bin/eval
15
10
double 5 resolves to M2's own definition (5 * 3 = 15); M1.double 5 reaches the qualified-only import explicitly (5 + 5 = 10). The
qualified keyword is what makes this shadowing safe: M1's double
never enters the unqualified symbol table at all (155e9f5a's qns
rule β qualified imports contribute only the dotted name), so there's
nothing for M2's own double to collide with. Confirm the collision
is real by dropping qualified and rebuilding the same program under a
new name:
cat > M2plain.hs <<'EOF'
module M2plain(module M2plain) where
import Prelude
import M1
double :: Int -> Int
double x = x * 3
main = do
print (double 5)
print (M1.double 5)
EOF
nix develop $MICROHS -c ./bin/uhs -ilib M2plain
uhs: ambiguous: "double", [Var "M1.double",Var "M2plain.double"]
CallStack (from HasCallStack):
error, called at ./Desugar.hs:73:17 in main:Desugar
Same two modules, same two names, and the only thing that changed is
one keyword β 0cc677d5's scope-checking EVar case (see "What
happened") is what raises this, the instant the unqualified double is
looked up in M2plain.main and finds two candidates instead of one.
Exercise 2: a guarded list comprehension, desugared
cat > Compr.hs <<'EOF'
module Compr(module Compr) where
import Prelude
main = do
putStrLn $ showList showInt [ x*x | x <- [1,2,3,4,5], x > 2 ]
EOF
nix develop $MICROHS -c ./bin/uhs -v -ilib Compr
The -v dump (chapter 05's verbosity flag) prints every compiled
definition; grepping for Compr.main finds the one line this exercise
cares about:
Compr.main = ((Data.Function.$ System.IO.putStrLn) ((Text.String.showList Text.String.showInt) ((Data.List.concatMap (($S (($C (($C Data.Int.>) 2)) $K)) ((($C' $O) (($S Data.Int.*) $I)) $K))) (($O 1) (($O 2) (($O 3) (($O 4) (($O 5) $K))))))))
Data.List.concatMap sitting right there as a Var reference β not
bracket-abstracted away, because it's a call to a library function, not
an inlined lambda β is 0eb369f9's Bind rule made visible: [x*x | x <- [1..5], x > 2] compiles through concatMap (\x -> ...) [1,2,3,4,5]
exactly as the diff's second rule says. To isolate what the guard
itself contributes, compile the same comprehension with the guard
removed and diff the two compiled forms:
cat > ComprNoGuard.hs <<'EOF'
module ComprNoGuard(module ComprNoGuard) where
import Prelude
main = do
putStrLn $ showList showInt [ x*x | x <- [1,2,3,4,5] ]
EOF
nix develop $MICROHS -c ./bin/uhs -v -ilib ComprNoGuard
ComprNoGuard.main = ((Data.Function.$ System.IO.putStrLn) ((Text.String.showList Text.String.showInt) ((Data.List.concatMap ((($C' $O) (($S Data.Int.*) $I)) $K)) (($O 1) (($O 2) (($O 3) (($O 4) (($O 5) $K))))))))
The no-guard body, ((($C' $O) (($S Data.Int.*) $I)) $K) β the
compiled \x -> [x*x] β appears byte-for-byte inside the guarded
version too, wrapped in one extra layer, $S (($C (($C Data.Int.>) 2)) $K) (...). That extra layer is 0eb369f9's third rule, if x > 2 then [x*x] else [], compiled: the guard adds exactly one wrapping
combinator around the same lambda the ungated comprehension already
produces, nothing more. Run it to confirm the values:
nix develop $MICROHS -c ./bin/eval
[9,16,25]
3^2, 4^2, 5^2 β 1 and 2 are filtered out by the x > 2 guard, as
expected.
Exercise 3: the required export list, rejected
cat > NoExport.hs <<'EOF'
module NoExport where
import Prelude
main = putStrLn "hi"
EOF
nix develop $MICROHS -c ./bin/uhs -ilib NoExport
uhs: "NoExport.hs": line 1, col 16:
found: "where"
expeced: white-space
expeced: '('
CallStack (from HasCallStack):
error, called at ./Parse.hs:124:17 in main:Parse
Column 16 is formatFailed's 0-based offset for the 16 characters
already consumed, "module NoExport " β the position where starts at
in module NoExport where. pModule (1136f265:Parse.hs, quoted in
"What happened") requires a ( right after the module name, and there
isn't one. The filename
prefix on the error ("NoExport.hs":) is itself a small feature from
earlier the same day: 2d818f30 ("Better error message.") threads the
source filename through ParserComb.hs's formatFailed, so a failure
in any imported module β not just the one named on the command line β
says which file it came from.
Clean up:
git worktree remove --force /tmp/mhs-0eb369f9
07 β The self-hosting push
Where we are
Chapter 06 closes at 41d6a7e8, 2023-06-07 14:13:56 β the runtime's
dedicated CHAR tag gone, the module system Haskell-shaped (qualified
imports, as-aliases, required export lists), the surface syntax
carrying if, operator sections, and list comprehensions. As that
chapter's own "Why it matters" put it, none of it was speculative: it was
aimed at "getting this compiler to eventually compile a real Haskell
program, and eventually, itself, once it's written in Haskell rather than
in the tiny dialect the runtime speaks." Chapter 06 also disclosed a
stowaway inside a commit titled like pure housekeeping: 2f89971e
("Move files.") smuggled in src/MicroHs/Translate.hs, a first,
unfinished draft of a translator from the compiler's own Exp
combinator terms into native GHC values via unsafeCoerce β frozen
mid-keystroke, an unterminated string literal on its last line, not
imported by anything yet.
Then the log goes quiet for 24 days. Nothing in this repository's
history explains the gap (see 00-overview.md's own
timeline table); the next commit after 41d6a7e8 is 44004020 ("Small
fixes."), 2023-07-01 15:41:40, followed six minutes later by a merge
commit, d128fb6f, reconciling a stale branch that had fallen out of
sync back on 06-03 β the same two-checkouts pattern chapter 06's own
"Where we are" describes for its own opening, not something new to
re-litigate here.
What does change, the moment the log resumes, is the goal. Chapter 06
was about the compiler resembling Haskell β modules, a Prelude,
comprehensions. This chapter is about the compiler becoming a legal
program written in the dialect it itself accepts: no type classes, no
deriving, no fixity declarations, nothing GHC would compile without a
comment doing the work instead. The chicken-and-egg mechanics of why
that specific, narrower goal matters β and the concrete ladder of make
targets MicroHs uses to prove it, once chapter 08 finishes wiring them
up β are this book's new aux doc,
bootstrapping-a-compiler.md; read it
alongside this chapter's "What happened" below, which walks the same
July grind chronologically.
What happened
The range 41d6a7e8..a824a175 is 90 commits (one of them, d128fb6f,
a merge) landing on eleven calendar days spread across eighteen: 07-01
through 07-03 and 07-05 through 07-07 in one cluster, then a lone commit
on 07-11, then 07-15 through 07-18 closing it out β a shape with its own
internal gaps, not a steady eighteen-day burn.
The return: compiling with a local lib
The first evening back, 07-01, opens short and mechanical: 44004020
("Small fixes.") adds -package mtl directly to the ghc ... -o $(BIN)/uhs build line and folds bin/* into clean; six minutes
later, d128fb6f merges in the stale branch mentioned above.
The real re-launch follows fifty-two minutes after that, still the same
evening. ff3837b9 ("Allow ghc compilation with local lib.") adds a
brand-new top-level directory, ghc/, alongside a matching
lib/Primitives.hs β two implementations of the same interface, one
per audience. lib/Primitives.hs is the dialect uhs itself reads,
built on the primitive "..." FFI-style calls the runtime already
understands (ff3837b9:lib/Primitives.hs lines 3β4, quoted):
primIntAdd = primitive "+"
primIntSub = primitive "-"
ghc/Primitives.hs is the same names, this time real GHC functions over
GHC's own Int and IO, for when GHC compiles the library directly
(ff3837b9:ghc/Primitives.hs lines 10β11, quoted):
primIntAdd :: Int -> Int -> Int
primIntAdd = (+)
Every function lib/'s modules call through Primitives now has two
bodies, chosen by which compiler's search path finds it first. Three
minutes later, 2a95e71e ("Allow compilation with local lib.") wires a
new Makefile target (a.out, later renamed) that compiles the whole
lib/ tree plus Main.hs under GHC against the ghc/ shim instead of
GHC's own base β this is the "local lib" build the aux doc's
bootboottest traces back to. The same commit finally finishes what
2f89971e left mid-keystroke back in chapter 06: Translate.hs's
comb "IO.h β an unterminated string literal that couldn't even lex β
becomes a real, if still partial, primitive-ops table
(2a95e71e:src/MicroHs/Translate.hs lines 60β67, quoted whole):
, comb "IO.>>=" iobind
, comb "IO.>>" iothen
, comb "IO.return" ioret
-- , comb "IO.getChar" getc
, comb "IO.putChar" putc
, comb "IO.stdin" stdin
, comb "IO.stdout" stdout
, comb "IO.stderr" stderr
An hour and fourteen minutes later, fcde46f4 ("Make it compile.")
is a four-line Makefile fix β a stray $(BOOTDIR) missing from
GHCFLAGS's include path β restoring the a.out target's own
buildability, nothing more than its title claims. The evening closes
with two small, honestly-titled commits: 27df3d65 ("Skip {- -}
comments. Fix bugs.") and, forty-one seconds later, fe2ea98a ("More
ignore"), a nested-comment parsing fix and a .gitignore entry.
81683158 ("Better Makefile") follows the next day.
"Make it compile with uhs"
Two days after that first evening, on 07-03, a four-commit run of small
refactors (688dfe52
"More functions.", 4e2bb772 "Refactor symbol table handling.",
50e30797 "Refactor symbol table creation.", a3302100 "Make module
definitions shadow imports.") leads into the first commit actually
titled for the new goal: d6c25f00 ("Make ParserComb compile with
uhs."), 408 lines changed across ParserComb.hs and Parse.hs. The
Functor/Applicative/Monad/Alternative instances chapter 02's own
aux doc, parser-combinators.md, walks in
detail are gone; in their place, plain top-level functions with the same
names and the class methods' old bodies, newtype downgraded to data
because uhs doesn't have newtype either, and the one place a real
Show instance existed now sits as a dead comment
(d6c25f00:src/Text/ParserComb.hs lines 65β70, quoted whole):
data Prsr s a = P ((String, s) -> Res s a)
runP :: Prsr s a -> ((String, s) -> Res s a)
runP pp =
case pp of
P p -> p
--instance Show (Prsr s a) where show _ = "<<Prsr>>"
The monadic bind itself moves from an instance Monad method to a
free function of the same name, and its list-comprehension pattern
binder ((a, u) <- aus) becomes an explicit let-destructuring inside a
generator clause instead β uhs's comprehension desugaring at this point
only ever binds one name at a time straight from a lambda argument, not a
tuple pattern mid-comprehension (d6c25f00:src/Text/ParserComb.hs lines
75β82, quoted whole):
--Xinfixl 1 >>=
(>>=) :: Prsr s a -> (a -> Prsr s b) -> Prsr s b
(>>=) p k = P $ \ t ->
case runP p t of
Many aus plf ->
let { xss = [ runP (k a) u | au <- aus, let { (a, u) = au } ] }
in case unzip [ (rs, lf) | xs <- xss, let { Many rs lf = xs } ] of
(rss, lfs) -> Many (concat rss) (longests $ plf : lfs)
The --Xinfixl 1 >>= comment above it is the marker the aux doc
explains in full: invisible to uhs, uncommented into a real infixl
declaration for GHC by the convertX.sh preprocessor. Two days later,
f37b389f ("Make it compile with uhs", 397 lines in Parse.hs) repeats
the same treatment on the parser proper β every deriving (Show) becomes
--Xderiving (Show), and elem/== calls that leaned on Eq get
replaced by the explicit, monomorphic elemBy/eqString helpers
src/Compat.hs supplies for exactly this purpose.
Later that same 07-05, bd8a47d7 ("Make the Exp module compile with
uhs.") does the same to Exp.hs, and this time the commit says the quiet
part out loud in its own body: "This makes it quite ugly. :(". The
PatternSynonyms extension Exp.hs used since chapter 02 (pattern App2 x y z = App (App x y) z, and friends) is commented out wholesale, and
every place that leaned on those patterns for readable multi-clause
matches grows an explicit helper that returns an ordinary sum type and a
case over it (bd8a47d7:src/MicroHs/Exp.hs lines 19β28, quoted whole):
data MaybeApp = NotApp | IsApp Exp Exp
getApp :: Exp -> MaybeApp
getApp ae =
case ae of
Var _ -> NotApp
App f a -> IsApp f a
Lam _ _ -> NotApp
Int _ -> NotApp
Prim _ -> NotApp
cS, the S-combinator constructor that chapter 02's optimizer built out
of six terse pattern-matched equations (bd8a47d7:src/MicroHs/Exp.hs
lines 189β194 show the old form, still present in the same diff as
dead code below the new one), is rewritten around getApp and isPrim
calls into three separate functions (cS/cS2/cS3, roughly 47 lines
combined) chained by nested if/case β same optimization, far more
code, because pattern-matching on PatternSynonyms constructors is
gone and there's no other way left to ask "is this expression an
application of K" without spelling out the whole case analysis by
hand. Three commits, one throughline: nothing
here changes what the compiler does β every rewrite is behavior-
preserving β only what vocabulary it's allowed to use to say it.
515664d1: wrapping IO so unsafeCoerce behaves
Later the same day d6c25f00 lands β the very next commit, nine hours
on, at 21:28 that evening β 515664d1
("Make unsafeCoerce work by wrapping IO in a data constructor.") fixes a
narrower problem: Translate.hs's trans function turns every
compiled combinator term into a GHC value via unsafeCoerce :: Exp -> Any, and GHC's optimizer is free to treat a bare, coerced IO a action
differently from an ordinary value β nothing forces it to actually stay
an unevaluated IO action across the coercion boundary. The fix is a
one-constructor wrapper that gives GHC something concrete to hold onto
(515664d1:src/MicroHs/Translate.hs line 14, quoted, alongside its use
seven lines later):
data DIO a = DIO { unDIO :: IO a }
in unDIO $ unsafeCoerce $ look mainName
"Running IO still doesn't work. :("
Fifteen minutes after 515664d1, 6f08f029 carries the frustration
straight into its own commit title: "Running IO still doesn't work.
:(". The diff is not, despite the title, a shrug β it's a real fix to
the three functions 515664d1's new DIO wrapper had left half-done.
Before this commit, iobind a k = unDIO a >>= k tries to bind a plain
GHC IO action's result straight into a continuation typed a -> DIO b, which doesn't type-check as written; 6f08f029 corrects all three
combinators to unwrap and rewrap DIO consistently
(6f08f029:src/MicroHs/Translate.hs lines 79β84, quoted whole):
iobind :: DIO a -> (a -> DIO b) -> DIO b
iobind a k = DIO (unDIO a >>= \ x -> unDIO (k x))
iothen :: DIO a -> DIO b -> DIO b
iothen a b = DIO (unDIO a >> unDIO b)
ioret :: a -> DIO a
ioret a = DIO (return a)
A real correctness fix, landing under a title that reads like defeat β the sad face is honest about the overall state (something downstream still doesn't run), not about this particular diff's quality.
Whatever was still broken after 6f08f029 waits until the next session:
nothing lands 07-04, and the next sixteen commits β everything from
67715b16 ("Add 'not'") through c275f468 ("More testing") β all carry
timestamps between 12:22:03 and 12:58:43 on 07-05, a 36-minute window two
days later. Inside that window sits b808bc73 ("Fix IO primitives."),
which is where the actual IO fix lives. The bug is a convention
mismatch across the primitives boundary. lib/System/IO.hs's
hGetChar wrapper reads a character through P.primHGetChar and
checks for an end-of-file sentinel (1abb181e:lib/System/IO.hs
lines 25β30, quoted whole β 1abb181e is b808bc73's parent):
hGetChar :: Handle -> IO Char
hGetChar h = do
c <- P.primHGetChar h
case ord c == negate 1 of
False -> return c
True -> error "hGetChar: EOF"
Under uhs's own runtime that convention works: ord is the identity
function (chapter 06's CHAR removal made it so), and eval.c's
IO.getChar really does hand back -1 at end of file. But
ghc/Primitives.hs's shim had simply aliased GHC's own functions β
and left two others unwritten (1abb181e:ghc/Primitives.hs lines
66β69, quoted whole):
primHPutChar = hPutChar
primHGetChar = hGetChar
primOpenFile = undefined
primIsNullHandle = undefined
GHC's hGetChar never returns a sentinel β it throws an exception at
end of file β so the wrapper's ord c == negate 1 branch could never
fire under the GHC build (no Char's ord is negative there), and
primOpenFile had been undefined since ff3837b9 first wrote it on
07-01. The code type-checks fine on both paths; it's the semantics
of the shim that don't match the runtime's contract. b808bc73 makes
the shim honor the same convention the uhs runtime speaks β Int
character codes with -1 at EOF, checked explicitly via hIsEOF
(b808bc73:ghc/Primitives.hs lines 66β71, quoted whole):
primHPutChar :: Handle -> Int -> IO ()
primHPutChar h c = hPutChar h (toEnum c)
primHGetChar :: Handle -> IO Int
primHGetChar h = do eof <- hIsEOF h; if eof then pure (-1) else fromEnum <$> hGetChar h
primOpenFile :: String -> Int -> IO Handle
primOpenFile s m = openFile s (case m of 0->ReadMode; 1->WriteMode; 2->AppendMode; 3->ReadWriteMode)
and, in the same commit, adjusts lib/System/IO.hs's wrappers to
match: the sentinel test becomes c == negate 1 on the raw Int, with
an explicit chr/ord conversion at the boundary β
b808bc73:lib/System/IO.hs line 31, quoted whole:
hPutChar h c = P.primHPutChar h (ord c). Unlike 6f08f029, this
title matches its diff exactly β it fixes IO primitives, on both sides
of the boundary, and nothing else is smuggled in.
The diet
Inside that same 07-05 burst, three more threads close out the "diet" the
aux doc names directly. a721e046 ("Use Int instead of Integer") narrows
Expr's EInt field from Integer to Int
(a721e046:src/MicroHs/Parse.hs line 49: | EInt Int) and drops the
now-unneeded fromInteger call in the desugarer
(a721e046:src/MicroHs/Desugar.hs line 104: dsExpr _ (EInt i) = Int i); 2ff2a2e9 ("More s/Integer/Int/") mops up one remaining
Parse.hs site the same day. f113210f ("Use our own Map.") replaces
qualified Data.Map with a new src/MicroHs/Map.hs β a plain
association-list wrapper that takes its equality test as an ordinary
function argument rather than an Ord constraint, candid about its own
limits in a comment beside the type (f113210f:src/MicroHs/Map.hs lines
1β33, quoted whole):
module MicroHs.Map(module MicroHs.Map) where
import Prelude
--Ximport Compat
-- This is a pretty bad implementation.
data Map k v = Map [(k, v)]
--Xderiving(Show)
insert :: k -> v -> Map k v -> Map k v
insert k v (Map kvs) = Map ((k, v):kvs)
fromListWith :: (k -> k -> Bool) -> (v -> v -> v) -> [(k, v)] -> Map k v
fromListWith eq un =
let
ins ikv akvs =
case akvs of
[] -> [ikv]
kv : kvs ->
if eq (fst ikv) (fst kv) then
(fst ikv, un (snd ikv) (snd kv)) : kvs
else
kv : ins ikv kvs
in
Map . foldr ins []
fromList :: [(k, v)] -> Map k v
fromList = Map
union :: Map k v -> Map k v -> Map k v
union (Map kvs1) (Map kvs2) = Map (kvs1 ++ kvs2)
lookup :: (k -> k -> Bool) -> k -> Map k v -> Maybe v
lookup eq k (Map kvs) = lookupBy eq k kvs
And fixity: three commits inside the same burst β edfb9bde ("Add
fixties"), 1200d1a3/56ad54ae (both "--Yinfix") β sprinkle
--Yinfixr/--Yinfixl/--Yinfix comments through lib/Data/Bool.hs,
lib/Data/Int.hs, and lib/Data/Function.hs
(edfb9bde:lib/Data/Bool.hs lines 7β8, quoted):
--Yinfixr 2 ||
(||) :: Bool -> Bool -> Bool
These are not read by uhs at all β its own parser resolves every
operator's precedence from a fixed, hand-written ladder in pExpr
(unchanged by any commit in this whole range), not from declarations in
the library source. The --Y marker exists purely so the other GHC
build β the "local lib" path 2a95e71e set up, which compiles
lib/Data/Bool.hs directly under GHC via the convertY.sh preprocessor
β sees a real infixr 2 || and parses (||)'s own definition and every
call site the way the library author intended. The mechanism in full,
including why two separate markers (--X/--Y) exist for two separate
GHC builds, is the aux doc's subject,
not repeated here.
59b8b1ab: a rename that isn't just a rename
Still inside the same 16-commit, 36-minute burst (67715b16 through
c275f468, above), 59b8b1ab lands at 12:57:39 under the title "Fix
name" β no trailing period. It does contain a rename β lib/PreludeNOIO.hs becomes
lib/PreludeNoIO.hs, fixing an inconsistent capitalization. But the
same commit also adds three files the title never mentions: A.hs (9
lines, testing operator sections against +/-), Test.hs (39 lines,
an early ancestor of what later becomes the project's throwaway
Main.hs scratch harness), and t.hs (2 lines, a fixture Test.hs
reads). Fifty lines of new Haskell, three new files, under a two-word
title about one file's casing.
8ab69cb0: a squash! that never got squashed
An hour and nine minutes later, in the next cluster that same afternoon
(13:22:18β14:07:11), 46c4309f ("infixr 9 .") adds one more
--Yinfixr 9 . comment to Data.Function, 14:07:00. Eleven seconds
after it, 8ab69cb0's commit title is squash! infixr 9 . β Git's own
convention for a commit meant to be folded into an earlier one (here,
46c4309f) by git rebase --autosquash. That rebase never ran:
8ab69cb0 sits in this repository's history as its own commit,
forever, eleven seconds after the commit it was named to merge into.
Its diff has nothing to do with infixr 9 . at all. Six files change:
a new alltest Makefile target; a new enumFrom in lib/Data/List.hs;
two new helpers in src/Compat.hs; four dead --X comment lines
removed from src/Text/ParserComb.hs; a deriving-stripped, case-
rewritten Exp.hs (the same "make it compile with uhs" treatment
bd8a47d7 gives the rest of the file two commits later); and, in
src/MicroHs/Main.hs, the Lbl Int Exp constructor's last use quietly
disappears β the labeled-definition wrapper App2 CT (Lbl i (subst e)) r becomes a hand-built string, with the old call left behind as a dead
comment right next to its replacement (8ab69cb0:src/MicroHs/Main.hs
lines 31β33, quoted whole):
def :: ((Ident, Exp), Int) -> String -> String
def ((_, e), i) r = "(($T :" ++ showInt i ++ " " ++ toStringP (subst e) ++ ") " ++ r ++ ")"
-- App2 CT (Lbl i (subst e)) r
A whole data constructor retired, in a commit whose title names an
unrelated one-line fixity comment it was never even merged into β the
same lesson chapter 06 drew from 2f89971e's stowaway Translate.hs:
read the diff, not just the subject line.
The last two weeks
The range closes with a longer, more scattered stretch: 07-06 and 07-07
bring a getArgs preparation and a pattern-matching cleanup,
6b5741e1 ("Get rid of PTuple constructor."); then four quiet days,
one lone commit breaking them, 4ae4fd20 ("Add copyright") on 07-11,
adding a BSD-style LICENSE file, and four quiet days more before
07-15 resumes with a358eaa6 ("Add
copyright.") stamping a two-line header onto 27 source files, followed
35 minutes later by 52ee83e2 ("Start of complex patterns.") β pattern
matching resuming after the 07-07 cleanup β and 28 minutes after that by
d514d2d9 swapping the whole project to the Apache license instead β
copyright housekeeping and pattern-matching work interleaved the same
afternoon. 07-16
and 07-17 grind through more of the "make it compile with uhs" series
by name (a36ca755 "Make Desugar compile with uhs", 7e7c18d1 "Make
Compile module work with uhs", 5e462798 "Make Main work with uhs."),
getArgs support, and β honestly titled, no surprises in the diff β
e61ea002 ("A hack to make openFile compatible with uhs."). The range's
last commit, a824a175 ("Check for stack overflow."), 07-18 15:04:09,
replaces the C runtime's fixed-size stack array with a malloc'd one
sized by a new -K flag β the same flag the aux
doc's bootcombtest target invokes
(-K1000000) to survive interpreting the compiler's own bulky combinator
file. What that target actually running looks like is chapter 08's
story.
Why it matters
Self-hosting is a forcing function with an unusually sharp feedback
signal: a gap in the compiler's own feature set doesn't show up as a
missing test case someone has to think to write, it shows up as "my own
compiler's source won't compile" the moment that gap sits between the
compiler and itself. Every thread this chapter walks β no type classes,
no deriving, a hand-rolled Map, Int instead of Integer, fixity
resolved by a fixed parser ladder instead of declarations β is something
the compiler's own source needed and didn't have, discovered one
"make it compile with uhs" commit at a time rather than planned in
advance. The TODO list writes itself: point the compiler at its own
three-thousand-odd lines (src//lib/ combined, .hs files only, at
this range's close) and see what breaks.
Notebook margin
- "More functions." (and its unpunctuated twin, "More functions") opens 13 separate commits across this range's 90 β the grind's own rhythm, the same small, load-bearing filler chapter 06 first named.
8ab69cb0's title,squash! infixr 9 ., is Git's own convention for "please fold me into the commit with this subject viagit rebase --autosquash" β a request nobody ever ran. It's still sitting there, its own permanent commit, eleven seconds after the commit it asked to disappear into.- The 16 commits from
67715b16throughc275f468all carry timestamps inside one 36-minute window on 07-05 (12:22:03β12:58:43) β the tightest cluster in this chapter's range, and the one that happens to contain both the real IO fix (b808bc73) and one of the two undisclosed-extras commits above (59b8b1ab, at 12:57:39). The other,8ab69cb0, sits in the very next cluster that same afternoon (13:22:18β14:07:11). - The copyright thread took two license files four days apart: a
BSD-style
LICENSEon 07-11 (4ae4fd20), then an Apache-license swap on 07-15 (d514d2d9), 64 minutes after 27 files got copyright headers stamped on them bya358eaa6.
Try it
Anchor: f113210f ("Use our own Map."), 2023-07-05 20:55:37 β chosen
because it's the last commit of that day's third, late-evening session
(6c9d021f through f113210f, 19:34β20:55), sitting just after
bd8a47d7's Exp-module rewrite and before the next day's getArgs
work, and because β checked directly, not assumed β it builds cleanly as
written. Convention (see
00-overview.md):
git worktree add /tmp/mhs-f113210f f113210f
cd /tmp/mhs-f113210f
This commit's Makefile builds $(BIN)/uhs from src/*/*.hs through
the convertX.sh preprocessor described in the aux
doc, and $(BIN)/eval from
src/runtime/eval.c β both built and run below exactly as written, no
patch needed:
mkdir -p bin
nix develop $MICROHS -c make bin/uhs
[...]
[10 of 11] Compiling Main ( src/MicroHs/Main.hs, ghc-out/Main.o )
[11 of 11] Linking bin/uhs
(a run of -Wx-partial warnings about head/tail, one per call site,
omitted β the same harmless category earlier chapters' Build notes
already flag).
nix develop $MICROHS -c make bin/eval
gcc -Wall -O3 src/runtime/eval.c -o bin/eval
Exercise 1: run the ghc-built uhs on one of its own source modules
This commit's top-level Main.hs is the project's own scratch harness β
it imports MicroHs.Parse, one of the compiler's real source modules
under src/MicroHs/, and re-parses a tiny fixture file, t.hs, through
it. Compiling it needs both -ilib (for Prelude) and -isrc (for
MicroHs.Parse itself) β exactly the Makefile's own uhstest target:
nix develop $MICROHS -c ./bin/uhs -ilib -isrc Main
This exits silently and writes out.comb. A trimmed look at the file
(it's one line, 32590 bytes on this build; head and tail shown, ...
marking everything omitted between them):
(($T :0 _198) (($T :1 (_0 (($O 117) (($O 110) (($O 100) (($O 101) (($O 102) ...
...
))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))
Running it through bin/eval confirms the whole pipeline β GHC-built
uhs, compiling a module that itself imports a real compiler source
file, producing a .comb file eval can run β actually works at this
commit:
nix develop $MICROHS -c ./bin/eval
Start
File read: t.hs
module T(
module T
) where
That's Main.hs's own putStrLns ("Start", then the file-read
message) followed by showEModule's rendering of t.hs's one-line
module, module T(module T) where β parsed by MicroHs.Parse, compiled
by a GHC-built uhs, run by eval.
Exercise 2: what typeclass usage survives in src/
nix develop $MICROHS -c grep -rn "class \|instance " src/
src/Text/ParserComb.hs:67:--instance Show (Prsr s a) where show _ = "<<Prsr>>"
One hit, across every .hs file under src/MicroHs/ and
src/Text/ParserComb.hs combined β and it's commented out, the fossil
d6c25f00 left behind when it rewrote ParserComb.hs's instances into
plain functions (quoted in "What happened," above). Zero live class
declarations, zero live instance declarations anywhere in the
compiler's own source at this commit. The same search over lib/ (the
library uhs itself compiles programs against) comes back empty too β
not even a dead comment there.
Exercise 3: what convertX.sh/convertY.sh paper over
cat convertX.sh
cat convertY.sh
#!/bin/sh
( echo "{-# LINE 1 \"$1\" #-}" ; sed -e 's/--X//' $2 ) > $3
#!/bin/sh
( echo "{-# LINE 1 \"$1\" #-}" ; sed -e 's/--Y//' $2 ) > $3
Two lines each, and identical but for one character: strip a marker
(--X or --Y) out of every line of the file, after stamping a {-# LINE #-} pragma so GHC's own error messages still point at the original
file and line number. The gap they paper over is everything GHC's
Haskell needs that uhs's Haskell doesn't have a way to say: real
hiding clauses on import Prelude (so GHC's own Monad/Applicative
methods don't collide with this project's hand-rolled >>=/pure),
real infixl/infixr/infix declarations (so GHC's parser gives
operators like (||) the precedence their definition assumes), real
deriving (Show) clauses. Every one of those, in this chapter's source,
is written as an ordinary two-character-prefixed comment β invisible to
uhs's own parser, which just sees -- and stops reading β and turned
back into live code only when one of these two scripts runs as GHC's
own preprocessor first.
Clean up:
git worktree remove --force /tmp/mhs-f113210f
08 β Bootstrap
Where we are
Chapter 07 closes at a824a175 ("Check for stack overflow."), 2023-07-18
15:04:09 β the compiler's own source has been squeezed, module by module,
into the narrow dialect it can read itself: no type classes, no
deriving, no fixity declarations, a hand-rolled Map, Int in place of
Integer. That last commit replaces the C runtime's fixed-size
interpreter stack array with a malloc'd one, sized by a new -K flag.
Three minutes and twenty seconds later, 456ce17c lands. The ladder
those two commits and everything before them were built for β GHC
compiles the compiler, the compiler compiles itself, the C runtime runs
that self-compile, and a byte-for-byte cmp checks the two results agree
β is aux/bootstrapping-a-compiler.md's
subject in full; read it first. This chapter narrates what happens the
moment that ladder gets make targets of its own and someone actually
climbs it.
What happened
The range a824a175..298c9824 is 28 commits, all by Lennart Augustsson,
spread across two and a half calendar days: nineteen commits on 07-18
(15:07β23:19), four on 07-19 (13:11β19:29), and five on 07-20
(11:20β14:58).
456ce17c: the milestone
456ce17c ("Add bootstrap test."), 15:07:29, adds one new Makefile
target β four lines (456ce17c:Makefile lines 66β69, quoted whole):
bootcombtest: $(BIN)/uhs uhs.comb
$(BIN)/uhs -ilib -isrc -omain-uhs.comb MicroHs.Main
$(BIN)/eval -H10000000 -K1000000 -ruhs.comb -- -ilib -isrc -omain-comb.comb MicroHs.Main
cmp main-uhs.comb main-comb.comb
Line one has the GHC-built bin/uhs compile MicroHs.Main β its own
compiler's entry point, the whole src/MicroHs/lib tree it imports β
straight to main-uhs.comb. Line two hands uhs.comb (a separate,
already-built target: bin/uhs compiling itself to a combinator file) to
bin/eval, the C runtime, and tells it to run that combinator file as a
program, with the same compiler arguments (-ilib -isrc -omain-comb.comb MicroHs.Main) tacked on after the --. uhs.comb is uhs, translated
into the combinator terms eval already knows how to run β chapter
02's .comb format, unchanged in shape since then. So line two is the
interpreted compiler compiling the same source a second time, writing
main-comb.comb. Line three is the oracle: plain byte equality, no
special-casing. If a native, GHC-built compiler and that same compiler,
translated into combinators and run by the C interpreter, produce
identical output compiling their own source, the ladder holds. The full
treatment of why cmp (rather than some semantic check) is the right
oracle, and of bootboottest, the parallel "local lib" rung, is the aux
doc's; this chapter's job is watching bootcombtest actually run.
fc7a2ad7 / f06bacb7 / 1876f4c1: the reality of a compiler-compiling-compiler workload
Running a compiler on nothing but a few short test files, the way every
prior chapter's Try-it does, and running it on itself β 30 .hs files
under src/ and lib/, 73,856 bytes combined at this commit (counted
with git ls-tree -r -l 456ce17c -- src lib) β are different workloads, and the
difference shows up within the hour.
fc7a2ad7 ("Add comments."), 15:36:42, is mostly what its title says:
short /* ... */ annotations next to eval.c's combinator cases (/* K x y = * x */ and the like). But three of its added lines, sitting right
above the IO_BIND case, are not decoration β they're a TODO note
written against a real, anticipated problem (fc7a2ad7:src/runtime/eval.c
lines 977β979, quoted whole):
/* XXX could use associativity to avoid deep evalio recursion. */
/* (m >>= g) >>= h === m >>= (\ x -> g x >>= h) */
/* BIND (BIND m g) h === BIND m (\ x -> BIND (g x) h) == BIND m (C' BIND g h)*/
Thirty-five minutes later, f06bacb7 ("Commented out non-working
alternative."), 16:12:11, tries a fix β in the library, not the
runtime. lib/System/IO.hs's hGetContents reads a file handle one
character at a time, and its recursive case is an ordinary do-bound
>>=: each character read nests one more IO.>>= around the rest of the
file (f06bacb7:lib/System/IO.hs lines 126β135, quoted whole):
-- Strict hGetContents
hGetContents :: Handle -> IO String
hGetContents h = do
c <- P.primHGetChar h
case c == negate 1 of
False ->
do { cs <- hGetContents h; return (chr c:cs) }
-- This should use less stack, but it doesn't work. :(
--return (chr c : P.primPerformIO (hGetContents h))
True -> return ""
This is exactly the site the TODO comment above was worried about:
src/MicroHs/Compile.hs's readFilePath calls IO.hGetContents to read
every module the compiler compiles, so a source file of N characters
builds a chain of N nested >>=s before any of them run. The commented-
out line tries routing around that with P.primPerformIO instead of a
recursive do-bind; the comment says plainly it doesn't work, and the
attempt is left in place, dead, rather than deleted.
Twenty-one seconds after that, 1876f4c1 ("Use associativity of IO.>>=
to avoid deep evalio recursion."), 16:12:32, fixes the actual mechanism β
in the runtime, where the TODO comment said it belonged. Before this
commit, evalio's IO_BIND case handles m >>= g by calling evalio
recursively on m β so for a left-nested chain (m >>= g) >>= h,
evaluating the outer bind means a nested C function call to evaluate the
inner one, one native stack frame per bind in the chain. A hand-read of
one character produces one bind; a source file produces as many nested
binds as it has characters, and that recursion runs on the C process's
own call stack, not the interpreter's own -K-sized NODEPTR stack
a824a175 just made growable β a different stack, with no such flag.
1876f4c1's fix uses the algebraic law fc7a2ad7's TODO comment already
named β (m >>= g) >>= h === m >>= (\x -> g x >>= h) β to rewrite the term
instead of recursing into it, and loops back to the top of the evaluator
via goto top rather than making a nested call
(1876f4c1:src/runtime/eval.c lines 980β998, quoted whole):
case IO_BIND:
CHECKIO(2);
{
/* Use associativity to avoid deep evalio recursion. */
/* (m >>= g) >>= h === m >>= (\ x -> g x >>= h) */
/* BIND ((BIND m) g) h === BIND m (\ x -> BIND (g x) h) == (BIND m) (((C' BIND) g) h)*/
NODEPTR bm;
NODEPTR bmg = evali(ARG(TOP(1)));
GCCHECKSAVE(bmg, 4);
if (TAG(bmg) == AP && TAG(bm = indir(FUN(bmg))) == AP && TAG(indir(FUN(bm))) == IO_BIND) {
NODEPTR g = ARG(bmg);
NODEPTR h = ARG(TOP(2));
n = new_ap(bm, new_ap(new_ap(new_ap(combCC, combIOBIND), g), h));
POP(3);
goto top;
}
}
x = evalio(ARG(TOP(1))); /* first argument, unwrapped */
Precision matters here: this is not a report of >>= producing wrong
answers, and nothing about it changes what any bind computes β every
m >>= g still ends up running g's continuation with m's result, in
order. What changes is how many native stack frames evaluating a
left-nested chain of binds costs: unboundedly many before this commit
(one per bind, however deep the chain), a constant number after it,
because the interpreter loop now iterates instead of recursing whenever
it can. The bug this closes is a stack-depth risk under a
character-at-a-time file reader run against real source files β a
compiler compiling itself is the first workload in this project's history
that produces IO chains long enough to make that risk concrete.
5d58461f: bigger ints, honestly
Six minutes and forty-eight seconds after the associativity fix,
5d58461f ("Use bigger ints."), 16:19:20, is not about the Haskell Int
type user programs see β that had already narrowed to machine width back
in chapter 06/07's diet. It widens the runtime's own bookkeeping counters
from int to int64_t: num_reductions, num_gc, num_marked,
max_num_marked, start_size, and several heap-indexing loop variables
in init_nodes, scan, gc, and parse_top
(5d58461f:src/runtime/eval.c lines 121β122, quoted):
int64_t num_reductions = 0;
int64_t num_gc = 0;
A test program's reduction and GC counts fit comfortably in 32 bits; a compiler compiling several thousand lines of its own source does not stay comfortably inside that margin for long. Nothing user-visible about program semantics changes β only how far the runtime's own statistics can count before silently wrapping.
1a84b483: give up when the heap is down to 2%
6ca78e8a ("Cleanup"), 23:16:14, and eight seconds later 1a84b483
("Give up when heap is down to 2%"), 23:16:22, land at the end of the
same long evening. The GC's gc() function already tracked how many
cells survived each mark-and-sweep pass; this commit adds one check right
after that count is known (1a84b483:src/runtime/eval.c lines 334β338,
quoted whole):
if (num_marked > max_num_marked)
max_num_marked = num_marked;
int64_t free = heap_size - heap_start - num_marked;
if (free < heap_size / 50)
ERR("heap exhausted");
heap_size / 50 is exactly 2% of the heap. This is not a smarter
collector or a bigger one β the heap size is still whatever -H says it
is β it's an honest bail-out: once a GC pass leaves fewer than 2% of the
heap free, the runtime stops and reports "heap exhausted" instead of
continuing to collect ever more often for ever less benefit, or running
out mid-allocation with no diagnostic at all. It is the runtime meeting
its own limit, for the first time, under a workload big enough to find
it β an honest ceiling stated in the same commit that discovers it
matters.
ee853660 / 04a4bbe4 / 8ad7094c: performance work, only after a real workload
Two days later, on 07-20, ee853660 ("Specialize the Map to
StringMap."), 11:20:05, deletes src/MicroHs/Map.hs β the general
Map k v chapter 07 built with an explicit equality argument on every
call β and replaces every use of it in Compile.hs, Desugar.hs,
Main.hs, and Translate.hs with src/MicroHs/StringMap.hs, a version
specialized to String keys with eqString baked in rather than passed
at each call
site: ee853660:src/MicroHs/Compile.hs line 79 reads case M.lookup nm ch of where the previous commit's same line read case M.lookup eqIdent nm ch of. Twenty-five minutes later,
04a4bbe4 ("Sort in frequency order."), 11:45:47, reorders two lookup
tables that are both searched linearly, string by string, on every
combinator or primop name the compiler ever reads or writes: eval.c's
primops[], which parse() scans on every .comb-file token, and
Translate.hs's primTable. Both diffs add a comment naming the same
reasoning (04a4bbe4:src/runtime/eval.c lines 169β190 and
04a4bbe4:src/MicroHs/Translate.hs lines 36β51, quoted whole, ...
marking the primop entries omitted after each combinator table):
/* We use linear search in this, because almost all lookups
* are among the combinators.
*/
struct {
char *name;
enum node_tag tag;
NODEPTR node;
} primops[] = {
/* combinators */
/* sorted by frequency in a typical program */
{ "B", B },
{ "O", O },
{ "K", K },
{ "C'", CC },
{ "C", C },
{ "T", T },
{ "S'", SS },
{ "P", P },
{ "I", I },
{ "S", S },
{ "Y", Y },
{ "B'", BB },
...
-- Use linear search in this table.
-- 99% of the hits are among the combinators.
primTable :: [(String, Any)]
primTable = [
("B", primitive "B"),
("O", primitive "O"),
("K", primitive "K"),
("C'", primitive "C'"),
("C", primitive "C"),
("T", primitive "T"),
("S'", primitive "S'"),
("P", primitive "P"),
("I", primitive "I"),
("S", primitive "S"),
("Y", primitive "Y"),
("B'", primitive "B'"),
...
Both tables move B and O to the front and push S, Y, and B' β the
combinators chapter 01's bracket abstraction produces least often in
practice β toward the back, on the strength of a one-line comment about
typical frequency, not a cited measurement. Between these two, at
16:50:16 on 07-18 (the same evening as the recursion fix, not the same
day as the reordering), 8ad7094c ("Add profiling option.") adds a
PROF variable to the Makefile, wired into bootuhs's build and link
steps: GHC's own -prof -fprof-auto cost-center profiler, applied to the
"local lib" GHC build of the compiler. Built and run this chapter's own
Try-it below, that profiler's report is dominated by
Text.ParserComb.(>>=), Data.List.(++), and Text.String.eqString β
Haskell-level cost centers inside the compiler's own parser and
library, measured on bootuhs, a native GHC binary. It has nothing to
say about which combinator names β S, K, B, O β turn up most often
in a .comb file, which is what 04a4bbe4's tables are sorted by; that's
a property of the C runtime eval and the translator Translate.hs,
neither of which the GHC profiler ever touches. Checked directly rather
than assumed: no commit message, code comment, or file in this range
connects the two. They read as two independent performance
interventions from the same 48-hour window β one instruments the
compiler's own Haskell, the other hand-tunes a linear search in a
completely different program β not a pipeline where one feeds the other.
db418344: type parsing, parse-only
db418344 ("Add type parsing."), 18:46:43, gives Parse.hs a real type
syntax for the first time since chapter 06 flattened type Type = Expr.
A new EType distinguishes type variables, constructor applications, and
unification variables, and ETypeScheme adds forall
(db418344:src/MicroHs/Parse.hs lines 99β107, quoted whole):
-- No higher order type variables (yet)
data EType
= TVar Ident
| TConstr Ident [EType]
| TUVar Int
--Xderiving (Show)
data ETypeScheme = ETypeScheme [Ident] EType
--Xderiving (Show)
Sign :: Ident -> ETypeScheme -> EDef now carries a real parsed type
scheme instead of an untyped Expr alias. But Desugar.hs, unchanged in
this respect by this commit, still turns every Sign into nothing
(db418344:src/MicroHs/Desugar.hs line 151, quoted): Sign _ _ -> [].
Types are syntax the parser understands and the EDef AST can carry, and
nothing downstream reads them yet β no checker, no inference, not even a
warning. The same parse-only posture chapter 03 gave the compiler's
earliest type signatures, one step further along; what those parsed types
eventually get used for is chapter 09's subject, not this one's.
298c9824: uhs becomes mhs β what actually renames
The range's last commit, 298c9824 ("Rename uhs to mhs."), 14:58:46,
touches exactly two files: Makefile and tests/Makefile. No file is
git mv'd, no module is renamed β MicroHs.Main was already called
that. What changes is a new MHS=mhs Makefile variable substituted
everywhere the literal string uhs had named a build artifact: the
binary ($(BIN)/uhs becomes $(BIN)/$(MHS)), its GHC-built,
local-library sibling (bootuhs becomes boot$(MHS)), the self-compiled
combinator file (uhs.comb becomes $(MHS).comb), the phony test target
(uhstest becomes $(MHS)test), and, in tests/Makefile, the UHS
variable itself is renamed to MHS=../bin/mhs. The project earns its
name β MicroHs β in its build vocabulary first. It doesn't earn it
everywhere at once: a grep -rn uhs over src//lib//ghc/ at this
commit still turns up one hit, src/MicroHs/Main.hs's own usage message,
unchanged by this commit (298c9824:src/MicroHs/Main.hs line 23,
quoted): error "Usage: uhs [-v] [-r] [-iPATH] [-oFILE] ModuleName". The
compiler renamed by this commit still calls itself uhs to its own
users' faces.
Undisclosed extras
Two commits carry more than their titles say. 787d8842 ("Add an
(unused) Data.Map"), 14:48:47, does add the promised file β 177 lines,
a real balanced 2-3 tree implementation, built but wired into no call
site (the Makefile only compiles it as part of bootuhs, never imports
it from anywhere that matters) β but the same commit also adds a
leIdent helper to Parse.hs and moves StringMap.hs's type
signatures into a new trailing block alongside a fresh commented-out
Data.Map-based alternative implementation, neither mentioned in the
title. 8e2f7109 ("Change test targets."), 16:25:51 on 07-18, mostly
does what it says β renaming and adding alltest/everytest targets β
but its bootcombtest diff also silently drops -K1000000 from the
eval invocation 456ce17c had added 1 hour 18 minutes earlier
(8e2f7109:Makefile line 67, quoted): $(BIN)/eval -H10000000 -ruhs.comb -- -ilib -isrc -omain-comb.comb MicroHs.Main β no -K1000000, and no
mention of it in the commit title β leaving bootcombtest
to run on eval's built-in default of 10,000 interpreter-stack cells
from then on β a hundredfold cut in an explicit safety margin, undisclosed.
On the other side of the ledger: acf40ed4 ("Remove junk"), 13:11:39 on
07-19, deletes A.hs and Test.hs β the exact two files 59b8b1ab
added, undisclosed, under the unrelated title "Fix name" back in chapter
07's range. Two weeks later, the same project removes them under a title
that says plainly what the diff does.
Why it matters
456ce17c isn't a feature landing; it's a fixpoint closing. Once
bootcombtest exists and passes, the compiler's own speed stops being a
side concern and becomes the benchmark that matters most: every commit
that makes the runtime or the compiler faster makes the next bootstrap
run faster too, and the bootstrap run is now the heaviest, most
representative workload in the whole project's test suite. That's
visible in the commit order itself. Every performance intervention in
this chapter β the int64_t widening, the 2%-heap bailout, StringMap,
the frequency-sorted lookup tables β comes after 456ce17c, several of
them within hours, none of them before. The runtime had gone 54 days,
from this repository's first commit (ccb31388, 2023-05-25) to
a824a175, without a single commit tuning its counters, its GC
threshold, its data structures, or its lookup order β earlier commits
titled "optimiser" (ae2a1403, d2956ee8, and others) are a different
concern entirely, code-quality passes over Exp.hs's bracket-abstraction
output, aux/bracket-abstraction.md's
subject, not runtime engineering. The self-compile is what finally gave
the runtime a workload worth engineering for, and the project engineered
for it immediately once it existed, not before.
Notebook margin
- The three fixes most directly forced by running a real self-compile β
1876f4c1's associativity rewrite,5d58461f's wider counters,1a84b483's heap bailout β land 7 hours 3 minutes 50 seconds apart at the outside (1876f4c1to1a84b483), and the tightest pair (f06bacb7to1876f4c1) is 21 seconds. - The StringMap/frequency-order/profiling cluster (
8ad7094c,ee853660,04a4bbe4) spans 42 hours 55 minutes 31 seconds end to end β still inside two days of456ce17c, even though the last two land on a different calendar day than the first. d8d8856b("Add version string to compiler binary."), 11:53:35 on 07-20, gives every.combfile a five-byte header ("v1.0\n") and adds acheckversioncheck toeval'smainthat reads and matches those bytes before parsing the rest of the file, oneERR("version mismatch")away from running a.combfile built by an incompatible compiler version.787d8842("Add an (unused) Data.Map"), the same afternoon, is the honest kind of speculative code this project allows: built, compiled, never called, and its title says so.
Try it
Anchor: 456ce17c ("Add bootstrap test."), 2023-07-18 15:07:29 β the
commit that adds the bootcombtest target this exercise runs. Convention
(see 00-overview.md):
git worktree add /tmp/mhs-456ce17c 456ce17c
cd /tmp/mhs-456ce17c
Build both binaries the target needs, exactly as written, no patch needed on this project's flake toolchain (GHC 9.8.4):
mkdir -p bin
nix develop $MICROHS -c make bin/uhs
nix develop $MICROHS -c make bin/eval
[...]
[12 of 12] Linking bin/uhs
gcc -Wall -O3 src/runtime/eval.c -o bin/eval
(The same -Wx-partial warnings on head/tail every earlier chapter's
Build notes already flag appear during the bin/uhs build; omitted.)
Now the money shot β the whole ladder, run as bootcombtest itself runs
it, timed step by step:
nix develop $MICROHS -c bash -c 'time bin/uhs -ilib -isrc -ouhs.comb MicroHs.Main'
real 0m0.964s
nix develop $MICROHS -c bash -c 'time bin/uhs -ilib -isrc -omain-uhs.comb MicroHs.Main'
real 0m0.942s
nix develop $MICROHS -c bash -c 'time bin/eval -H10000000 -K1000000 -ruhs.comb -- -ilib -isrc -omain-comb.comb MicroHs.Main'
real 0m21.273s
nix develop $MICROHS -c cmp main-uhs.comb main-comb.comb
cmp prints nothing and exits 0: main-uhs.comb and main-comb.comb β
68,799 bytes each β are byte-identical. The GHC-built bin/uhs compiled
its own source; that same source, translated into combinators and
interpreted by bin/eval, compiled it again; both runs produced the
exact same output. That's bootcombtest, in full, actually running,
at the commit that adds it β no stack overflow, no patch. (I also
re-ran the whole ladder at this range's last commit, 298c9824, with
bin/mhs in place of bin/uhs: main-mhs.comb and main-comb.comb
agreed there too, 75,052 bytes each, in 20.715s for the interpreted step
β so whatever native-stack risk 1876f4c1 closed, it isn't one this
environment's default 8 MB ulimit -s happens to trigger for this
particular self-compile; the fix is real and the mechanism above is read
directly from the diff, but I did not manage to reproduce the crash it
prevents.)
Exercise 1: time the eval-run self-compile against the GHC-built one
The three time runs above already answer this, on this machine: the
GHC-built bin/uhs compiles MicroHs.Main in well under a second
(0.942sβ0.964s, run to run); bin/eval interpreting the combinator
translation of that same compiler, doing the identical compile, takes
21.273s β a 22.6Γ slowdown (21.273 Γ· 0.942): native code versus a
graph-reduction interpreter walking the same combinator terms, on this
compiler's own source for the first time rather than on a short test
program the way every earlier chapter's time target measures it. The
same three-step timing at 298c9824, two days and 27
commits of source growth later (main-uhs.comb 68,799 bytes here versus
main-mhs.comb 75,052 bytes there, roughly 9% more program), gives
0.926s / 0.890s / 20.715s β a slightly better throughput per byte
(75,052 Γ· 20.715 β 3,624 B/s interpreted, versus 68,799 Γ· 21.273 β 3,234
B/s here), consistent with β though this single before/after pair can't
isolate exactly which of this range's performance commits earns it β
ee853660/04a4bbe4's lookup-table work paying for itself rather than
being pure overhead.
Exercise 2: corrupt one byte of uhs.comb
uhs.comb's text format names every combinator with a $-prefixed
token; find one and flip a single byte:
(python3 isn't in the devShell's buildInputs either β like diff in
chapter 01, it's whatever the host system provides on the PATH nix develop hands you.)
nix develop $MICROHS -c python3 -c "
data = bytearray(open('uhs.comb','rb').read())
assert data[53:54] == b'C'
data[53] = ord('Z')
open('uhs-corrupt.comb','wb').write(bytes(data))
"
Byte offset 53 (0-indexed) sits inside a \$C token near the start of
the file; the edit above turns it into \$Z , a combinator name that
does not exist. Ask eval to run it, the same way bootcombtest's
middle line does:
nix develop $MICROHS -c bin/eval -H10000000 -K1000000 -ruhs-corrupt.comb -- -ilib -isrc -ocorrupt-out.comb MicroHs.Main
eval: bad primop Z
ERR: no primop
Exit code 1. parse()'s primop table lookup (the same linear search
04a4bbe4 later reorders) finds no entry named Z, prints exactly the
diagnostic its fprintf/ERR calls promise, and the whole run aborts
before a single reduction happens β one corrupted byte, caught at parse
time, not miscompiled silently into the next bootstrap.
Exercise 3: the profiling flag and the frequency ordering β checked, not assumed
8ad7094c's PROF flag doesn't exist yet at the 456ce17c anchor, and
neither does 04a4bbe4's reordered primops[] table; this exercise
needs both, so it uses a second worktree at the commit where they last
appear together, 04a4bbe4 itself:
git worktree add /tmp/mhs-04a4bbe4 04a4bbe4
cd /tmp/mhs-04a4bbe4
mkdir -p bin
nix develop $MICROHS -c make PROF="-prof -fprof-auto" bin/bootuhs
[...]
ghc -prof -fprof-auto -hide-all-packages -o bin/bootuhs ghc-boot/*.o ghc-boot/*/*.o
nix develop $MICROHS -c bin/bootuhs -ilib -isrc -omain-boot.comb +RTS -p -RTS MicroHs.Main
This writes bootuhs.prof, GHC's own cost-center report (trimmed to the
top entries):
COST CENTRE MODULE SRC %time %alloc
>>=.\.xss Text.ParserComb src/Text/ParserComb.hs:80:15-69 18.8 7.4
++ Data.List lib/Data/List.hs:(22,1)-(25,26) 10.1 23.9
>>=.\ Text.ParserComb src/Text/ParserComb.hs:(78,5)-(82,68) 9.9 17.0
eqString Text.String lib/Text/String.hs:(101,1)-(110,44) 8.7 12.9
runP Text.ParserComb src/Text/ParserComb.hs:(66,1)-(68,12) 6.3 0.0
foldr.rec Data.List lib/Data/List.hs:(57,5)-(60,30) 4.2 2.8
...
Read this list straight: it's dominated by Text.ParserComb's >>=,
Data.List.(++), and Text.String.eqString β Haskell functions inside
the compiler's own parser and prelude, measured on bootuhs, a native
GHC binary running the "local lib" build. Not one combinator name β S,
K, B, O, the names 04a4bbe4's primops[]/primTable reorder β
appears anywhere in this report, because they belong to a different
program entirely: eval.c's interpreter and Translate.hs's translator,
neither of which bootuhs's GHC profiler ever runs. The "connect the
profiling flag to the frequency ordering" instinct doesn't survive this
run: they're two separate performance interventions, aimed at two
separate programs, that happen to land in the same 48-hour window. What
this report does support independently is 8f361a0a's "Fix stupid
eqString" from not quite two days earlier β eqString alone accounts for 8.7% of
this run's time and 12.9% of its allocation, exactly the kind of
hot-path cost a workload this size makes visible for the first time.
Clean up both worktrees:
git worktree remove --force /tmp/mhs-456ce17c
git worktree remove --force /tmp/mhs-04a4bbe4
09 β Types at last
Where we are
Chapter 08 closes at 298c9824 ("Rename uhs to mhs.", 2023-07-20
14:58:46): the compiler is self-hosting, bootcombtest passes, and the
binary finally answers to the name this book has used from the start,
mhs. Nothing about that binary checks a single type. f :: T has
parsed since chapter 03's e96d04be (2023-05-30) and gone straight to
dsDef (Sign _ _) = [] β accepted, then thrown away. Chapter 08's own
db418344 (2023-07-18) gave signatures a real grammar β EType, a
TVar/TConstr/TUVar type distinct from Expr, and ETypeScheme for
forall β but left the desugarer's verdict unchanged: Sign _ _ -> [],
still nothing downstream reads what's parsed. This chapter's range,
298c9824..a38c64ce, 24 commits over 21 days, is where something finally
does.
What happened
Three commits named "Update"
The range opens with 7936c701, bca0b02d, and 3719955b, all on
2023-07-20, all titled "Update", and all touching exactly one file each
time: README.md (4 insertions/13 deletions, 1/1, and 6 insertions with
nothing removed, in that order). None of the three has anything to do
with type checking β they're documentation upkeep sitting in the gap
between chapter 08's close and this chapter's first substantive commit.
Included here only because "commits named vaguely and checked anyway" is
this book's own standard, not because there's anything more to say about
them.
The prep, in one day: 33b009fa through e5bc9236
Five commits, all 2023-07-21, each making the ground firmer before anyone tries to write a type checker on it.
33b009fa ("Use Expr for EType.") deletes the three-constructor EType
db418344 had introduced two days and twenty hours earlier and replaces it with type EType = Expr plus a validType predicate β the change
aux/hindley-milner-gently.md opens with,
since it's the one decision the rest of the checker is built on. The same
commit, not mentioned in its own title, breaks showEType β the function
that would pretty-print a type for a human β into a function that never
returns (33b009fa:src/MicroHs/Parse.hs, quoted whole, the whole diff to
this function):
showEType :: EType -> String
-showEType at =
- case at of
- TVar i -> i
- TConstr c ts ->
- if null ts then
- c
- else
- "(" ++ unwords (c : map showEType ts) ++ ")"
- TUVar n -> "a" ++ showInt n
+showEType = showEType
There's no longer a TVar/TConstr/TUVar to pattern-match on β EType
is just Expr now β and rather than rewrite showEType to walk Expr
directly, 33b009fa leaves a stub that would loop forever if it were ever
called. Checked directly: nothing in TypeCheck.hs or Main.hs calls
showEType or showETypeScheme at any commit between 33b009fa and
617475b6 (below) β the stub sits dead in the source for nearly sixteen
days (15 days 21 hours), across c0b279fc's skeleton checker, both
"Transfer" commits, and f7db30f1's library-wide signature sweep, without
ever being exercised.
45b34015, sixteen seconds later, is a genuine rewrite of a different
function, showExpr β sixteen alternatives replaced to print terms
without the (EVar "x")-style constructor-tagged debug format they used
before ("(EVar " ++ v ++ ")" becomes plain v, and so on), the format
unify's own error messages read from
(aux/hindley-milner-gently.md quotes
unifyR's use of it directly). 93a2f76e ("Allow '()' and '[]' as
uppercase identifiers.") teaches pUIdent two new alternatives so that
() and [] parse as ordinary constructor names rather than only as
special-cased tuple/list punctuation (93a2f76e:src/MicroHs/Parse.hs,
two lines added to pUIdent, quoted whole):
<|> ("()" <$ (pSym '(' *> pWhite *> pSym ')')) -- Allow () as a constructor name
<|> ("[]" <$ (pSym '[' *> pWhite *> pSym ']')) -- Allow [] as a constructor name
β needed because, with types now just Expr, a type like () or [a]
has to resolve to an ordinary EVar constructor name (tCon "()", tApps "[]" [...], in the checker's own vocabulary) the same way Int or
Maybe does, and before this commit ()/[] were parseable only inside
expression syntax, never as a bare uppercase identifier a type
application could apply to. ca512ed2 ("Get rid of Data.List_Type") folds
data [] a = [] | (:) a [a] directly into lib/Data/List.hs, deleting
the separate Data.List_Type module that used to carry only the type
declaration β now that a module's own Data/Sign definitions and its
term-level bindings live in the same EDef list the checker reads
together, there's no structural reason to keep them apart. e5bc9236
("Change ELam to abstract just one variable.") narrows ELam [Ident] Expr to ELam Ident Expr, with a new eLams :: [Ident] -> Expr -> Expr
folding multi-argument lambdas down to nested single-argument ones at
parse time (e5bc9236:src/MicroHs/Parse.hs, quoted whole):
pLam :: P Expr
pLam = eLams <$> (pSymbol "\\" *> esome pLIdent_) <*> (pSymbol "->" *> pExpr)
eLams :: [Ident] -> Expr -> Expr
eLams is e = foldr ELam e is
β the checker's own ELam case, added the next day, expects exactly one
bound variable per lambda so it can unify it against exactly one tArrow
at a time; a multi-argument ELam would have needed its own multi-step
unification logic instead.
c0b279fc: a skeleton, wired into nothing
c0b279fc ("Start of type checking.", 2023-07-22 10:50:52) adds one new
file, src/MicroHs/TypeCheck.hs, 57 lines β and touches nothing else, not
the Makefile, not Compile.hs, not Main.hs. Nothing in the build
imports it yet. It isn't even Haskell that GHC would accept on its own:
tcExpr's type signature mentions a monad T (tcExpr :: Maybe EType -> Expr -> T (Typed Expr)), but the file's only import is import Control.Monad.State as T (c0b279fc:src/MicroHs/TypeCheck.hs line 3) β
qualified import syntax that makes names like T.get available, not a
bare type called T. Several cases are undefined outright (ECase _ = undefined, ELet _ = undefined, EDo _ = undefined), and the function
runs off the end mid-definition β the file's last line is tc, an
unfinished identifier with nothing after it. This is the first draft in
the most literal sense: a shape for what the checker's core recursion
over Expr will look like (tcExpr, an EVar/EApp/ELam/EInt
walk with a Maybe EType for bidirectional checking, already recognizable
in cc57346e's working version two weeks later), committed before it
compiles, let alone runs.
An unrelated fix, and two commits called "Transfer"
7ae8c443 ("Make int64_t printing portable.", 2023-07-22 15:57:15) is a
one-file runtime fix β eval.c's number formatting β with nothing to do
with type checking; included here because it lands between c0b279fc and
the next TypeCheck.hs work, a reminder that this project's git history
doesn't pause other work while one feature is in progress. Then, one
minute fifty seconds apart the same evening, 3437518f and 031d2125,
both titled "Transfer", touch only src/MicroHs/TypeCheck.hs β 114 lines changed (74
insertions, 40 deletions) and then 6 more (4/2). Neither commit message
says what's being transferred or from where; read as diffs, both are
straightforward elaborations of the same skeleton c0b279fc started (more
cases filled in, more helper functions), not a wholesale replacement β
consistent with, though not confirmed by anything in the repository
beyond the diffs themselves, work drafted somewhere else (a second
checkout, a different machine) and copied in by hand rather than merged.
f7db30f1: the chapter 03 debt, collected β in the library
f7db30f1 ("Add lots of type signatues and forall.", 2023-07-23
19:28:49) is the commit aux/hindley-milner-gently.md
quotes closely: 9 files, 134 insertions, 74 deletions, 80 added lines
containing forall, 128 added lines containing ::. Worth stating
precisely, because the shape is easy to get wrong: every file this commit
touches is under lib/ (Data/Either.hs, Data/Function.hs,
Data/Int.hs, Data/List.hs, Data/Map.hs, Data/Maybe.hs,
Data/Tuple.hs, Primitives.hs) plus the Makefile β the standard
library every MicroHs program (including the compiler's own src/
tree, which imports it) pulls in, not src/MicroHs itself. And most of
those 128 :: lines are not brand-new signatures β const :: a -> b -> a
already existed in lib/Data/Function.hs; what f7db30f1 adds to it is
forall a b . in front. Chapter 03's e96d04be:Desugar.hs comment says
"the meaning arrives months later" β f7db30f1 lands 54 days after
e96d04be, and cc57346e's working checker another 14 days after that,
68 days total. What arrives first, at f7db30f1, is not "the compiler
reads Sign" β the checker doesn't exist as a working feature yet β it's
the library gaining the quantifiers the checker's rigid-variable mechanism
will need to accept anything polymorphic at all, once it does exist,
exactly as
aux/hindley-milner-gently.md's
forall/instantiation section demonstrates by removing one.
Two more unrelated commits, one of them quietly load-bearing
677a0f0e ("Handle negative literals.", 2023-07-25 14:58:16) is another
runtime-only fix, eval.c, twelve insertions and one deletion. Eighteen seconds later,
17b450fc ("Add more lib modules.") adds two files outright:
lib/Control/Monad/State.hs (52 lines) and lib/Data/IntMap.hs (77
lines). The commit message doesn't say why a compiler needs an IntMap,
but TypeCheck.hs's own state β TCState's last field, the unification
substitution
(aux/hindley-milner-gently.md's
"Metavariables and substitution" section) β is exactly IM.IntMap EType,
and cc57346e's import list confirms it: import qualified Data.IntMap as IM. 17b450fc isn't titled as type-checking work, but it's the commit
that puts the checker's core data structure within reach.
The quiet grind: 9a392312 to 617475b6
9a392312 ("Temp commit", 2023-07-27 13:47:19) touches TypeCheck.hs
with 173 insertions and 26 deletions, and touches five other files
besides (Makefile, Compile.hs, Desugar.hs, Main.hs, Parse.hs) β
wiring the checker into the build for the first time, unlike every commit
before it. Then nothing, for 9 days 21 hours 51 minutes, until
617475b6 ("Temp", 2023-08-06 11:38:42) β a single commit that rewrites
TypeCheck.hs again, 543 insertions and 65 deletions this time, across 11
files total including a near-total restructuring of Desugar.hs (67
insertions, 205 deletions). Nothing in the repository explains the gap
the same way chapter 07's 24-day silence goes unexplained in
00-overview.md β the log simply resumes. What the two
commits' titles share β "Temp commit", "Temp" β is an honest description:
neither claims to be finished, and neither is committed as a clean,
working state so much as a savepoint. 617475b6 is also where the
showEType infinite loop from 33b009fa, nearly sixteen days earlier,
finally gets fixed β one line, alongside everything else this commit touches
(617475b6:src/MicroHs/Parse.hs, quoted whole): showEType = showExpr.
Not a redesign, just routing the type-printer through the (already
rewritten, in 45b34015) term-printer, since EType has been Expr all
along.
cc57346e: "First version with type checking."
Seven hours thirty-eight minutes after 617475b6's rewrite lands, the
same day, cc57346e (2023-08-06 19:16:48) is the commit both this chapter and
aux/hindley-milner-gently.md treat as the
checker's first real snapshot: 16 files, 187 insertions, 78 deletions.
src/MicroHs/TCMonad.hs is new β 13 lines, a thin re-export of
Control.Monad.State.Strict under the compiler's own qualified name T,
finally giving c0b279fc's two-week-old, never-compiling type signature
somewhere real to point. TypeCheck.hs itself, at this commit, is the
file aux/hindley-milner-gently.md quotes
from throughout β unify/unifyR, EUVar-as-metavariable, tInst,
tcDefValue, all present and in the shape that document describes. What
it can do by this commit: check EVar/EApp/ELam/literals, ECase,
ELet, ETuple, EList, EDo, EIf, and list comprehensions β every
case c0b279fc had left as undefined two weeks earlier is filled in.
What it demands in return shows up immediately in the same commit's own
test suite: all six files under tests/ gain a main :: IO () signature
they didn't have before (tests/Arith.hs, ListTest.hs, StringTest.hs
each need only that one line; Fac.hs also needs res :: (Int, Int);
IOTest.hs needs f :: Int -> Int and foo :: IO (); Misc.hs's
existing first :: (a, b) -> a needs forall a b . added in front) β
the same "every top-level binding needs an explicit signature" rule
aux/hindley-milner-gently.md demonstrates
by removing one from lib/Data/List.hs's null, now visible landing on
the project's own test files at the exact commit that turns checking on.
The fix streak
Six commits, da5350d2 through 64345fe2, spread across three calendar
days with two full silent calendar days in the middle β 2023-08-06 (da5350d2
19:31, 3bf3924a 23:56), 2023-08-07 (a90d1175 12:21, bb8aec3b 12:22),
nothing on the 8th or 9th, then 2023-08-10 (64345fe2 11:06, the same
day as the chapter's final commit). Read together, most of their
TypeCheck.hs diffs are not new type-checking logic at all β they're the
same "diet" aux/bootstrapping-a-compiler.md
describes for the rest of the compiler, applied to the newest module so
that it, too, can eventually compile under mhs itself, not only under
GHC. da5350d2 ("Improve.") strips every HasCallStack => constraint
behind a --X comment marker and switches bare mapM/mapM_ to
qualified T.mapM/T.mapM_ β GHC.Stack and an unqualified Prelude
mapM are exactly the kind of import mhs's own restricted dialect
doesn't have. 3bf3924a ("Various type fixes.") continues the same
pattern: EApp <$> expandType f <*> expandType a-style Applicative
code, which needs <$>/<*> instances mhs has no type classes to
provide, rewritten as explicit T.do binds; bare return/pure
qualified to T.return. a90d1175 ("Better import") is three insertions
and two deletions in TCMonad.hs, an import tweak. bb8aec3b ("More
type fixes") is six insertions and one deletion in Parse.hs, mostly
commented-out (--X-marked) pretty-printer
alternatives for showExpr, not live code. 64345fe2 ("More type
checking fixes.") is the largest of the six β 163 lines in TypeCheck.hs
alone β and, checked directly rather than assumed from its title, contains
at least one genuine correctness fix, not just portability churn:
expandSyn's handling of a multi-argument type synonym. Before this
commit, type Pair a b = (a, b) used with two arguments fails to
type-check at all β confirmed by building cc57346e and compiling a
one-line program that uses one: mhs: Cannot unify SynTest.Pair and ,.
64345fe2 rewrites the EApp case of expandSyn to walk the application
spine correctly, expanding each argument before pushing it onto the
accumulator instead of recursing into the wrong sub-term
(64345fe2:src/MicroHs/TypeCheck.hs, quoted whole, the changed case):
EApp f a -> T.do
aa <- expandSyn a
syn (aa:ts) f
β confirmed fixed by building a38c64ce and compiling the identical
SynTest.hs: it compiles and runs, printing 1. One more honest detail,
found by trying to build 64345fe2 on its own rather than only reading
its diff: this commit does not compile standalone under GHC, failing with
Not in scope: 'T.fail' / Not in scope: 'T.>>=' wherever TypeCheck.hs
pattern-matches inside a T.do block. The fix, a38c64ce's own
TCMonad.hs change, is the --X/--Y marker trick
aux/bootstrapping-a-compiler.md
documents, caught mid-flip: at 64345fe2,
src/MicroHs/TCMonad.hs line 16 reads --import Control.Monad, a plain
comment with no --X marker, so convertX.sh never touches it and
Control.Monad β the module that would bring fail/(>>=) into scope
for MicroHs.TCMonad's own module MicroHs.TCMonad export item to
re-export β is never actually imported for the GHC build. a38c64ce
changes that one line to --Ximport Control.Monad hiding(ap)
(a38c64ce:src/MicroHs/TCMonad.hs line 17) β now marked, so
convertX.sh's sed -e 's/--X//' strips the marker and the bin/mhs
build actually sees import Control.Monad hiding(ap), and T.fail/
T.>>= resolve. Consistent with 00-overview.md's framing
of this project's commit history β broken states get committed, not held
back until clean.
a38c64ce: the fixpoint
a38c64ce ("Now bootstraps with type checking.", 2023-08-10 20:37:30) is
seven files, 29 insertions, 24 deletions β small, by this chapter's
standards, for a commit that closes the book's whole arc. The Makefile
change is one number: bootcombtest's eval invocation grows its heap
from -H10000000 to -H50000000, and the same commit deletes the
$(MHS)test target (originally uhstest, chapter 07's 1d6d1210,
2023-07-05, renamed by 298c9824 in chapter 08) β an interpreted-mode
smoke test, $(BIN)/$(MHS) -ilib -isrc Main; $(BIN)/eval with no
arguments, superseded once bootcombtest itself is the thing that has to
work. In TypeCheck.hs, the load-bearing change is four lines,
a new function called between every top-level definition's own check
(a38c64ce:src/MicroHs/TypeCheck.hs lines 360β364, quoted whole):
-- Reset type variable and unification map
tcReset :: T ()
tcReset = T.do
TC mn _ tenv venv _ <- get
put (TC mn 0 tenv venv IM.empty)
β wired in at both call sites that walk a module's definition list,
tcDefsType and tcDefsValue, each changed from T.mapM tcDefType ds /
T.mapM tcDefValue ds to T.mapM (\ d -> T.do {tcReset; tcDefType d}) ds
and the value-checking equivalent. Before this commit, the fresh-
metavariable counter and the unification substitution map accumulated
across every top-level definition in a module without ever being cleared;
after it, each definition starts from EUVar 0 and an empty substitution.
The commit message doesn't explain what checking the compiler's own
~30-file source β 571 distinct top-level combinator labels in the
resulting mhs.comb, counted directly β without that reset actually
did β whether it was simply slow, or something worse β only that adding
tcReset is what the title reports finally working. Alongside it, two
correctness fixes narrower in scope: ESectR's case used to pattern-match
its checked operator as EVar ii directly, which crashes for any section
built from a data constructor rather than a plain function (the checker's
own EVar case can return ECon cti qi from tLookupInst, not only
EVar) β fixed to match a generic var and extract the name through
getIdent (a38c64ce:src/MicroHs/TypeCheck.hs line 694: T.return (ESectR (getIdent var) ee, t)). And primTypes gains one new entry,
"Any", the same commit that adds forall a b . to
lib/Unsafe/Coerce.hs's unsafeCoerce :: a -> b β the "Any" primitive
type and unsafeCoerce's forced universal quantification arrive together.
The rest of the diff β a qualified MicroHs.StateIO.return in
StateIO.hs where a bare return used to be ambiguous between two
returns now both in scope, an unqualified Handle import fix in
Compile.hs, a fallback-arm tweak in Desugar.hs's single-arm case
handling β is the same self-hosting-compatibility housekeeping the fix
streak above already established as this range's dominant texture. Built
and run, at this commit, exactly as bootcombtest runs it (see "Try it"
below): bin/mhs compiles MicroHs.Main from source; bin/eval,
interpreting mhs's own combinator translation, compiles the identical
source a second time; the two outputs, 140,782 bytes each, are
byte-identical. The typed compiler type-checks itself, compiling itself.
Undisclosed extras
Three found by reading diffs rather than titles, gathered here: the
showEType = showEType infinite loop 33b009fa introduces silently
(under a title about EType's representation, not its printing) and
617475b6 fixes silently nearly sixteen days later, never once exercised
in between; 64345fe2 ("More type checking fixes.") not compiling standalone
under GHC, discovered only by building it rather than trusting the next
commit's message; and the two "Transfer" commits, 3437518f/031d2125,
whose titles say nothing about what's being transferred but whose diffs β
114 and 6 lines, both confined to TypeCheck.hs β are at least
consistent with what they claim to be, unlike chapter 08's 8e2f7109
(which dropped a safety flag its title never mentioned). Nothing found
this chapter rises to that level β everything here is scope not stated
plainly, not effects contradicting a title.
Why it matters
Types came last. Not first, not alongside the parser, not even alongside
do-notation or modules β after the language, the runtime, IO, and
self-hosting all worked, on a compiler that had already compiled itself
for three weeks with every signature in its own source doing nothing at
all. That ordering is the whole shape of this book's method, not an
accident of one project: chapter 03's dsDef (Sign _ _) = [] bought two
months and change of writing signatures as documentation, free of any
obligation to make them true; chapter 05's habits β commit often, note
what's broken, keep a lab notebook β are exactly what let 9a392312 and
617475b6 sit nine days apart under titles that admit they're
unfinished, without the project stalling; chapter 08's self-hosting push
is what gives this chapter a workload β the compiler's own ~30-file
source β big enough to need tcReset at all, a fix nothing smaller would
have surfaced. The untyped compiler wasn't a detour from building a typed
one; it was the scaffolding that made building a typed one tractable,
one small commit at a time, on a language that already worked before
anyone had to prove it worked. Two files, Lam.hs and eval.c, on
2023-05-25. A self-hosting typed compiler that checks itself compiling
itself, 77 days later, on a38c64ce, 2023-08-10. This account stops here
because that's where git log says the story
00-overview.md promised is told in full: "The last
stretch, through early August, adds a Hindley-Milner type checker and
gets the self-hosted compiler bootstrapping with type checking ...".
Notebook margin
- "Temp commit" and "Temp" are not filler titles β they're savepoints,
committed mid-thought on purpose, 9 days 21 hours 51 minutes apart, with
no explanation in between for the gap. The habit this book has tracked
since chapter 05 β commit even when it doesn't work β is what makes a
multi-week rewrite like
617475b6's 608-lineTypeCheck.hsdiff possible without losing9a392312's 199-line first attempt. - Refactor before feature, again:
33b009fa/e5bc9236's prep (types asExpr, one-variable lambdas) lands a full day beforec0b279fc's first line of actual checker code β the same rhythm chapter 03's "Notebook margin" named for4e4c5252andff4622e8, now operating one level up, on the checker's own prerequisites rather than the parser's. - The fix-streak rhythm: six commits, three calendar days, two full silent
calendar days in the middle (08-08, 08-09) β and the very last commit
in that streak,
64345fe2, doesn't even build standalone. Fixing real bugs (a multi-argument type synonym) and fixing self-hosting portability (qualifiedT.dobinds, strippedHasCallStack) happen in the same commits, not separated into "correctness" and "cleanup" passes. 17b450fc("Add more lib modules.") never says why the compiler needs anIntMapβ the connection toTypeCheck.hs's own substitution map is legible only from reading both files, not from either commit message.
Try it
Anchor: a38c64ce ("Now bootstraps with type checking."), 2023-08-10
20:37:30 β the commit that makes the typed self-compile agree with
itself. Convention (see
00-overview.md):
git worktree add /tmp/mhs-a38c64ce a38c64ce
cd /tmp/mhs-a38c64ce
No Build-note patch needed at this commit β the GHC-9.8/mtl issues
chapters 02, 03, and others in this book had to route around are gone
from Main.hs's own import list by this point in the project. Build both
binaries as written:
mkdir -p bin
nix develop $MICROHS -c make bin/mhs
nix develop $MICROHS -c make bin/eval
[...]
[16 of 16] Linking bin/mhs
gcc -Wall -O3 src/runtime/eval.c -o bin/eval
(The same -Wx-partial head/tail warnings every earlier chapter's
Build notes already flag appear during the bin/mhs build; omitted.)
Run the ordinary test suite first β seven tests/*.hs files (the six
cc57346e had to add main :: IO () to, per "What happened" above, plus
Case.hs, which already had one), each compiled and its output diffed
against a checked-in .ref file:
nix develop $MICROHS -c make test
cd tests; make test
../bin/mhs -i../lib IOTest && (echo q | ../bin/eval -- a bb ccc > IOTest.out) && diff IOTest.ref IOTest.out
../bin/mhs -i../lib StringTest && ../bin/eval > StringTest.out && diff StringTest.ref StringTest.out
../bin/mhs -i../lib ListTest && ../bin/eval > ListTest.out && diff ListTest.ref ListTest.out
../bin/mhs -i../lib Fac && ../bin/eval > Fac.out && diff Fac.ref Fac.out
../bin/mhs -i../lib Misc && ../bin/eval > Misc.out && diff Misc.ref Misc.out
../bin/mhs -i../lib Case && ../bin/eval > Case.out && diff Case.ref Case.out
../bin/mhs -i../lib Arith && ../bin/eval > Arith.out && diff Arith.ref Arith.out
Every diff above is silent β all seven pass. Now the money shot, the same
bootcombtest target chapter 08's Try-it ran without type checking β
first make bootcombtest whole, then each of its three lines timed
separately the way chapter 08's own Try-it does:
nix develop $MICROHS -c bash -c 'time make bootcombtest'
bin/mhs -ilib -isrc -omhs.comb MicroHs.Main
bin/mhs -ilib -isrc -omain-mhs.comb MicroHs.Main
bin/eval -H50000000 -rmhs.comb -- -ilib -isrc -omain-comb.comb MicroHs.Main
cmp main-mhs.comb main-comb.comb
real 0m37.674s
nix develop $MICROHS -c bash -c 'time bin/mhs -ilib -isrc -omain-mhs.comb MicroHs.Main'
real 0m1.794s
nix develop $MICROHS -c bash -c 'time bin/eval -H50000000 -rmhs.comb -- -ilib -isrc -omain-comb.comb MicroHs.Main'
real 0m32.254s
cmp prints nothing and exits 0: main-mhs.comb and main-comb.comb β
140,782 bytes each, confirmed with ls -la and a second explicit cmp
run β are byte-identical. Compare against chapter 08's own numbers at
this chapter's own starting point, 298c9824 (75,052-byte outputs,
0.890s native / 20.715s interpreted, no type checking at all): the typed
compiler's self-compiled output is nearly twice the size, the native
compile is about twice as slow (1.794s), and the interpreted step is
about 55% slower (32.254s) β and every byte of that output now passed
through unify on the way, the same combinator-level oracle chapter 08
established, now proving something chapter 08 couldn't: that
the type checker agrees with itself about its own source, not just that
the untyped compiler does.
Exercise 1: a deliberate type error, traced to its source
cat > TypeErr.hs <<'EOF'
module TypeErr(module TypeErr) where
import Prelude
bad :: Int
bad = 1 + "hello"
main :: IO ()
main = putStrLn (showInt bad)
EOF
nix develop $MICROHS -c ./bin/mhs -ilib TypeErr
mhs: Cannot unify [Char] and Primitives.Int
EApp (EVar "Data.List.[]") (EVar "Primitives.Char") - EVar "Primitives.Int"
Map [("Data.Char.String",ETypeScheme [] (EApp (EVar "Data.List.[]") (EVar "Primitives.Char")))]
CallStack (from HasCallStack):
error, called at src/MicroHs/TypeCheck.hs:334:11 in main:MicroHs.TypeCheck
unifyR, called at src/MicroHs/TypeCheck.hs:326:3 in main:MicroHs.TypeCheck
...
(exit code 1; the rest of the CallStack β more unify/unifyR frames
from +'s own two-argument check β omitted above.) The message comes
from unifyR's bad binding (a38c64ce:src/MicroHs/TypeCheck.hs line
334, quoted): bad = error $ "Cannot unify " ++ showExpr a ++ " and " ++ showExpr b ++ "\n". 1 + "hello" needs "hello"'s type, [Char]
(String is a type synonym for it, hence the Map line naming
Data.Char.String), to unify with +'s second-argument type, Int β an
EApp/EVar mismatch that unifyR's structural case rejects
immediately, exactly as
aux/hindley-milner-gently.md's unification
section traces by hand.
Exercise 2: remove a signature the checker needs
(python3 here comes from the host system, not the devShell β the same
caveat chapter 01 discloses for diff.)
python3 -c "
with open('lib/Data/List.hs') as f:
lines = f.readlines()
out = [l for l in lines if l.strip() != 'null :: forall a . [a] -> Bool']
with open('lib/Data/List.hs', 'w') as f:
f.writelines(out)
"
nix develop $MICROHS -c ./bin/mhs -ilib Example
mhs: undefined variable null
CallStack (from HasCallStack):
error, called at src/MicroHs/TypeCheck.hs:382:16 in main:MicroHs.TypeCheck
Inference doesn't cope β there is no inference step for a missing
top-level signature to fall back on. addValueType
(a38c64ce:src/MicroHs/TypeCheck.hs lines 553β559) only adds a name to
the value table when it sees a Sign; a Fcn with no matching Sign in
the same module never gets one, and tcDefValue's tLookup i on that
name hits the "not found" branch directly β even though null is never
called anywhere in Example.hs, lib/Data/List.hs itself still has to
type-check as a whole module once anything imports it, and every one of
its top-level bindings, used or not, needs its own signature to be
checked at all. Restore the file before continuing:
git checkout -- lib/Data/List.hs
Exercise 3: hand-infer a Prelude function, then check the source
lib/Data/Function.hs, flip:
flip :: forall a b c . (b -> a -> c) -> a -> b -> c
flip f a b = f b a
Hand-inferring flip f a b = f b a's body against the checker's own
algorithm (tcExpr''s EApp case: to check g x, check x first with
no expected type, then check g against (type of x) -> (fresh result)):
f b aisEApp (EApp f b) a. Checking the outer application:a(the argument) gets a type first β it's a lambda-bound variable, so its type is whatever fresh metavariableflip's ownELamcase gave it when it bounda; call thatΟ_a. The result of the whole expression gets a fresh metavariable too,Ο_r. Thenf bis checked against the expected typeΟ_a -> Ο_r.f bis itselfEApp f b.b's type is its own binding's metavariable,Ο_b.fis checked againstΟ_b -> (Ο_a -> Ο_r).fis a bound variable too, with its own metavariableΟ_ffromflip's outermostELam; unifyingΟ_fagainstΟ_b -> (Ο_a -> Ο_r)binds it directly (f's type isΟ_b -> Ο_a -> Ο_r, nothing left to check).- Unwinding:
flip's constructed type isΟ_f -> Ο_a -> Ο_b -> Ο_r, substituting step 2's result forΟ_f:(Ο_b -> Ο_a -> Ο_r) -> Ο_a -> Ο_b -> Ο_r. Unified against the declared(b -> a -> c) -> a -> b -> c:Ο_b β¦ b,Ο_a β¦ a,Ο_r β¦ c, every metavariable resolving to exactly the signature's own names, no contradiction β the same shape aux/hindley-milner-gently.md'sconstwalk-through ends on.
Checked against the source: a38c64ce:lib/Data/Function.hs's own
signature for flip is exactly forall a b c . (b -> a -> c) -> a -> b -> c β matching the hand-inferred type above term for term. Confirmed live,
not just by inspection, with a program that uses it at a concrete type:
cat > FlipDemo.hs <<'EOF'
module FlipDemo(module FlipDemo) where
import Prelude
main :: IO ()
main = putStrLn (showInt (flip (-) 3 10))
EOF
nix develop $MICROHS -c ./bin/mhs -ilib FlipDemo
nix develop $MICROHS -c ./bin/eval
7
(flip (-) 3 10 = (-) 10 3 = 7, b β¦ Int, a β¦ Int, c β¦ Int all
instantiated to the same concrete type here, which is why this one call
doesn't distinguish flip's type from a monomorphic one β the point of
the hand-trace above is that the checker never needed to know that in
advance.)
Clean up:
git worktree remove --force /tmp/mhs-a38c64ce
Background for chapter 01.
Lambda calculus and SKI combinators
This is the theory Lam.hs and eval.c are built on, told through the
actual first-commit code rather than abstractly. If you've never seen
lambda calculus or combinators before, this is the whole prerequisite.
1. Exp: an AST for lambda calculus, plus three pragmatic extras
ccb31388:Lam.hs (lines 44β51):
data Exp
= Var Ident
| App Exp Exp
| Lam Ident Exp
| Int Integer
| Comb String
| Prim String
deriving (Show, Eq)
Var, App, Lam are the three term formers of the lambda calculus,
verbatim: a variable reference, a function application, and a
variable-binding abstraction (Lam "x" e is \x. e). Everything you can
build in the pure lambda calculus is one of these three.
The other three constructors are not lambda calculus β they're what a
compiler needs to be useful. Int is a machine integer literal, so
arithmetic doesn't have to be Church-encoded. Prim names a built-in
operation like "+" or "-" that eval.c implements natively. Comb
names a combinator constant (S, K, I, ...) β these are what
compile introduces; see bracket-abstraction.md.
(The file also defines pattern synonyms like App2, CI, CK β those are
just readability sugar over these six constructors, not new syntax.)
2. Beta reduction, on paper
ccb31388:Lam.hs (lines 36β39):
e1 :: Exp
e1 = Lam "x" $ App2 (Prim "+") (Var "x") (Var "x")
e2 :: Exp
e2 = Lam "x" $ Lam "y" $ App2 (Prim "-") (Var "y") (Var "x")
putStrLn (toString e1) prints (\x. + x x) and putStrLn (toString e2)
prints (\x. (\y. - y x)) (checked in ghci).
The one computation rule of lambda calculus is beta reduction: applying a
lambda to an argument substitutes the argument for the bound variable
throughout the body.
(\x. + x x) 5 --> [x := 5](+ x x) = + 5 5 --> 10
That's ordinary math, not a code claim. Here's the code claim, machine
verified: does Lam.hs's own reduce function actually perform this step?
ghci> pp (reduce (App e1 (Int 5)))
(\x. + x x) 5
It does not reduce β the term comes back unchanged. reduce/redOne
(lines 137β161) pattern-match on combinators (CI, CK, CS, ...) and on
Prim applications; there is no case for Lam or for "apply a Lam to an
argument." Beta reduction on raw Var/Lam terms is simply not
implemented anywhere in this file. That's the subject of the next section.
3. Why variables are a problem for a machine
To implement beta reduction directly, an interpreter needs to substitute
into a term while avoiding variable capture β if you naively
substitute y for x in \y. x, the x you're replacing must not be
captured by the enclosing \y, so a correct substitution has to rename
bound variables on the fly. The usual production fix is to not substitute
at all: keep the term as-is and carry an environment (a map from names
to values/closures) that grows every time you enter a Lam.
eval.c's node representation has no such thing:
typedef struct node {
enum node_mark mark;
enum node_tag tag;
union {
value_t value;
struct {
struct node *fun;
struct node *arg;
} s;
} u;
} node;
A node is a tag plus either a value or a (fun, arg) pair β nothing that
looks like an environment or a closure (there is no env/closure field
anywhere in the 539-line file). eval.c's reduction loop (the eval
function, see section 5) never needs to look up a variable by name,
because by the time a term reaches eval.c it doesn't have any variables
left. That transformation β eliminating Var/Lam entirely before
runtime β is exactly what compile in Lam.hs does, and it's called
bracket abstraction (see the companion doc).
4. S, K, I
Three combinators are enough to express any closed lambda term:
| combinator | definition | reduction rule |
|---|---|---|
I | \x. x | I x = x |
K | \x y. x | K x y = x |
S | \f g x. f x (g x) | S f g x = f x (g x) |
Each rule is a pure graph rewrite: match the shape on the left, produce
the shape on the right. No name lookup, no substitution, no capture to
worry about β because there are no names. That's the punchline: a term
built only from S, K, I, primitives, and applications can be
executed by a machine that just pattern-matches on node shape, which is
exactly what eval.c's eval loop does.
Concretely, compare section 2's failure to the compiled version:
ghci> pp (reduce (App (compile e1) (Int 5)))
10
compile e1 turns \x. + x x into S (S (K +) I) I (see
bracket-abstraction.md for the derivation) β a
term with no Var/Lam in it β and that term, reduce handles fine.
5. The extended set: B, C, S', C', B', T, Y
S/K/I are enough in principle, but S-only translations blow up in
size (see the companion doc). Lam.hs and eval.c share a larger
combinator set. Here is the reduction switch, trimmed to the combinator
cases (ccb31388:eval.c, from the eval function, lines 380β474; each
... marks elided stack-bookkeeping lines β CHECK/GCCHECK guards,
f/g/x/k argument fetches, and POP/TOP adjustments β and the
unreachable break; after each GOTO is also omitted):
case S:
...
FUN(n) = new_ap(f, x);
ARG(n) = new_ap(g, x);
GOTO ap;
case SS:
...
FUN(n) = new_ap(k, new_ap(f, x));
ARG(n) = new_ap(g, x);
GOTO ap;
case K:
...
SETIND(n, x);
GOTO ind;
case T:
...
x = ARG(TOP(2));
...
SETIND(n, x);
GOTO ind;
case I:
...
SETIND(n, x);
GOTO ind;
case Y:
...
FUN(n) = f;
ARG(n) = n;
GOTO ap;
case B:
...
FUN(n) = f;
ARG(n) = new_ap(g, x);
GOTO ap;
case C:
...
FUN(n) = new_ap(f, x);
ARG(n) = g;
GOTO ap;
case CC:
...
FUN(n) = new_ap(k, new_ap(f, x));
ARG(n) = g;
GOTO ap;
(pretty(), lines 288β316, prints tag SS as S', BB as B', and CC
as C' β the enum names and the source-language names don't match, which
is worth knowing before you go looking for a case S' that doesn't
exist.) Lam.hs's own reference reducer, redOne (lines 147β161; the
trailing primitive-arithmetic case and the Nothing catch-all are elided,
marked ...), states the same rules purely in terms of Exp:
redOne :: Exp -> Maybe Exp
redOne (App CI x) = Just x
redOne (App (App CK x) _) = Just x
redOne (App (App CT _) x) = Just x
redOne (App3 CS f g x) = Just $ App (App f x) (App g x)
redOne (App3 CB f g x) = Just $ App f (App g x)
redOne (App3 CC f g x) = Just $ App (App f x) g
redOne (App4 CS' k f g x) = Just $ App2 k (App f x) (App g x)
redOne (App4 CB' k f g x) = Just $ App2 k f (App g x)
redOne (App4 CC' k f g x) = Just $ App2 k (App f x) g
...
One rule per combinator, all machine-checked against the running code
(reduce in Lam.hs, and the real eval binary compiled from eval.c):
| combinator | rule | checked |
|---|---|---|
I x | = x | reduce (App CI (Int 9)) β 9; eval on (I 9) β 9 |
K x y | = x | reduce (App2 CK (Int 1) (Int 2)) β 1; eval on ((K 1) 2) β 1 |
T x y | = y | reduce (App2 CT (Int 1) (Int 2)) β 2; eval on ((T 1) 2) β 2 |
S f g x | = f x (g x) | reduce (App3 CS (Prim "+") CI (Int 5)) β 10; eval on (((S +) I) 5) β 10 |
B f g x | = f (g x) | reduce (App3 CB (App CK (Int 100)) CI (Int 7)) β 100; eval on (((B (K 100)) I) 7) β 100 |
C f g x | = f x g | reduce (App3 CC (Prim "-") (Int 3) (Int 5)) β 2 (i.e. 5 - 3); eval on (((C -) 3) 5) β 2 |
S' k f g x | = k (f x) (g x) | reduce (App4 CS' (Prim "+") CI CI (Int 5)) β 10; eval on ((((S' +) I) I) 5) β 10 |
C' k f g x | = k (f x) g | reduce (App4 CC' (Prim "-") CI (Int 3) (Int 10)) β 7; eval on ((((C' -) I) 3) 10) β 7 |
B' k f g x | = k f (g x) | reduce (App4 CB' (Prim "-") (Int 10) CI (Int 3)) β 7 β but see below |
Y f | = f (Y f) (self-application node, line 434: ARG(n) = n) | exercised by eNfib; ./eval nfib.comb prints 29860703 (nfib 35) |
One real discrepancy worth flagging, since this doc's rule is "verify,
don't assume": B' (BB in the enum) is declared in eval.c's
node_tag enum, has parser support ('B' followed by '), and has a
pretty() case β but the eval switch has no case BB:. Feeding
eval.c a term that needs to reduce a B' node crashes it:
$ ./eval test_bprime.comb # ((((B' -) 10) I) 3)
((((B' -) 10) I) 3)
bad tag 12
ERR: eval tag
(Tag 12 is BB β count the enum node_tag list: FREE=0, IND=1, AP=2, INT=3, S=4, K=5, I=6, B=7, C=8, T=9, Y=10, SS=11, BB=12, ....) Lam.hs's
own redOne does implement B', so the two files disagree here β a
small, genuine gap in the very first commit, not a mistake in this doc.
As the companion doc shows, it happens not to matter in practice: B' is
never actually produced by compile/improve in this commit.
Next: bracket-abstraction.md β how compile
turns Var/Lam terms into exactly these combinators.
Background for chapter 01.
Bracket abstraction
lambda-calculus-and-ski.md established that
eval.c can only run combinator terms β no Var, no Lam. Bracket
abstraction is the algorithm that gets you from one to the other: given
Lam x body, it produces a combinator expression E with no free x
such that E applied to any argument behaves like body did with x
bound to that argument. Do this recursively from the inside out and every
Lam in a term disappears, variable by variable.
1. compile and abstract
ccb31388:Lam.hs, lines 92β101, quoted whole (both functions together are
ten lines):
compile :: Exp -> Exp
compile (App f a) = App (compile f) (compile a)
compile (Lam x a) = abstract x a
compile e = e
abstract :: Ident -> Exp -> Exp
abstract x e@(Var y) | x == y = CI
abstract x (App f a) = App2 CS (abstract x f) (abstract x a)
abstract x (Lam y e) = abstract x $ improveC $ abstract y e
abstract x e = App CK e
compile walks the term recursing into App and, on hitting a Lam x a,
hands off to abstract x a β "eliminate x from a." compile e = e
covers Var/Int/Comb/Prim: nothing left to do to a term with no
App/Lam at its root.
abstract x e has exactly four equations, and each one is a standard
bracket-abstraction rule:
abstract x (Var y) | x == y = CIβ abstractingxout ofxitself gives the identity:(\x. x) = I.abstract x (App f a) = App2 CS (abstract x f) (abstract x a)β to abstractxout of an application, abstract it out of both sides and glue them withS, sinceSis exactly "apply both branches to the same argument":S (abstract x f) (abstract x a) y = (abstract x f) y ((abstract x a) y).abstract x (Lam y e) = abstract x $ improveC $ abstract y eβ a nestedLamis abstracted from the inside out: first eliminate the inner bound variabley(recursively), simplify what comes out (improveC, section 3), then eliminatexfrom that.abstract x e = App CK e(the catch-all:eisVar ywithy /= x, orInt/Comb/Prim) βxdoesn't occur free ineat all, so the result must ignore whatever it's applied to and always yielde: that'sK e, sinceK e y = e.
Together these four equations are a complete, mechanical proof that
abstract never leaves a Var x behind: every case either replaces x
directly (CI), or recurses into strictly smaller subterms (App,
Lam), or removes x because it isn't there (App CK e).
2. Worked trace: compile e1
e1 = Lam "x" $ App2 (Prim "+") (Var "x") (Var "x"), i.e. \x. + x x.
compile e1 = abstract "x" (App (App (Prim "+") (Var "x")) (Var "x")).
Applying the App equation, then recursing into both sides:
abstract x (App (Prim "+") (Var "x"))
= App2 CS (abstract x (Prim "+")) (abstract x (Var "x"))
= App2 CS (App CK (Prim "+")) CI
= S (K +) I
abstract x (Var "x") = CI
compile e1 = App2 CS (S (K +) I) CI = S (S (K +) I) I
Machine-verified (nix develop $MICROHS -c ghci Lam.hs, then pp (compile e1)):
ghci> pp (compile e1)
S (S (K +) I) I
matches the hand trace exactly. And it runs: reduce (App (compile e1) (Int 5)) gives 10, confirmed in ghci β the combinator form does what
\x. + x x applied to 5 should.
3. improve
S (S (K +) I) I is already redundant: S (K f) I is the classic
identity S (K f) I x = (K f) x (I x) = f (I x) = f x, i.e. S (K f) I
and f behave identically β the S (K f) I wrapper buys nothing.
improveC (ccb31388:Lam.hs, lines 106β117) spots exactly this and
similar patterns and rewrites them away:
improveC :: Exp -> Exp
improveC (App f a) =
case (improveC f, improveC a) of
(App CS (App CK e1), App CK e2) -> improve (App CK (App e1 e2))
(App CS (App CK e1), CI) -> e1
(App CS (App CK e1), e2) -> improve (App2 CB e1 e2)
(App CS e1, App CK e2) -> improve (App2 CC e1 e2)
(App CS (App2 CB e1 e2), e3) -> improve (App3 CS' e1 e2 e3)
(App CC (App2 CB e1 e2), e3) -> improve (App3 CC' e1 e2 e3)
-- (CK, CI) -> CT
(e1, e2) -> App e1 e2
improveC e = e
improve = improveT . improveC (line 104); improveT (lines 119β135)
does one more pass in the same style. Its first equation is a second,
smaller wrapper-removal rule (improveC's own copy of it, at line 115
above, is commented out β this is the live version):
(CK, CI) -> CT
K and I glued together by an enclosing S/C/B collapse to the
single combinator T (T x y = y, from
lambda-calculus-and-ski.md). The rest of
improveT canonicalizes commutative/flippable primitives so improveC's
CC-rules apply to them too β not shown here in full, but the same
"spot a pattern, rewrite it" style.
Applied to S (S (K +) I) I: the inner S (K +) I matches
(App CS (App CK e1), CI) -> e1 and collapses straight to +. The outer
application is now App2 CS (Prim "+") CI, whose pieces (App CS (Prim "+"), CI) don't match any of the five special patterns above (the left
side isn't App CS (App CK e1) or App CS (App2 CB e1 e2), and the right
side isn't App CK e2), so it falls through to the default case
unchanged apart from the inner collapse, giving S + I. Machine-verified:
ghci> pp (improve (compile e1))
S + I
Three combinators instead of five, and reduce (App (improve (compile e1)) (Int 5)) still gives 10.
4. Why this matters: naive S-only translation explodes
The plain S/K/I bracket-abstraction rules (abstract's four
equations, using only CS/CK/CI) are enough on their own β the
App/Lam equations never need improveC to terminate. But every
Lam you abstract wraps its whole body in another layer of Ss, and
nested Lams multiply that: abstract x (Lam y e) first fully abstracts
y out of e (an S/K/I term the size of e), and then abstracts
x out of that whole term β so k nested lambdas can blow the
combinator count up exponentially in k. Concretely, in abstract's own
App case, App2 CS (abstract x f) (abstract x a) duplicates every
occurrence of x's eventual argument once per S, even when x doesn't
occur in f or a at all (that's exactly the S (K _) I/S (K _) e
waste improveC is cleaning up above).
improveC's extra combinators are the standard Kiselyov-style fix:
B f g x = f (g x)β used whenxoccurs only in the second argument (App CK e1on the left with a non-trivial right side): no need forSto duplicatexinto both branches when only one branch uses it.C f g x = f x gβ the mirror case:xoccurs only in the first argument.S' k f g x = k (f x) (g x)andC' k f g x = k (f x) gβ these arise when the left side of anS-node is itself aB-combinator application (App CS (App2 CB e1 e2) e3/App CC (App2 CB e1 e2) e3): instead of stacking aBinside anS, fuse them into one combinator that does the composition and the branching in a single reduction step.
Each rewrite either removes a redundant S/K/I wrapper outright or
merges two combinator applications into one, so the output shrinks instead
of growing with nesting depth. One member of the extended set is notably
absent: nothing in abstract, improveC, or improveT ever constructs
B' (B' k f g x = k f (g x)) β the only mentions of CB' in the whole
file are its pattern-synonym declaration (lines 62 and 70) and its
redOne reduction case (line 155). So in this commit B' can never
appear in compiled output, which is why the missing case BB: in
eval.c's switch (see the companion doc's section 5) goes unnoticed.
A real, non-toy example: this commit's
nfib function (eNfib, line 225 of Lam.hs) compiles via
comp = improve . compile . toLam to (from ./eval nfib.comb):
((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)
β dense with B, C, S', C', and only two bare S/K/I-free
uses of S, for a function with three variables (nfib, n, and the
implicit recursion argument) referenced many times. Without improve,
every one of those references would cost a separate S (K _) _ wrapper.
5. Check your understanding
e3 = Lam "x" $ Lam "y" $ Var "y", i.e. \x. \y. y β a function that
ignores its first argument. Before running anything, use abstract's
four equations from section 1 to predict compile e3, then predict what
improve does to your answer (hint: section 3's (CK, CI) -> CT rule).
Then check yourself:
nix develop $MICROHS -c ghci Lam.hs
ghci> pp (compile e3)
ghci> pp (improve (compile e3))
Machine-verified expected answers (do not peek until you've predicted):
compile e3 is K I, and improve (compile e3) is T β the two-argument
"return the second argument" combinator, T x y = y, which
lambda-calculus-and-ski.md already covered.
That match (improveC's commented-out (CK, CI) -> CT rule at line 115
is dead in improveC, but the live (CK, CI) -> CT rule in improveT,
line 122, catches it) is itself a small confirmation that abstract +
improve really do collapse \x. \y. y down to the simplest combinator
that has that behavior.
Background for chapter 01.
Graph reduction and laziness
lambda-calculus-and-ski.md and
bracket-abstraction.md get you to a combinator
term with no Var/Lam left. This doc is about the other half:
ccb31388:eval.c, the ~540-line C program that actually runs that term.
The central idea is that a term isn't a tree that gets copied around β it's
a graph of mutable cells, and reduction rewrites cells in place. That one
design choice is where sharing and laziness both come from.
1. A cell is a tag plus two pointers (or a value)
ccb31388:eval.c (lines 23β33):
typedef struct node {
enum node_mark mark;
enum node_tag tag;
union {
value_t value;
struct {
struct node *fun;
struct node *arg;
} s;
} u;
} node;
and the tag enum (line 17):
enum node_tag { FREE, IND, AP, INT, S, K, I, B, C, T, Y, SS, BB, CC, ADD, SUB, MUL, DIV, MOD, SUBR, EQ, NE, LT, LE, GT, GE };
Every node in the heap is one of these. An AP node is a function
application: its fun/arg union fields (accessed via the FUN/ARG
macros) point at two other cells. A combinator like S or K is a node
that carries no payload at all β the tag alone is the value. INT nodes
use the value field instead of fun/arg. The whole compiled program is
one big tree of AP cells with combinator/INT/prim leaves β but as
section 3 shows, "tree" stops being accurate the moment reduction starts,
because cells get reused and cross-linked.
2. The spine stack and the S rewrite
eval's job is to find the leftmost-outermost redex. It does this by
walking down the left spine of applications β FUN, FUN, FUN, ... β
pushing every node it passes onto an explicit stack, until it hits
something that isn't an AP. The stack macros (ccb31388:eval.c, lines
99β101):
#define PUSH(x) stack[++stack_ptr] = (x)
#define TOP(n) stack[stack_ptr - (n)]
#define POP(n) stack_ptr -= (n)
and the loop that uses them (lines 364β377, the AP unwinding step only):
PUSH(n);
for(;;) {
num_reductions++;
switch (TAG(n)) {
ind:
case IND:
n = INDIR(n);
TOP(0) = n;
break;
ap:
case AP:
n = FUN(n);
PUSH(n);
break;
TOP(0) is the innermost node just found, TOP(1) the one one level out,
and so on β after unwinding k applications, TOP(k) is the outermost
node, the root of the redex. Once the switch lands on a combinator tag with
enough arguments pushed above it, it reads the arguments off the stack via
TOP/ARG and rewrites. Here is case S in full (lines 380β391):
case S:
CHECK(3);
GCCHECK(2);
f = ARG(TOP(1));
g = ARG(TOP(2));
x = ARG(TOP(3));
POP(3);
n = TOP(0);
FUN(n) = new_ap(f, x);
ARG(n) = new_ap(g, x);
GOTO ap;
break;
CHECK(3) bails out (returns without reducing) if fewer than three
arguments are on the stack β S is only a redex when fully applied.
Otherwise f, g, x are read off the spine, the three intermediate
nodes are popped, and n = TOP(0) recovers the outermost node of the
redex β the original ((S f) g) x application cell. That cell's FUN/
ARG fields are then overwritten to new_ap(f, x) and new_ap(g, x):
the node is turned, in place, into f x (g x), exactly the S rule from
lambda-calculus-and-ski.md. GOTO ap
restarts unwinding from this now-rewritten node β section 3 traces what
happens next.
3. Sharing: x is fetched once, not copied
Look again at case S: x = ARG(TOP(3)) reads one pointer, and that
same pointer value is used in both new_ap(f, x) and new_ap(g, x). The
two x-occurrences in f x (g x) are not two copies of a subterm β they
are two AP cells whose arg field happens to point at the identical
heap cell. That is the whole mechanism of sharing in one line of C.
Take the smallest term that exercises it, S f g x itself, as
(((S +) I) ((+ 2) 3)) β i.e. + x (I x) with x = 2 + 3. Before the
S-reduction, the term is three stacked AP cells (call them n_c, the
outermost, applied last) over a leaf S, plus the separate subgraph X
for ((+ 2) 3):
n_c ββfunβββΆ n_b ββfunβββΆ n_a ββfunβββΆ [S]
β β βββargβββΆ [+]
β βββargβββΆ [I]
βββargβββΆ X where X = AP(AP(+, 2), 3)
Tracing case S against this graph exactly as in section 2: f = ARG(n_a) = +, g = ARG(n_b) = I, x = ARG(n_c) = X, then n = TOP(0) = n_c β the
outermost cell, reused as the answer. After the rewrite:
n_c ββfunβββΆ n_d = AP(+, X)
βββargβββΆ n_e = AP(I, X)
β
both n_d.arg and n_e.arg point at the SAME cell X
n_c itself was never freed and reallocated β its fun/arg fields were
just overwritten, so any other pointer into n_c anywhere in the graph
sees the rewritten form for free. And n_d/n_e both point at the one
X cell, not at two copies of ((+ 2) 3).
Now the payoff. Unwinding restarts from n_c and lands on the ADD
prim, whose ARITH2 macro (line 476) forces both operands:
r = evalint(ARG(TOP(1))) op evalint(ARG(TOP(2))); with op = + β
here ARG(TOP(1)) = X and ARG(TOP(2)) = n_e = AP(I, X). One honest
caveat: C does not specify the evaluation order of +'s operands, so
which of the two evalint calls runs first is the compiler's choice β
and the sharing conclusion doesn't depend on it. Both operands lead to
the same cell X: the first directly, the second through n_e, which
case I turns into an indirection to X (section 4). Whichever side is
forced first fully reduces X β the inner addition's SETINT(n, r)
(line 475) overwrites X's own tag/value fields to INT 5, in place.
When the other side is then forced, it arrives at that same cell β
directly, or via evali's while (TAG(n) == IND) loop (lines 335β336)
through n_e's indirection β and finds tag INT already there: it
returns immediately, no re-addition. In either order, 2 + 3 runs
exactly once; the second consumer just follows a pointer to a cell
someone else already finished computing.
Verified by running the actual term (shared2.comb, the same idea with
a 20-deep addition chain in place of 2 + 3 to make the effect large
enough to see in the reduction counter, appears below). The small case:
$ ./eval shared.comb # (((S +) I) ((+ 2) 3))
(((S +) I) ((+ 2) 3))
10
node size=24, heap size=100000
16 reductions, 0 GCs
$ ./eval duplicated.comb # ((+ ((+ 2) 3)) ((+ 2) 3)) -- two independent copies of (+ 2 3)
((+ ((+ 2) 3)) ((+ 2) 3))
10
node size=24, heap size=100000
13 reductions, 0 GCs
Both give 10, as they must. But the shared version does more
reductions here (16 vs 13) β for a two-step addition, the constant
overhead of the extra S and I rewrites outweighs the one addition it
saves. Sharing isn't free; it costs an indirection and an extra
application node. To see sharing actually win, the shared subterm has to
be expensive enough that recomputing it costs more than the S/I
overhead. Replacing 2 + 3 with a 20-deep nested addition
(((...((+ 1) 1)...) 1), still x + x via the same S + I shape) makes
the saving obvious:
$ ./eval shared2.comb
42
node size=24, heap size=100000
92 reductions, 0 GCs
$ ./eval dup2.comb # x + x with x written out twice (no sharing)
42
node size=24, heap size=100000
165 reductions, 0 GCs
Same answer, 92 vs 165 reductions β the duplicated version pays for the 20-deep chain twice; the shared version pays once. That is "evaluate once," measured, not asserted.
4. Indirection nodes: yes, they exist at commit 1
It would be easy to guess that the first commit's evaluator is too crude
for indirection nodes β it is not. IND is tag 1 in the enum quoted in
section 1, and it is created and followed in several places.
Creation, via the SETIND macro (line 361):
#define SETIND(n, x) do { TAG((n)) = IND; INDIR((n)) = (x); } while(0)
used by case K, case T, case I (the identity rewrites β each turns
its redex cell into a pointer to its result rather than copying the
result in), and by the comparison primitives, whose shared macro (line
496) sets the result cell to indirect to the boolean combinators T/K:
#define CMP(op) do { CHECK(2); r = evalint(ARG(TOP(1))) op evalint(ARG(TOP(2))); n = TOP(2); SETIND(n, r ? combT : combK); POP(2); } while(0)
case I itself (lines 421β428):
case I:
CHECK(1);
x = ARG(TOP(1));
POP(1);
n = TOP(0);
SETIND(n, x);
GOTO ind;
break;
β I x doesn't allocate a fresh copy of x; it turns the I x
application cell itself into an indirection to x. This is the same
in-place-update trick as S (section 3), specialized to the case where
the "new graph" is just the existing argument.
Following happens in three places: inside eval's own loop (case IND,
quoted in section 2, n = INDIR(n); TOP(0) = n;), in evali (lines
327β338, while (TAG(n) == IND) n = INDIR(n); after eval returns β this
is what callers use to get a "real" answer, never an indirection cell),
and inside the garbage collector's mark (next section) β which also
compresses indirection chains as a side effect, rewriting the root
pointer past them (lines 165β171):
if (TAG(n) == IND) {
/* Skip indirections, and redirect start pointer */
while (TAG(n) == IND) {
n = INDIR(n);
}
*np = n;
}
5. Memory: allocation and a real mark-and-scan GC
Cells come from one fixed-size array, allocated once (lines 11, 46):
#define HEAP_CELLS 100000
#define ALLOC_HEAP(n) do { cells = malloc(n * sizeof(node)); memset(cells, 0x55, n * sizeof(node)); } while(0)
100,000 cells, malloc'd up front and never grown β there's no free()
of individual cells anywhere in the file; the only way a cell becomes
available again is via the collector. alloc_node (lines 108β120) just
pops the head of a free list:
NODEPTR
alloc_node(enum node_tag t)
{
NODEPTR n = next_free;
if (n == NIL) {
ERR("out of memory");
}
if (TAG(n) != FREE)
abort();
next_free = NEXT(n);
TAG(n) = t;
return n;
}
And commit 1 genuinely does have a collector β mark-and-scan, called
before allocation-heavy reductions via GCCHECK (e.g. case S, section
2: GCCHECK(2) before its two new_ap calls). gc_check only actually
collects if the free list looks short (lines 212β222); gc, mark, and
scan (lines 158β210) are a textbook stop-the-world mark/sweep: mark
walks from every live root β every cell currently on stack[0..stack_ptr]
β marking AP spines recursively (and compressing IND chains as shown
above); scan then does one linear pass over the whole heap, threading
every unmarked cell onto the free list and clearing marks on the rest
(lines 198β210, three commented-out fprintf debug lines elided, marked
...):
void
gc(void)
{
num_gc++;
num_marked = 0;
// ...
for (int i = 0; i <= stack_ptr; i++)
mark(&stack[i]);
// ...
scan();
// ...
}
This is exercised for real, not just present in principle: running
nfib 35 (see y-combinator-and-recursion.md)
reports 1944 GCs over a billion-odd reductions.
What commit 1 does not have: no compaction or copying (cells never
move β scan only relinks the free list, it doesn't shrink or defragment
the heap array), no generations, no reference counting, and no growable
heap (out of memory in alloc_node is a hard ERR/exit, not a
request for more memory). The NODE_SPLIT and NODE_64 representations
sketched in the #elif branches at the top of the file are unused
alternatives β NODE_NAIVE (line 9: #define NODE_NAIVE) is the one
#defined and compiled; struct node in section 1 is its representation.
6. Laziness falls out of this, it isn't added on top
Nothing above ever evaluates an argument before it's asked for. case S
builds new_ap(f, x) and new_ap(g, x) and immediately GOTO aps back
into the left spine β it never inspects x. x only gets forced when
some later reduction step needs it as an INT (via evalint, e.g. inside
ARITH2, line 476) or needs to pattern-match its outermost tag. An
argument that's never demanded is simply never touched β it stays an
unevaluated AP/combinator subgraph forever, at zero cost beyond the
memory it occupies. Combine that with section 3's sharing and you get
call-by-need for free: an argument is a graph, evaluated at most once,
exactly when (and if) something forces it.
Next: y-combinator-and-recursion.md β the same in-place-update trick, applied to build a genuinely cyclic graph for recursion.
Background for chapter 01.
The Y combinator and recursion
bracket-abstraction.md eliminates every Lam in
a term. But nfib calls itself β how does a self-call survive translation
into a language that, after compile, has no names left at all?
1. No names, no self-reference
abstract (see bracket-abstraction.md section
- only knows how to eliminate a bound variable from a term that
contains it. A recursive definition like
nfib n = ... nfib ...isn't naturally expressible this way:nfibisn't a parameter of anything β it's the name being defined, referring to itself, inside its own body. Oncecompilehas erasedVar/Lam, there is no environment and no name table at runtime (see lambda-calculus-and-ski.md section 3) for a name to even refer through. Combinator terms are closed by construction; something else has to supply the self-reference.
2. Y f = f (Y f), and what the reduction actually builds
The standard trick is the fixed-point combinator Y: Y f is defined to
behave exactly like f (Y f) β apply f to "the very recursive value
we're building." Turn nfib's body into an ordinary (non-recursive)
function that takes its own recursive callee as an extra argument,
nfibF = \nfib n -> ..., and Y nfibF is a value equal to
nfibF (Y nfibF) β feed nfibF a copy of itself to call.
ccb31388:eval.c's reduction rule for Y (lines 429β437):
case Y:
CHECK(1);
f = ARG(TOP(1));
POP(1);
n = TOP(0);
FUN(n) = f;
ARG(n) = n;
GOTO ap;
break;
n here is TOP(0) β the outermost cell of the redex, i.e. the AP(Y, f)
node for Y f itself (compare case S in
graph-reduction-and-laziness.md section
2, same pattern: read the argument, then reuse the outer cell as the
answer). The rewrite sets FUN(n) = f and, critically, ARG(n) = n β the
node's own argument field is set to point at itself. This is not "call
Y f again to get another copy of f (Y f)" β it is one cell, mutated in
place, that is now literally AP(f, n) where n is the very node holding
that AP. Follow ARG from n and you arrive back at n. The graph for
Y f is genuinely cyclic: a single self-referencing application cell,
built once, with no bound on how many times it can be "unfolded" because
unfolding it just means re-entering the same cell.
GOTO ap restarts the spine walk from n, now f (Y f) β exactly the
fixed-point equation, but as a graph identity rather than a rewrite that
has to be repeated. If f's body demands its second argument again deeper
inside (the recursive call site), it dereferences the same self-looped
cell n and finds it β by then β either still the same AP(f, n) (if the
recursion hasn't been forced further) or already reduced further along
the same self-link. Either way, f itself was fetched once and is never
copied; only the spine around the self-reference grows as reduction
proceeds.
3. eNfib: the recursive definition, and where Y enters it
ccb31388:Lam.hs (lines 193β194):
fix :: Exp -> Exp
fix e = App CY e
fix is nothing but "wrap in Y" β CY is the Comb "Y" pattern
synonym (ccb31388:Lam.hs, line 72: pattern CY = Comb "Y"), and
App CY e is the term Y e. eNfib (line 225):
eNfib = fix $ comp $ \ nfib n -> iF (n .< 2) 1 (nfib $$ (n-1) + nfib $$ (n-2) + 1)
\nfib n -> ... is the non-recursive nfibF from section 2, written with
Haskell's own lambda syntax and turned into an Exp by the ToLam
machinery (comp = improve . compile . toLam, line 219 β see
bracket-abstraction.md for compile/improve).
nfib $$ (n-1) and nfib $$ (n-2) are the two recursive calls, both
against the parameter nfib, not a self-reference by name. fix
applies Y to the whole compiled function, so eNfib = Y nfibF β and
every occurrence of nfib inside the body ends up, after compile,
referring to whatever Y's self-looped cell (section 2) resolves to when
demanded. That compiled term, printed by ./eval nfib.comb before
reduction, has Y right where the derivation predicts β wrapping the
whole recursive function, applied to 35:
((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)
(This is the same printed term bracket-abstraction.md
section 4 already quotes as the running example of improve's output; the
Y at the front is the part this doc is about.)
4. Recursion as sharing
Section 2's self-looped cell is the same in-place-update mechanism
graph-reduction-and-laziness.md
describes for ordinary combinators (S, K, I) β Y's reduction just
uses it to point a cell at itself instead of at some other existing node.
Every recursive call in a running nfib reuses the one f pointer
case Y fetched at the start; nothing about f's code (its compiled
combinator graph) is ever duplicated per call. Only the argument spine β
the chain of pending n-1/n-2 applications β grows as recursion
descends, and each of those is built and torn down by ordinary AP
reduction, exactly as in the rest of the graph.
5. Verified: ./eval nfib.comb prints 29860703
$ cd /tmp/mhs-ccb31388 && nix develop $MICROHS -c make test
./eval nfib.comb
((Y (((C' C) ((B (S (> 2))) (((C' (C' +)) (((S' (S' +)) ((C B) (-' 1))) ((C B) (-' 2)))) 1))) 1)) 35)
29860703
node size=24, heap size=100000
1045124618 reductions, 1944 GCs
29860703 is nfib 35 β matching every earlier task's known-good result
for this commit. The run also confirms
graph-reduction-and-laziness.md's claim
that GC is real and exercised, not just present in the source: 1944 GCs
actually fired over the course of this one computation.
6. What changes later
This Y β one self-looped cell, reduced by case Y exactly as shown
above β is what the first commit does. It is not the last word: commit
d2956ee8, "Optimize Y better." (2023-06-02), revisits this
implementation. What it changes is out of scope here; chapter 04 covers
it.
Background for chapter 02.
Parser combinators
Commit a9178695 ("Make a real compiler.", 2023-05-29) adds a new module,
ParserComb.hs, 165 lines, and its name is not a metaphor: it is a small,
self-contained parser-combinator library β a Prsr-style toolkit in the
lineage of Wadler/Hutton parsing, built from ordinary Haskell type classes
(Functor, Applicative, Monad, Alternative) rather than a parser
generator or an external grammar file. Parse.hs, a second new module
(204 lines), uses it to parse the surface syntax chapter 02 introduces.
This doc walks the library itself; chapter 02 walks what it's used for.
1. The parser type
a9178695:ParserComb.hs, lines 22β44 (LastFail, Res, Prsr together,
quoted whole):
data LastFail
= LastFail Int [(String, [String])]
deriving (Show)
noFail :: LastFail
noFail = LastFail maxBound []
longest :: LastFail -> LastFail -> LastFail
longest lf1@(LastFail l1 x1) lf2@(LastFail l2 x2) =
case compare l1 l2 of
LT -> lf1
GT -> lf2
EQ -> LastFail l1 (x1 ++ x2)
longests :: [LastFail] -> LastFail
longests [] = undefined
longests xs = foldl1 longest xs
data Res s a = Many [(a, (String, s))] LastFail
deriving (Show)
newtype Prsr s a = P { runP :: (String, s) -> Res s a}
instance Show (Prsr s a) where show _ = "<<Prsr>>"
Strip away the error bookkeeping and the shape is the textbook one: a parser is a function from the remaining input to a result. Two things about this particular function are worth noticing before anything else:
- It's not
String -> Maybe (a, String).Res s aisMany [(a, (String, s))] LastFailβ a list of(result, leftover)pairs, not one. This parser doesn't commit to the first successful parse; it can return several, andparseDie(a9178695:Parse.hs, lines 82β87) is the place that decides what to do if there's more than one: exactly one result is accepted, zero results becomes a formatted "found X, expected Y" message (formatFailed,a9178695:ParserComb.hslines 101β113 β a live transcript of its output is in chapter 02's "Try it" exercise 2), and more than one becomeserror $ "Ambiguous:\n" ++ .... Prsr s acarries a second type parameter,s, threaded alongside the string. This is a stateful parser βsis arbitrary parser state, not fixed by the library.Parse.hsinstantiates it astype P a = Prsr [Int] a, an indentation-column stack, and gets a full Haskell-style layout rule (section 4 below) withoutParserComb.hsknowing anything about layout at all.LastFail Int [(String, [String])]records, independent of whichever parse eventually succeeds, the furthest point the parser ever reached before failing, plus what it was expecting there (Intis a length β the parser measures "how far into the input" by how much string is left, so smaller remaining length means further progress;longestis misleadingly named β it's picking the shortest leftover, i.e. the parse that consumed the most). That's the raw material for a "found X, expected Y" error message, threaded through every combinator whether it succeeds or not.
2. Sequencing and choice: the type-class plumbing
Everything downstream β do-notation, <*>, <|> β comes from four
instances, quoted whole (a9178695:ParserComb.hs, lines 46β75):
instance Functor (Prsr s) where
fmap f p = P $ \ t ->
case runP p t of
Many aus lf -> Many [ (f a, u) | (a, u) <- aus ] lf
instance Applicative (Prsr s) where
(<*>) = ap
pure a = P $ \ t -> Many [(a, t)] noFail
instance Monad (Prsr s) where
return = pure
p >>= k = P $ \ t ->
case runP p t of
Many aus plf ->
let xss = [ runP (k a) u | (a, u) <- aus ]
in let (rss, lfs) = unzip [ (rs, lf) | Many rs lf <- xss ]
in Many (concat rss) (longests $ plf : lfs)
instance MonadFail (Prsr s) where
fail m = P $ \ (r, _) -> Many [] (LastFail (length r) [("fail", [m])])
instance MonadState s (Prsr s) where
get = P $ \ t@(_, s) -> Many [(s, t)] noFail
put s = P $ \ (f, _) -> Many [((), (f, s))] noFail
instance Alternative (Prsr s) where
empty = P $ \ (r, _) -> Many [] (LastFail (length r) [("empty", ["<empty>"])])
p <|> q = P $ \ t ->
case (runP p t, runP q t) of
(Many a lfa, Many b lfb) -> Many (a ++ b) (longest lfa lfb)
pure a succeeds without consuming input, returning a β the base case
for sequencing. p >>= k is "parse with p, then for every result p
produced, run k on it and parse with the resulting parser" β for a
single-result parser this would just be function composition, but because
Many can hold several (a, leftover) pairs, >>= runs k once per
result and concatenates. Alternative's p <|> q runs both p and q
on the same input and returns both sets of successes concatenated
(a ++ b) β this is not "try p, fall back to q on failure" in the
usual sense; both branches always run, both branches' results are kept
(genuine ambiguity, not just backtracking), and only the two LastFail
records get merged via longest.
That last point matters enough that the library ships a second, differently
-behaved choice operator right next to it (a9178695:ParserComb.hs, lines
77β84):
-- Left biased choice
(<|<) :: Prsr s a -> Prsr s a -> Prsr s a
p <|< q = P $ \ t ->
case runP p t of
Many [] lfa ->
case runP q t of
Many b lfb -> Many b (longest lfa lfb)
r -> r
<|< only tries q if p produced no results at all β ordinary
committed choice, the behavior most parser-combinator tutorials call
<|>. Parse.hs reaches for <|< exactly where determinism matters (a
{ for a real curly brace versus the indentation-triggered virtual one,
section 4) and plain <|> everywhere the grammar is allowed to stay
ambiguous and let parseDie sort it out later.
3. The primitives that touch characters
Above the type-class layer, ParserComb.hs defines the handful of
functions that actually look at the input. satisfy, char, and eof
(a9178695:ParserComb.hs, lines 119β141, string elided β same shape):
satisfy :: String -> (Char -> Bool) -> Prsr s Char
satisfy msg f = P $ \ t ->
case t of
(c:cs, s) | f c -> Many [(c, (cs, s))] noFail
(cs, _) -> Many [] (LastFail (length cs) [("satisfy", [msg])])
char :: Char -> Prsr s Char
char c = P $ \ t ->
case t of
(c':cs, s) | c == c' -> Many [(c, (cs, s))] noFail
(cs, _) -> Many [] (LastFail (length cs) [("char", [show c])])
...
eof :: Prsr s ()
eof = P $ \ t ->
case t of
("", _) -> Many [((), t)] noFail
(cs, _) -> Many [] (LastFail (length cs) [("eof", ["end-of-file"])])
Every one of them is the same four lines: look at the first character (or
the whole string, for eof), succeed-and-consume or fail-and-record-why.
satisfy takes an explicit msg string for its own failure record β the
"lower case", "upper case", "digit", "symbol" strings visible in
Parse.hs's error output (chapter 02's "Try it", exercise 2) all
originate as this msg argument.
Above that, three more pieces of plumbing (a9178695:ParserComb.hs, lines
86β90 and 143β147, two separate spots in the file, quoted whole each):
emany :: Prsr s a -> Prsr s [a]
emany p = esome p <|< pure []
esome :: Prsr s a -> Prsr s [a]
esome p = (:) <$> p <*> emany p
...
(<?>) :: Prsr s a -> String -> Prsr s a
p <?> e = P $ \ t ->
-- trace ("<?> " ++ show e) $
case runP p t of
Many rs (LastFail l xs) -> Many rs (LastFail l [(m, e:es) | (m, es) <- xs ])
emany/esome are "zero-or-more" and "one-or-more" β the standard
Kleene star and plus, mutually recursive in the usual way (esome p
parses one p then emany p for the rest; emany p is esome p, or
nothing). The library's export list also re-exports plain many and
some unchanged from Control.Applicative (the --hiding (many, some)
comment on the import Control.Applicative line is commented out β the
import isn't actually restricted) β so Parse.hs has both the
Alternative-derived many/some and this module's own eager emany/
esome in scope side by side, and in this range consistently reaches for
the e-prefixed ones. <?> is the labeling combinator: run p, and if
it produced any failure record, prepend e to every expectation string
in it β this is how pKeyword, pSymbol, and friends attach a
human-readable name ("module", "=", "int") to a failure that would
otherwise just say "symbol" or "lower case".
4. One production, read aloud
Parse.hs builds a whole grammar this way β pModule, pDef, pExpr,
pCase, and so on β each one a single <$>/<*>/<|> expression built
from smaller parsers. Here is the top one, quoted whole
(a9178695:Parse.hs, lines 94β95):
pModule :: P Module
pModule = Module <$> (pKeyword "module" *> pUIdent <* pKeyword' "where") <*> pBlock pDef
Read aloud: to parse a module, first parse the keyword module (*>
discards its result β a keyword carries no data), then an uppercase
identifier (pUIdent β this becomes the module name), then the keyword
where (<* again discards it), then a block of definitions
(pBlock pDef β a layout-delimited, semicolon-separated list of pDef,
described below). Package the identifier and the definition list into a
Module value with Module <$> ... <*> .... Nothing here is bespoke
parsing code; it's four already-defined parsers (pKeyword, pUIdent,
pKeyword', pBlock) glued with three already-defined combinators
(<$>, *>/<*, <*>).
pBlock and its neighbors are worth a second look, because they show the
s type parameter from section 1 earning its keep β a Haskell-style
layout rule (real {/;/} or indentation-inferred ones) implemented
entirely as user-level parsers over Prsr [Int] a, no changes to
ParserComb.hs required (a9178695:Parse.hs, lines 176β204, quoted
whole):
pLCurl :: P ()
pLCurl = pSym '{' <|< softLC
where softLC = do
msp <- pIndent
case msp of
Nothing -> fail "\\n"
Just sp -> do
-- traceM ("push " ++ show (length sp))
modify $ \ st -> length sp : st
pRCurl :: P ()
pRCurl = pSym '}' <|> softRC
where softRC = do
is <- get
if null is then
fail "}"
else do
put (tail is)
eof
pSemi :: P ()
pSemi = pSym ';'
pBlock :: P a -> P [a]
pBlock p = do
pLCurl
as <- esepBy p pSemi
pRCurl
pure as
pLCurl opens a block: either a literal {, or β via <|<, committed
choice, since a real { should never also try the indentation path β
softLC, which reads the indentation of the next line (pIndent, not
shown here) and pushes its column onto the state stack with modify.
pRCurl closes one: a literal }, or softRC, which pops the stack β
and, notably, only succeeds if that pop is followed immediately by eof,
which is how the whole toy currently gets away with never emitting a
virtual close-brace on dedent inside the file (pWhite, elsewhere in
Parse.hs, injects } characters back into the input stream directly
via inject, for the general case). esepBy (not shown; esepBy1 plus
an empty-list fallback, both ordinary user code built from emany and
<|>) handles the semicolon-separated middle. None of pLCurl,
pRCurl, pSemi, or esepBy are ParserComb.hs exports with special
status β they're Parse.hs-local functions built the same way pModule
is, out of the same eleven-ish primitives from sections 2β3.
5. Why this style suits incremental growth
That's the actual payoff of writing the parser this way rather than, say,
hand-rolling a state machine or reaching for a parser-generator tool: a
new piece of syntax is a new combinator expression, built from the same
building blocks, dropped in next to the others β not a rewrite of a
recursive-descent function pushed to sixteen cases from twelve. It
already happens once inside this very range: commit 4030f721 ("Parse
more") adds a parenthesized-operator form ((+), to write an operator as
an ordinary identifier) in twelve added lines total: a new pOper/
pOper' pair (nine of the twelve) built from esome/satisfy/guard
exactly like every other parser in the file, pSymbol adjusted to reuse
pOper' instead of repeating its definition, and one new alternative
appended to pAExpr with <|>. chapter 03 does this
repeatedly at larger scale β let, infix operators, tuples, list syntax,
do-notation each land as a handful of new p-something definitions,
not a restructuring of ParserComb.hs, which is untouched through all of
it. The parser type from section 1 already has everything later syntax
needs (sequencing, choice, state, error tracking); growing the language
is purely additive at the grammar layer.
Background for chapter 07.
Bootstrapping a compiler
"Self-hosting" means a compiler that can compile its own source code. It
sounds circular, and the first time through it is: before any version of
uhs exists, nothing can compile uhs's own sources β they're just
Haskell text sitting on disk. MicroHs breaks the circle the ordinary
way every self-hosting compiler does: use a different, already-working
implementation of (a large-enough subset of) the same language to build
the first working copy, then let that first copy take over. In this
project the "different implementation" is GHC, already sitting in the
nix develop shell every chapter of this book builds from.
The ladder, as it stands after chapter 07
By the time chapter 08 wires up dedicated make targets for it, the
bootstrap ladder has three rungs, and the Makefile names them directly
(456ce17c:Makefile lines 66β69, quoted whole):
bootcombtest: $(BIN)/uhs uhs.comb
$(BIN)/uhs -ilib -isrc -omain-uhs.comb MicroHs.Main
$(BIN)/eval -H10000000 -K1000000 -ruhs.comb -- -ilib -isrc -omain-comb.comb MicroHs.Main
cmp main-uhs.comb main-comb.comb
Read as a ladder:
- GHC compiles the compiler.
$(BIN)/uhsβ an ordinary native binary β is built by GHC fromsrc/MicroHs/Main.hsand everything it imports (456ce17c:Makefileline 23:$(GHCE) -package mtl -isrc -Wall -O src/MicroHs/Main.hs -main-is MicroHs.Main -o $(BIN)/uhs). Nothing self-referential here yet β it's the same "GHC builds a Haskell program" every earlier chapter's Try-it already does. bin/uhscompiles the same source. Theuhs.combrule (456ce17c:Makefilelines 80β81) runs$(BIN)/uhs -ilib -isrc -ouhs.comb MicroHs.Mainβ the GHC-built compiler reads its ownsrc/MicroHs/*.hssources and emitsuhs.comb, a combinator file in the same textual format every chapter's own.comboutput has used since chapter 02. This is the first genuinely self-hosting step: the compiler is compiling itself, just not running as itself yet.bin/evalrunsuhs.comb, which compiles the source a second time.bootcombtest's middle line handsuhs.combto the C runtime ($(BIN)/eval ... -ruhs.comb -- ... MicroHs.Main) β the runtime interprets the combinator file chapter 02 built the very first version of, and what it's interpreting is the compiler itself. That interpreted compiler then compilesMicroHs.Mainfrom source again, writingmain-comb.comb. Meanwhile the first line of the same target,$(BIN)/uhs -ilib -isrc -omain-uhs.comb MicroHs.Main, has the original GHC-built binary compile the identical source tomain-uhs.comb, independently.- Compare.
cmp main-uhs.comb main-comb.combβ plain byte equality, no special-casing.
A second, parallel check exists alongside this one, built from the "local
lib" infrastructure this chapter's own July 1st commits add (see chapter
07's "What happened"): bootboottest
(456ce17c:Makefile lines 61β64, quoted whole):
bootboottest: $(BIN)/uhs $(BIN)/bootuhs
$(BIN)/uhs -ilib -isrc -omain-uhs.comb MicroHs.Main
$(BIN)/bootuhs -ilib -isrc -omain-boot.comb MicroHs.Main
cmp main-uhs.comb main-boot.comb
$(BIN)/bootuhs is a second native binary β still built by GHC, but
compiled module-by-module against MicroHs's own hand-rolled lib/
sources (through the ghc/ primitives shim, -XNoImplicitPrelude, and
the convertY.sh preprocessor described below) instead of against GHC's
own base package. bootboottest checks that this alternate,
library-swapped GHC build agrees with the ordinary one β a different axis
of "does this still mean the same thing," orthogonal to running the
combinator file through the interpreter.
Why byte-identical output is the oracle
Neither test asks "does the output look right." Both just cmp two
files. That's deliberate, and it's the only oracle that scales here: a
compiler bug β say, a case arm handled in the wrong order, or an operator
compiled with the wrong associativity β doesn't have to crash anything to
be a bug. It can produce a .comb file that runs fine but computes a
different value from what the other build of the same compiler,
compiling the same source, produces. Left uncaught, that kind of bug
would happily compile itself into every future build. Byte-for-byte
comparison at each rung turns "the compiler is subtly wrong" into "these
two files differ" β a diff cmp reports immediately, instead of a bug
that only surfaces if someone happens to write a test that exercises the
exact miscompiled path. Chapter 07's whole grind β everything short of
actually running bootcombtest for real, which is chapter 08's story β
is the work of getting the compiler's own source into a state where
rung 2 above (bin/uhs compiling itself) even produces a uhs.comb that
parses and runs at all.
The constraint: uhs may only use uhs
For the ladder above to prove anything, the compiler's own source has to
be legal input to both GHC and, eventually, itself. That's a much
narrower dialect than ordinary Haskell β no type classes (grep -rn "class \|instance " across src/MicroHs/ and src/Text/ParserComb.hs
in this chapter's own Try-it turns up nothing live, and neither does the
same search over lib/), no deriving, no fixity declarations. Chapter
07 is largely the diet this constraint forces, three examples of it made
concrete:
Int, notInteger.a721e046("Use Int instead of Integer") narrowsEInt's payload fromIntegertoIntthroughoutsrc/MicroHs/Parse.hs/Desugar.hsβuhs's ownExptype had already committed to machine-widthIntback in chapter 06'sTranslate.hs(its arithmetic table is typedInt -> Int -> Int), and the front end catches up to match.- A hand-rolled
Map, becauseData.Mapneeds anOrdinstance anduhshas no instances at all:f113210faddssrc/MicroHs/Map.hs, 33 lines, admitting its own limits in a comment right next to the type (f113210f:src/MicroHs/Map.hsline 5):-- This is a pretty bad implementation.Every lookup and merge function takes its equality test as an explicit argument (lookup :: (k -> k -> Bool) -> k -> Map k v -> Maybe v) instead of reaching for(==)through a type-class constraint. - No fixity mechanism, so
uhs's own parser resolves every operator's precedence from one hand-written ladder ofpLeftAssoc/pRightAssoc/pNonAssoccalls inpExpr(f113210f:src/MicroHs/Parse.hslines 450β462) rather than readinginfixl/infixrdeclarations β there are none to read; a project-wide search forinfixinsrc/MicroHs/Parse.hsacross this chapter's whole commit range comes up empty. The--Yinfixr 2 ||-style comments that do show up inlib/*.hsin this range (chapter 07's "What happened" quotes one) are for GHC's benefit on the "local lib" build path below, notuhs's own parser.
The --X/--Y trick
The same source files have to satisfy two different compilers with two
different needs β GHC wants real hiding clauses, real fixity
declarations, real deriving; uhs doesn't understand any of them and
would choke if it tried. The fix is textual: write the GHC-only syntax as
an ordinary comment with a two-character marker prefix, and let a GHC
preprocessor (-F -pgmF, a stage that rewrites source before
compilation) strip the marker right before GHC parses the file. uhs
itself never runs this preprocessor, so it just sees an ordinary comment
and skips it. Both scripts are two lines (b71e0e90:convert.sh, split
into 67715b16:convertX.sh and bea8283d:convertY.sh; quoted here as
they read once both exist, f113210f:convertX.sh and
f113210f:convertY.sh, each whole):
#!/bin/sh
( echo "{-# LINE 1 \"$1\" #-}" ; sed -e 's/--X//' $2 ) > $3
#!/bin/sh
( echo "{-# LINE 1 \"$1\" #-}" ; sed -e 's/--Y//' $2 ) > $3
convertX.sh feeds the $(BIN)/uhs build itself (GHCE, rung 1 above):
a line like import Prelude --Xhiding (Monad(..), ...) becomes, once
GHC sees it, import Prelude hiding (Monad(..), ...) β needed because
uhs's source hand-rolls its own >>=/pure/<*> as plain top-level
functions (see chapter 07) and GHC's
real Prelude would otherwise fight it for those names. convertY.sh
feeds the "local lib" build (GHCC, the machinery bootboottest's
$(BIN)/bootuhs depends on): a line like --Yinfixr 2 || in
lib/Data/Bool.hs becomes a real infixr 2 || declaration, needed
because that build compiles lib/'s hand-rolled (||) under GHC's own
expression grammar, which does care about declared fixity even though
uhs's own parser doesn't. Same source, two markers, two audiences.
One more name for this
This two-rung self-check β compiler output matching compiler output,
one level removed β is the same shape of trust question Ken Thompson's
1983 Turing-award lecture "Reflections on Trusting Trust" (published
in CACM in 1984) raises: a
cmp that passes proves the two builds agree with each other, not that
either one is correct, so a bug (or something worse) baked into the very
first GHC-built bin/uhs could in principle survive every later
bootstrap unnoticed.
Background for chapter 09.
Hindley-Milner, gently
This is not a tutorial on Hindley-Milner in general β textbook HM comes
with generalization, an occurs check, and let-polymorphism baked in. This
describes the specific checker MicroHs actually shipped first,
src/MicroHs/TypeCheck.hs as of cc57346e ("First version with type
checking.", 2023-08-06). Every claim below is checked against that file,
at that commit, or against a program actually run through the cc57346e
build.
Types are terms
The hook, and the thing every other section depends on: MicroHs does not
have a separate type-syntax AST. 33b009fa ("Use Expr for EType.",
2023-07-21) deletes the three-constructor EType that had existed since
chapter 08's db418344 (TVar/TConstr/TUVar) and replaces it with a
type synonym for the same Expr that already represents terms
(33b009fa:src/MicroHs/Parse.hs lines 99β111, quoted whole):
-- Expr restricted to
-- * after desugaring: EApp and EVar
-- * before desugaring: EApp, EVar, ETuple, EList
type EType = Expr
validType :: Expr -> Bool
validType ae =
case ae of
EVar _ -> True
EApp f a -> validType f && validType a
EList es -> length es <= 1 && all validType (take 1 es)
ETuple es -> all validType es
_ -> False
A type is just an Expr that satisfies validType β a type variable and
a type constructor are both EVar, a type application (Maybe Int,
[a], a -> b) is EApp, and there's no separate parser production for
types at all: pType runs the ordinary expression parser and checks
validType on what comes back (33b009fa:src/MicroHs/Parse.hs lines
450β454). This carries all the way into the checker β a metavariable
(textbook HM's Ξ±, Ξ², ...) is one more constructor on the same Expr
type (cc57346e:src/MicroHs/Parse.hs lines 56β78, quoted in part, ...
marking constructors omitted from the middle):
data Expr
= EVar Ident
| EApp Expr Expr
...
| EBad
-- Only while type checking
| EUVar Int
-- Constructors after type checking
| ECon ConTyInfo Ident
--Xderiving (Show, Eq)
There is no Type module and not even a separate Kind type β type EKind = EType (cc57346e:src/MicroHs/Parse.hs line 133). Terms, types,
and kinds are one recursive datatype throughout.
Metavariables and substitution, as implemented
An unknown type is EUVar Int, just an integer tag. The substitution
that resolves those tags is a single IM.IntMap EType in the checker's
own state (cc57346e:src/MicroHs/TypeCheck.hs line 132):
data TCState = TC IdentModule Int TypeTable ValueTable (IM.IntMap EType)
The Int next to it is a fresh-name counter; newUVar bumps it and
returns EUVar n (cc57346e:src/MicroHs/TypeCheck.hs lines 338β342).
Recording a solution is a plain map insert, addUVar
(cc57346e:src/MicroHs/TypeCheck.hs lines 254β262, quoted whole):
addUVar :: Int -> EType -> T ()
addUVar i t = T.do
let
add = T.do
TC mn n tenv venv sub <- get
put (TC mn n tenv venv (IM.insert i t sub))
case t of
EUVar j -> if i == j then T.return () else add
_ -> add
The one check here is i == j β refuse to insert i β¦ EUVar i. That is
the entire safety net; there is no deeper occurs check, and "What this
checker leaves out" below shows what that costs. Reading a solution back
out chases the map recursively (derefUVar,
cc57346e:src/MicroHs/TypeCheck.hs lines 291β300, quoted whole):
derefUVar :: EType -> T EType
derefUVar at =
case at of
EApp f a -> EApp <$> expandType f <*> expandType a
EUVar i -> T.do
sub <- gets uvarSubst
case IM.lookup i sub of
Nothing -> T.return at
Just t -> derefUVar t
_ -> T.return at
expandType (lines 270β273) is derefUVar followed by expandSyn,
which does the same kind of lookup against a different map β type
synonyms. And replacing a named type variable (what
forall-instantiation needs) is separate again: subst, a plain
syntactic walk (cc57346e:src/MicroHs/Parse.hs lines 759β768, quoted
whole):
subst :: [(Ident, Expr)] -> Expr -> Expr
subst s =
let
sub ae =
case ae of
EVar i -> fromMaybe ae $ lookupBy eqIdent i s
EApp f a -> EApp (sub f) (sub a)
EUVar _ -> ae
_ -> error "subst unimplemented"
in sub
Three mechanisms, three pieces of state, all on the one Expr type.
Unification, traced by hand
unify/unifyR (cc57346e:src/MicroHs/TypeCheck.hs lines 302β330,
quoted whole) is the whole algorithm β dereference both sides, then
compare structurally:
unify :: HasCallStack => EType -> EType -> T ()
unify a b = T.do
aa <- expandType a
bb <- expandType b
unifyR aa bb
unifyR :: HasCallStack => EType -> EType -> T ()
unifyR a b = T.do
venv <- gets valueTable
tenv <- gets typeTable
let bad = error $ "Cannot unify " ++ showExpr a ++ " and " ++ showExpr b ++ "\n" ++
show a ++ " - " ++ show b ++ "\n" ++
show tenv ++ "\n" ++
show venv
case a of
EVar ia ->
case b of
EVar ib -> if eqIdent ia ib then T.return () else bad
EApp _ _ -> bad
EUVar i -> addUVar i a
_ -> impossible
EApp fa xa ->
case b of
EVar _ -> bad
EApp fb xb -> T.do { unify fa fb; unify xa xb }
EUVar i -> addUVar i a
_ -> impossible
EUVar i -> addUVar i b
_ -> impossible
Four cases, because a desugared EType only contains
EVar/EApp/EUVar: two constructor names must match exactly by
identifier; two applications unify head and argument separately (this is
how a -> b against Int -> Char splits in two, since tArrow a r = EApp (EApp (EVar "Primitives.->") a) r); a metavariable on either side
just records a binding.
Trace 1 β the call const 5 'x', where const :: forall a b . a -> b -> a has been freshened at the use site to EUVar 2 -> EUVar 3 -> EUVar 2
(see tInst below) β the application's result metavariable Ο
(EUVar 1) is allocated before tInst freshens the scheme, so it
carries the lowest fresh number, not the highest. Checking the
application unifies the freshened scheme against Int -> (Char -> Ο):
unify (EUVar 2) Int β addUVar 2 Int; unify (EUVar 3) Char β
addUVar 3 Char; unify (EUVar 1) (EUVar 2) β unify runs
expandType on both sides first, and 2 β¦ Int is already in the map,
so the binding recorded is addUVar 1 Int directly β no two-hop
chase. Dereferencing Ο afterward reads 1 β¦ Int β the call's
result type is Int.
Trace 2 β a failing unification, run for real: bad2 :: forall a b . a -> b -> a; bad2 x y = x + 1 fails with Cannot unify a and Primitives.Int, because inside bad2's own body the signature's a is
a literal, un-instantiated EVar name (why: next section) β unifyR's
EVar/EVar case runs with ia = "a", ib = "Primitives.Int",
eqIdent is false, and bad fires. No metavariable involved at all.
forall, instantiation, and rigid type variables
f7db30f1 ("Add lots of type signatues and forall.", 2023-07-23) adds
forall across the standard library β 9 files, 134 insertions, 74
deletions, all under lib/ plus the Makefile. Most touched functions
already had a signature; what's new is the quantifier
(f7db30f1:lib/Data/Function.hs, quoted in part: . and id omitted
after the first ..., flip omitted after the second, uncurry omitted
after fix β each of those four just gains a forall the same way ($)
does):
-($) :: (a -> b) -> a -> b
+($) :: forall a b . (a -> b) -> a -> b
...
-const :: a -> b -> a
+const :: forall a b . a -> b -> a
...
+fix :: forall a . (a -> a) -> a
fix = primFix
Why the quantifier matters is mechanical. ETypeScheme [Ident] EType
carries an explicit list of bound names
(cc57346e:src/MicroHs/Parse.hs line 130), and the parser only
populates it when it sees the keyword (cc57346e:src/MicroHs/Parse.hs
lines 484β488, quoted whole):
pTypeScheme :: P ETypeScheme
pTypeScheme = P.do
vs <- (pKeyword "forall" *> esome pLIdent <* pSym '.') <|< pure []
t <- pType
pure $ ETypeScheme vs t
Checking a signature (tcTypeScheme, lines 485β488 of TypeCheck.hs)
brings exactly the names in vs into scope; a name never quantified is
simply not in scope. Confirmed by deleting the quantifier from const's
signature in the cc57346e worktree (const :: a -> b -> a, no
forall) and compiling a program that uses it:
mhs: undefined variable a
CallStack (from HasCallStack):
error, called at src/MicroHs/TypeCheck.hs:354:16 in main:MicroHs.TypeCheck
β the checker fails on const's signature, never reaching its body.
Without forall a polymorphic-looking signature is ill-formed, which is
why f7db30f1 had to sweep the library two weeks before cc57346e
could accept any of it.
What makes a definition polymorphic, then, given that there is no
generalization step anywhere in this file? tInst freshens a scheme's
bound names to new metavariables only at a use site β every EVar
reference goes through tLookupInst
(cc57346e:src/MicroHs/TypeCheck.hs lines 364β371, quoted whole):
tInst :: ETypeScheme -> T EType
tInst as =
case as of
ETypeScheme vs t ->
if null vs then T.return t
else T.do
us <- mapM (const newUVar) (replicate (length vs) ())
T.return (subst (zip vs us) t)
But when the checker verifies the definition itself, tcDefValue
(lines 526β536) checks the body against the declared type verbatim β
still containing the literal identifiers a/b, not fresh
metavariables. Since unifyR's EVar/EVar case demands identical
names, a behaves as a rigid, opaque constant for that one definition's
check β exactly what the bad2 failure above shows. Two consequences:
(1) polymorphism = rigid check at the definition plus fresh
instantiation at every call site (verified: one program calling
const 5 'x' and const "hi" 42 compiles and prints 5 then "hi");
(2) with no generalization, every top-level binding needs an explicit
signature to be checked at all β the next section shows what a missing
one costs.
What this checker leaves out
Checked directly against the file, not assumed from familiarity with "real" Hindley-Milner:
- No occurs check.
addUVar's only guard isi β¦ EUVar iitself; it never checks whethertcontainsEUVar inested deeper.\x -> x xunifiesx's metavariable withx's-metavariable -> Ο, a cycleaddUVarhappily records β no "infinite type" error. Run for real (cc57346ebuild,ulimit -v 2000000): a program with that lambda in an unusedletdies withmhs: out of memory,derefUVarchasing the substitution's own cycle. (Unbounded, the same run consumed tens of gigabytes of host RAM before being killed β genuine non-termination, not a hypothetical.) - No let-polymorphism for local bindings.
tcBindsgives each local binding one fresh metavariable in an empty scheme,ETypeScheme [](cc57346e:src/MicroHs/TypeCheck.hsline 708), sotInston it is thenull vscase β no freshening, ever. A local function used at two different types β textbook let-generalization's whole reason to exist β is a hard "Cannot unify" error here. - Mutual recursion is an open gap, by the code's own admission.
tcBindscarries-- XXX No mutual recursion yet(line 703). Tested atcc57346e: a single self-recursive local function works (let countdown n = if n == 0 then 0 else countdown (n - 1)runs to0), but two local functions calling each other fail β one stage after type checking, at name resolution:mhs: undefined: isOdd, fromsrc/MicroHs/Main.hs's linking pass. - No type classes.
grep -in "class"overParse.hsandTypeCheck.hsatcc57346ereturns nothing β no AST case, no dictionaries.do-notation still resolves(>>=)/(>>)by explicit module qualifier (chapter 03), not aMonadconstraint. - Kinds exist, but only just.
EKindisETypeagain, checked against the single sentinelkType = EVar "Type"(lines 228β229) β enough to reject an arity mismatch likedata Foo a = Foo (a Int), but every type variable has kindTypeand there is no* -> *-and- beyond machinery.
Worked example: const
lib/Data/Function.hs (cc57346e):
const :: forall a b . a -> b -> a
const x _ = x
Hand-inferring the way tcDefValue does β walking ELam "x" (ELam "_" (EVar "x")) against the declared a -> b -> a, with a/b in scope as
rigid names:
- Outer
ELam "x" e, expecteda -> (b -> a). A fresh metavariableΟβstands forx's type; the body is checked with no expected type (ELam's case recurses withNothing, unifying afterward), withx : Οβin the environment. - Inner
ELam "_" (EVar "x"): freshΟβfor_; the body looks upxand returnsΟβ. The inner lambda's type isΟβ -> Οβ. - The outer lambda's constructed type,
Οβ -> (Οβ -> Οβ), is unified againsta -> (b -> a):Οβ β¦ a,Οβ β¦ b, and the return-positionunify Οβ aresolves tounifyR (EVar "a") (EVar "a")β succeeds byeqIdent.
Every metavariable resolves to the signature's own a/b, no
contradiction β the body checks against forall a b . a -> b -> a,
matching the source. Against the real checker (cc57346e worktree,
-ilib on the actual lib/ tree):
$ cat > ConstDemo.hs <<'EOF'
module ConstDemo(module ConstDemo) where
import Prelude
main :: IO ()
main = do
putStrLn (showInt (const 5 'x'))
putStrLn (const "hi" 42)
EOF
$ nix develop $MICROHS -c ./bin/mhs -ilib ConstDemo
$ nix develop $MICROHS -c ./bin/eval
5
hi
const 5 'x' instantiates a β¦ Int, b β¦ Char; const "hi" 42
instantiates a β¦ [Char], b β¦ Int β two independent instantiations of
the one rigid-checked definition, exactly as tInst predicts.
Appendix β How this book was made
This book was written by an AI (Anthropic's Claude) at the direction of a
human who wanted to learn from MicroHs's history β and who, reasonably,
did not want to trust an AI's unchecked account of someone else's work.
This appendix describes the production process, because the process is
itself a reusable technique: point it at any repository with a rich
history and it produces a verified teaching book. The design spec and
implementation plan for this instance are preserved in this
repository's docs/superpowers/ directory.
The shape of the process
-
Scope from milestones. Survey
git log --reverseand find the natural arc: here, "First version" (two files, one evening) β "Make a real compiler" β self-hosting β "Now bootstraps with type checking." Chapters are thematic phases of that arc β not one commit per chapter, and not one subsystem per chapter, because chronology is where the working style lives, and the style was the point. -
Spec, then plan, then fresh eyes per chapter. A short design spec fixed the reader (Haskell-literate, compiler-newcomer), the chapter map, and the rules. A plan turned that into fourteen tasks. Each chapter was then written by a fresh AI agent that saw only its own brief: the commit range, the commits requiring depth, the global constraints verbatim, and the facts established by earlier chapters. Fresh eyes cannot lean on context they do not have β everything they claim, they must pull from the history itself.
-
Adversarial review, every chapter. A different agent reviewed each chapter against its brief, explicitly instructed to treat the writer's report as unverified claims, and given a short list of named risks to check by re-deriving them from the diffs β never by re-reading the writer's account. Findings needed file:line evidence. The writer fixed; the reviewer re-reviewed the fix. Ten of fourteen tasks needed at least one fix round.
-
A final whole-book review (on the strongest available model) checked what per-chapter gates cannot: continuity between chapters, the uhsβmhs naming timeline, the hard boundary (nothing narrated past
a38c64ce), and fresh fact samples across chapters.
The rules that mattered
Most quality machinery is obvious in hindsight β quote code byte-for-byte, run what you claim. The rules below are the ones that were learned, each from a review actually catching a failure in this book's drafts:
-
Quotes are byte-exact, or they are marked. An early draft of the overview quoted
git show --statoutput with the columns re-padded after dropping two rows. Byte-comparison caught it. The rule became: quote the real bytes, mark every elision with an explicit..., citecommit:filewith the path as it exists at that commit (a later chapter citedeval.cat a commit where the file had already moved tosrc/runtime/eval.cβ the citation didn't resolve). -
Never state an unverified mechanism as fact. One draft asserted which operand C evaluates first in
x + y. C doesn't say, and no transcript showed it; the passage was rewritten to hold in either order. Another draft described a bug fix with a plausible mechanism β right commit, wrong file, wrong kind of bug β that only fell apart when the reviewer re-derived it from the diff. Plausible-and-wrong survives checklists; it does not survive re-derivation. -
The printed text is the test artifact. Every "Try it" block was executed before publication β and that was not enough. One chapter's exercises each worked individually, but the printed sequence omitted a rebuild between them, so a reader following the text in order would diff a stale artifact. Another placed a worktree cleanup block before the exercises that still needed the worktree. The fix: a reviewer paper-walks the exercises exactly as printed, as a naive reader.
-
Commit titles under-describe; audit the diffs. A commit titled as a pure file move quietly introduced an entire new module (an early experiment in native evaluation) β with an unterminated string literal, no less. The narrative initially repeated the title's story. The rule: for every narrated commit, read the whole diff and disclose what the title doesn't say. The book's own chapters now practice this on Augustsson's history, and the reviews practiced it on the book.
-
Say what doesn't reproduce. A documented 2023 stack-overflow crash refused to fire under a modern system's default limits. The chapter says so, and shows what was observed instead. A book about honest engineering cannot smooth over its own environment's differences.
-
Verify the negative claims too. Chapter 08 checks whether the profiling flag feeds the combinator frequency ordering β the diffs show no connection, and the chapter says exactly that, having looked.
Numbers, for calibration
Roughly: 28 commits of book production; 10 of 14 tasks required a fix round; review findings ranged from byte-level (a re-padded stat column) to structural (a missing rebuild step that invalidated an exercise's premise). Every fix was itself re-reviewed. The final whole-book review found eight further issues β commands that assumed a convention two aux docs had drifted from, one range mischaracterization β and its fix was reviewed again. None of this guarantees correctness; it does mean every claim you can check has already survived one adversary checking it.
Reusing the process
The condensed, executable form of this process β the chapter-brief and
reviewer-dispatch templates, and the failure-derived rules above β ships
with this book as an agent skill file:
skills/writing-teaching-books-from-git-history/.
It is plain markdown; that directory's
README
explains what a skill file is and how to install it, and nothing in the
method is AI-specific. The spec and plan in docs/superpowers/ show the
concrete instance that produced this book.
To apply it elsewhere: find a repository whose history you want to learn from, fix an endpoint, and hold every chapter to the same bargain this book makes with you β everything quoted is real, everything claimed was run, and everything else is labeled.