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