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