Design decisions
Why Zeno is the way it is
Every choice in the language, the reason for it, and what was turned down. 166 decisions so far. Ones marked provisional may still change.
Every choice in Zeno, and the reason for it. If a choice has no good reason, it should not be here; and if a better reason turns up for the opposite choice, the choice should change.
Each entry says what was decided, why, and what was turned down. Entries marked (provisional) are expected to be revisited at the stage named.
The principles
Most decisions below fall out of five principles. When two choices are otherwise equal, the principle higher up the list wins.
- Catch the mistake as early as possible. Before the program runs is better than while it runs; while it runs is better than a wrong answer. Zeno should never quietly do something the programmer did not mean.
- Read like plain words. Code is read far more often than it is written. A whole word beats a symbol or an abbreviation.
- Punctuation has to earn its place. A mark stays only if removing it would make code harder to read or easier to get wrong.
- One way to say a thing, with one meaning. An operator or keyword should not change meaning depending on the values it is given.
- Nothing that a microcontroller cannot afford. Every feature must be able to compile to small, predictable C with no hidden runtime.
Where Python already has a spelling that passes these tests, Zeno uses it, because more people can read Python than any other language.
0. What kind of language Zeno is
0.1 Zeno is both object-oriented and functional
What that means. The two are not opposites; they answer different questions. Object-oriented style is about where code lives: data and the functions that work on it are bundled into a class. Functional style is about how data flows: functions are values that can be passed around, and results are built from new values instead of by changing old ones.
Zeno has the pieces for both:
| Object-oriented | Functional |
|---|---|
class with fields and methods |
functions are values: store them, pass them, return them |
| objects that hold state | function(n) { ... } written in place, with no name |
self |
closures: a function remembers the variables around it |
let is the default, so values do not change unless you say var |
|
enums with data, taken apart by match |
|
map, filter, reduce, and comprehensions |
Why. Different problems have different natural shapes. A device driver
is an object: it has state (a register, a mode) and operations on it. A
signal-processing chain is functional: readings go in, get filtered and
scaled, and a result comes out. A language meant to cover every use must not
force one shape onto both. Python, Rust, Kotlin and Swift all made the same
choice. examples/styles.zeno shows one job done
each way.
The limits, stated plainly. - Zeno is not a pure functional language like Haskell, where functions are forbidden from having effects. Writing to a hardware register is an effect, and firmware is made of them. - Classes have no inheritance yet (provisional, see 3.7).
0.2 Types are optional
Every type annotation can be left out: on variables, parameters, return values, class fields and enum cases. The same program runs with or without them.
function area(w: float, h: float) -> float { return w * h } # typed
function area(w, h) { return w * h } # untyped
Why. Types pay for themselves in large programs and in firmware, where a mistake found on the bench costs hours. They get in the way of a ten-line script or a quick experiment. With optional types, one language covers both, and a program can start loose and be tightened a line at a time as it grows, instead of being rewritten in another language. (This is called gradual typing; TypeScript and Python's type hints took the same road.)
How it works.
1. Where a type is written, it is checked before the program runs.
2. Where it is left out, the checker works it out from the value when it
can. let n = 3 is an int without being told.
3. Where it cannot be worked out, the value is dynamic: it carries its type
with it and is checked while the program runs.
The cost, stated plainly. A dynamic value needs a tag saying what it is,
and usually memory from the heap. A small microcontroller cannot afford
either. (Principle 5.) So there is a strict mode (--strict), in which
step 3 is an error: everything must be known before the program runs. Strict
mode will be the default when compiling for a microcontroller, and optional
elsewhere.
What gets worked out. Everything that can be: variables, return types
(of recursive functions too), and also the parameters and class fields that
have no type written. A parameter is worked out from the calls: if every call
to add(a, b) passes two ints, a and b are ints, and the body is checked
with that knowledge. A field is worked out from every value stored in it.
function add(a, b) { return a + b }
let total: str = add(1, 2) # error: `total` should be str, but this is int
Why. The first version of the checker left untyped parameters dynamic.
That made strict mode demand a type on every parameter, so the "same program,
typed or untyped" promise held for scripts and broke for firmware. Working
them out keeps the promise: examples/styles.zeno, written with no types at
all, now passes strict mode and can be compiled without a heap.
The rules, and the reason for each.
- Only what is certain is used. If two calls disagree (id(1) and
id("s")), the parameter stays dynamic; the checker never picks a winner.
An int and none do combine, to int or none, because that is one real type.
- A written type always wins. It is a promise to the reader; the calls are
checked against it and never change it.
- A function that goes where its type is not tracked (stored in a list[any],
passed to an untyped parameter that is itself dynamic) may be called from
there with anything, so its parameters are not worked out from the calls
that happen to be visible.
- A function that is never called has nothing to go on, and stays dynamic.
The cost, stated plainly. There are two. 1. When a parameter's type was worked out, a wrong call is reported inside the function, where the value is misused, not at the call. To keep the real mistake findable, the error carries a note naming the line the type came from:
error: int has no field or method `upper`
--> main.zeno:2:12
|
2 | return a.upper()
| ^
= note: `a` was worked out to be int from line 5. Give `a` a type to
have each call checked against it instead
The checker cannot know which of the two lines is wrong, so it shows both. Writing the type settles it: then the call is the error. This is the reason annotations are still worth writing on anything other people call. 2. Adding a call can change what a parameter is worked out to be: a second call with a different type turns it dynamic again, which strict mode then reports at the function. The message says the type could not be worked out from how it is used.
Alternatives rejected. Leave parameters dynamic (the first version):
simple, but strict mode then forces annotations everywhere. Make an untyped
function generic, checked afresh for each call as C++ templates are: more
precise (id(1) and id("s") would both be known), but one function then
becomes many in the compiled program, which costs flash on a microcontroller
without the programmer having asked for it. That is what generics are for,
and there the programmer does ask: function id[T](x: T) -> T (3.16).
How it is done. The checker makes several passes over the program. Each pass notes what arrives at every untyped parameter and field, and the next pass starts from those answers. It stops when a pass learns nothing new. If that does not happen within eight passes, everything worked out is thrown away and treated as dynamic: unknown is always safe, a wrong guess is not.
Strict mode is chosen by the file or by the command. A file whose first
line is the word strict is checked in strict mode, however it is run.
--strict on the command line does the same from outside.
Why a line in the file. Whether a file may hold dynamic values is a fact
about that file: firmware must not, a script may. A flag lives in a build
script, where it can be forgotten; a first line travels with the code and is
seen by every reader. It is a bare word, with no # or @, because it is
part of the language and not a comment or a decoration. (Principle 3.) It
must be the first line so that nothing above it is checked under different
rules. There is no way to switch strict mode off from inside a file that is
run with --strict: the stricter of the two always wins.
1. Syntax
1.1 Blocks use { }, not indentation
Why. With braces, where a block ends is written down rather than implied. A stray space or a tab-versus-spaces mix can never change what a program does, code survives being pasted into a chat or an email, and editors can re-indent it automatically. (Principle 1.)
Turned down. Python-style indentation is cleaner to look at, but it makes whitespace meaningful, which is exactly the kind of invisible mistake principle 1 is against. It also makes the lexer harder.
1.2 No semicolons; a line break ends a statement
Why. The line break is already there and already means "next statement" to the reader. A semicolon on every line repeats that information. (Principle 3.) Go, Swift and Kotlin work the same way.
The cost, and how it is handled. A long statement must be able to
continue on the next line. It does in two cases that are impossible to
misread: inside ( ) or [ ], and after a token that cannot end a statement
(+, ,, =, and, ...). The rule is "an unfinished line continues".
1.3 No : after if / for / function, no ( ) around conditions
Why. The { already marks where the condition ends, so both are
decoration. (Principle 3.)
1.4 Keywords are whole words: function, not fn or def
Why. function can be read aloud by someone who has never seen the
language. fn and def have to be learnt. The extra characters cost nothing
with editor completion. (Principle 2.)
1.5 and, or, not instead of &&, ||, !
Why. They are words (principle 2), and ! in particular is easy to
miss: if !ready and if ready differ by one thin character. It also frees
& and | for bit operations later, which firmware code needs, without the
& versus && confusion C has.
1.6 let and var to declare; plain = only updates
let x = 1 creates a value that never changes, var x = 1 one that can, and
x = 2 changes an existing var.
Why. In Python, x = 1 both creates and updates, so a misspelt name
(coutn = 5) silently creates a new variable. Separating the two turns that
into an error. (Principle 1.) Making the unchanging form the short, default
one nudges code toward values that do not change, which are easier to reason
about and let the compiler put data in flash instead of RAM. (Principle 5.)
Turned down. let / let mut (Rust): mut is an abbreviation.
const / let (JavaScript): const is kept for true compile-time constants.
1.7 const for values fixed when the program is built
Why. A const must be computable by the compiler, so it can live in
read-only memory and be used for things like array sizes. let is "set once
while running", which is a different promise. (Principle 5.)
1.8 else if, no elif
Why. With braces, } else if x { already reads naturally and needs no
keyword of its own. (Principle 4.) Typing elif gives a hint pointing to it.
1.9 match uses case Pattern { ... }, with no =>
Why. Every other block in the language is keyword something { }. An
arrow would be a second way to introduce a block. (Principles 3 and 4.)
1.10 # for comments
Why. One character, and it leaves // free for floor division (4.2).
1.11 Types go after the name: port: int, -> int
Why. The name is what you scan for, so it comes first; and because types
can usually be inferred, the optional part should be the trailing part. The
: and -> earn their place by separating a name from its type at a glance.
Optional. A type can always be left out; see 0.2.
1.11a The type of a function is written function(int) -> int
function twice(f: function(int) -> int, x: int) -> int { return f(f(x)) }
Why. It is the heading of a function with the names and body taken out,
so there is nothing new to learn: the same keyword, the same ( ), the same
->. (Principle 2.) Leaving -> off means the function returns nothing.
A function fits the type if it can be called with those arguments and what it
returns fits; an untyped anonymous function passed there has its parameters
worked out from the type.
Alternative rejected. (int) -> int, as in TypeScript and Swift: shorter,
but it gives ( ) a second meaning in types and reads as punctuation where
Zeno prefers a word. (Principle 3.)
1.12 true, false, none in lower case
Why. They are keywords like any other, and every other keyword is lower case. Python's capitals are a historical accident. (Principle 4.)
1.13 Absence is written T or none, and tested with is none
Why. There is no null that can turn up anywhere. A value can be none
only if its type says so, and then it must be checked before use.
(Principle 1.) str or none reads as it means. (Principle 2.)
is and is not work only with none. In Python, is compares
identity, which gives surprising answers on numbers and strings and is a
well-known trap. Restricting it leaves one way to compare values (==) and
one way to test for absence. (Principle 4.)
Provisional. General unions (int or str) are not accepted. They overlap
with enums, and they need a tag stored beside the value in the compiled
code. To be decided at the type checker.
1.14 Formatted strings need the f prefix
Why. Without the prefix, any string containing { would change meaning,
which is common in firmware (JSON, format templates). The single f marks
the strings that contain code. (Principle 1.)
1.15 Numbers can carry units: 500 ms, 3.3 V
Why. Mixing up units is a real source of firmware bugs: milliseconds
passed where microseconds were expected, or volts added to millivolts. If
the unit is part of the value, that is an error instead of a wrong reading.
(Principle 1.) sleep(500 ms) also documents itself. (Principle 2.)
A unit is also a type. function wait(d: ms) takes a quantity in
milliseconds; passing 3 s or a bare 3 is an error before the program
runs. The unit's own name is the type, so there is no second vocabulary such
as Duration to learn.
Units come from a fixed list (s ms us ns min h, Hz kHz MHz, V mV uV
kV, A mA uA, ohm kohm Mohm, W mW kW, J Wh kWh, degC K, m mm cm
km, g mg kg, deg rad, rpm). Anything else, such as 5 sec, is an
error. Why. If any word after a number were a unit, a typing mistake
would silently create a new one, which is the very bug units are there to
prevent. (Principle 1.)
Units that measure the same thing work together. 1 s + 500 ms is
1500 ms, 1 s == 1000 ms is true, and 1 h / 1 min is 60.0. The answer
is always in the finer of the two units.
Why the finer unit. Going from seconds to milliseconds multiplies by a
whole number, which loses nothing. Going the other way divides, and 1 ms
is not exactly representable as a number of seconds. So the direction that
is always exact is the one chosen without being asked.
Changing unit is written .ms. (3 s).ms is 3000 ms; delay.s
gives seconds. It reads as "delay, in seconds", needs no new keyword, and
cannot clash with a variable called s or m. Passing 3 s where ms is
wanted is still an error, and the message says to write .ms.
Why not convert automatically at a call. It was tried on paper and
rejected: the interpreter does not look at type annotations (5.6), so it
would print 3 s where the compiled program, which knows the parameter is
ms, would print 3000 ms. One program, two outputs. An explicit .ms
means the same thing everywhere. (Principle 1, and 5.5d.)
What does not convert. degC and K differ by an offset, not a scale,
so adding them means nothing. deg and rad have no exact factor between
them. Both pairs are kept apart on purpose.
Provisional. No derived units yet (V * A is not W), and no
user-defined units.
1.16 Comparisons chain: 0 <= i < len
Why. It is how the condition is written in mathematics, and it means what
it looks like: 0 <= i and i < len, with i worked out once. (Principle 2.)
Turned down. C's meaning, (0 <= i) < len, compares a true/false with a
number and is a classic bug. Refusing chains altogether (Rust) is safe but
loses the readable form. An earlier version of Zeno did refuse them, while
this was being decided.
1.17 Errors use try / except / raise, and are declared with raises
Why. The words are Python's and are widely known. (Principle 2.) The
addition is raises ValueError in a function's signature, which lets the
compiler check that every error is handled or passed on deliberately.
(Principle 1.)
They are not exceptions in the C++ sense: there are no unwinding tables and
no cost on code that does not use try. How they compile is in 5.5i.
(Principle 5.)
An object of any class can be raised, as well as the built-in kinds:
class Timeout { after: ms }
raise Timeout(30 ms)
Why. An error often needs to carry more than a message: how long was waited, which pin, what code the device returned. A class already says "these named values, together", so no second way to declare an error is needed. (Principle 4.)
except Timeout as t gives the object, with its fields. except Error as e
asks for any error at all, so it always gives an error value, whatever was
raised: for an object, e.kind is the class's name and e.message is the
object as text. Raising e again passes the object on untouched.
How raises is checked. It follows the same rule as every other
annotation (0.2). If a function writes raises, the list must be complete,
and the checker reports anything missing. If it writes nothing, the list is
worked out from the body and passed on to callers. In strict mode it must
always be written, so that every signature is a complete contract.
What counts. Only errors that are raised on purpose are tracked: a
raise statement, a call to a function that raises, and turning text into a
number with int() or float(), which fails on bad input. Errors that an
operator can produce, such as dividing by zero, indexing out of range or
overflow, can still be caught, but do not have to be declared. Those can
happen on almost any line, so declaring them would put raises on every
function, and a label that is on everything says nothing. Java's checked
exceptions are widely disliked for exactly this.
2. Numbers and operators
2.1 / always gives a float; // divides and rounds down
Why. In C, 7 / 2 is 3 and 7.0 / 2 is 3.5: the same operator does
two different things depending on its operands, and silently dropping the
remainder is a classic bug. In Zeno the operator decides the meaning, not
the values. (Principle 4.) When you want whole-number division you say so
with //.
// rounds toward minus infinity and % takes the sign of the right-hand
side, so that (a // b) * b + a % b == a always holds and i % n is a valid
index for any i, negative or not.
2.2 Whole-number arithmetic is checked for overflow
Why. In C, overflow either wraps silently or is undefined behaviour. A
wrong number that looks plausible is the worst kind of bug. Zeno raises
OverflowError instead. (Principle 1.)
There is one int, of 64 bits. The sized types (u8, i32, ...) and
wrapping on purpose, which firmware needs for counters and checksums, are
in 2.8.
2.3 An int and a float can be mixed; nothing else converts by itself
1 + 0.5 is 1.5. But "3" + 4 is an error, and so is if count { }.
Why. Widening an int to a float matches what people expect from
arithmetic. Every other automatic conversion guesses at what the programmer
meant, and JavaScript shows where that leads ("3" + 4 is "34").
(Principle 1.)
But not at a boundary. let x: float = 1 is an error; write 1.0. Mixing
inside arithmetic produces a new value, so nothing is disguised. Storing an
int in a float variable would leave a value whose written type and real type
differ, and in compiled code it would hide a conversion instruction.
(Principles 1 and 5.) Kotlin, Swift and Rust draw the same line.
2.3b Outside arithmetic, an int is never quietly made a float
1 + 2.5 mixes the two (2.3). But [1, 2.5] is not a list[float], and a
function that returns 1 on one path and 2.5 on another does not return
float. Both are "a mix", which strict mode asks you to settle by writing
1.0 or float(n).
Why. The first checker did treat these as float. Compiling to C showed
what that costs: the compiled function really would return 1.0, while the
interpreter, which never looks at types, returns the int 1, and prints
1. The same program would print different things under zeno run and
zeno build. Converting only when asked keeps one answer everywhere, and it
matches the rule that was already there for let x: float = 1. (Principle 1.)
2.3a A whole number to a negative power is an error
2 ** 8 is an int. 2 ** -1 would have to be 0.5, a float. Then the type
of a ** b would depend on the value of b, which the checker cannot see.
Zeno refuses the negative power and says to write 2.0 ** -1.
(Principle 4: the operator decides the type, not the values.)
2.4 Conditions must be real bools
if items { } is an error; write if len(items) > 0 { }.
Why. "Truthiness" means remembering which values count as false: is it
0, "", [], none, 0.0? The rules differ between languages and cause
bugs such as treating a valid reading of 0 as "no reading". Saying what you
are testing is clearer. (Principles 1 and 2.)
2.5 == compares contents
Two lists with the same items are equal, and so are two objects of the same class with equal fields.
Why. That is what "equal" means to a reader. Comparing by identity
(Java's == on objects) is a famous source of confusion. (Principle 2.)
2.5a Comparing things that can never be equal is an error
1 == "1" is always false, so writing it is a mistake, most often a number
read as text and never converted. The checker reports it instead of quietly
answering false. (Principle 1.) An int and a float can be compared, because
they can hold the same number.
2.6 Positions start at 0, and negative positions are an error
Why for 0. It matches C, Python, and the memory layout of every target.
Why no negatives. In Python, items[-1] is the last item. That is handy,
but it means an index that goes negative by mistake quietly reads from the
wrong end instead of failing. For code that runs on hardware, a clear
IndexError is the safer behaviour. (Principle 1.)
2.7 Bit operators are symbols: &, |, ^, ~, <<, >>
They work on whole numbers only, with &=, |=, ^=, <<= and >>=
beside them. An int is 64 bits in two's complement, so ~x is -x - 1
and -1 & 0xFF is 255.
Why symbols, when 1.5 chose words. Principle 2 says a word beats a
symbol, and principle 3 lets a mark stay when it earns its place. These
do. status & MASK is how every datasheet, every C header and every
Python script writes it, and a firmware engineer reads it faster than
any word. The words tried were bit_and(status, MASK), which buries a
register expression in brackets, and infix band / bor / shl,
which nobody has seen before. 1.5 kept & and | free for exactly
this.
The C slip is caught. && and || are still lexer errors with a
hint (and, or). true & false is a checker error that says the
same: the operators take whole numbers, never bools. So & never means
"and", which is the confusion 1.5 was about.
2.7a Their precedence is Python's, not C's
From loosest: or, and, not, comparisons, |, ^, &, shifts,
+ -, * / // %, unary - and ~, **.
Why. In C, flags & MASK == 0 means flags & (MASK == 0), which is
almost never what was meant and compiles without a word. C's own
authors called it a mistake that was too late to fix. Python put the
bit operators above the comparisons, so the line means what it looks
like. Zeno copies Python, as it does wherever Python's spelling passes
the principles. (Principle 1.)
Cost. Someone who knows C's table by heart has one row to unlearn. The row they unlearn is the one that bites.
2.7b A shift is checked
a << n is a times two, n times, and like * (2.2) it raises
OverflowError when bits would be lost. a >> n rounds down, so
-5 >> 1 is -3, the same as -5 // 2. A count outside 0 to 63 is a
ValueError.
Why. In C a shift by 64 or by a negative count is undefined, and
1 << 40 on a 32-bit int is a wrong number. Each of the three is a
quiet wrong answer. (Principle 1.) Checking costs a comparison or two.
Turned down. Python's shift, which never overflows because its
ints grow: Zeno's do not (2.2). Java's, which uses only the low six
bits of the count, so 1 << 64 is 1: that is the quiet wrong answer
again. To throw the top bits away on purpose, say so: u32.wrap(x <<
4) (2.8b).
2.8 Sized whole numbers: u8, u16, u32, i8, i16, i32
let level: u8 = 200. Six types that say a whole number fits in
fewer bits than an int. They are for where the size is real: a field
of an object, a byte in a frame, a register.
Why. A protocol says "one byte", and a struct on a microcontroller
with 2 KB of memory cannot spend eight bytes on a flag. With the size
in the type, the checker knows that a u8 field is never handed 300,
and the compiler can store it in one byte (5.13a). (Principles 1 and
5.)
Why these names. They are Rust's, and close to C's uint8_t with
the noise taken off. byte and short say less: how long is a
short?
2.8a Arithmetic on sized numbers gives an int
a + b on two u8s is an int. So are a << 8, ~a and -a. To
put the answer back in a u8, say how: u8(a + b) or u8.wrap(a + b).
Why. There are three other choices, and each one hides something.
- Wrap at the size, as C does after promotion and Rust does in a
release build:
200 + 100is44, quietly. That is the bug 2.2 exists to stop. - Check at the size, as Rust does in a debug build:
(a + b) // 2fails for twou8s whose average fits perfectly well. The size of the storage has leaked into the middle of a sum. - Refuse to mix sizes, as Rust does:
length + offsetneeds a conversion on every line, and people stop reading them.
With the sum done on ints, the middle of an expression cannot overflow at 255, and the one place where a number may not fit is the place it is stored, where the program has to say what it wants. This is C's integer promotion, kept, without the silent cut at the end. (Principles 1 and 4.)
Cost. count += 1 on a u8 does not compile. The message gives
the fix: count = u8(count + 1), or u8.wrap(count + 1) for a counter
that is meant to go round.
2.8b u8(n) checks, u8.wrap(n) cuts
u8(n) is n if it fits and an OverflowError if not. u8.wrap(n)
keeps the low eight bits and never fails: u8.wrap(300) is 44, and
i8.wrap(200) is -56.
Why two. Both are needed, and they must not look alike. A length
that does not fit in its field is a bug. A checksum is meant to lose
its top bits. In C both are written (uint8_t)x, and the reader has
to guess which was intended. Here the short spelling is the safe one,
and wrapping is a word in the program that a reviewer can search for.
(Principles 1 and 2.)
Turned down. Wrapping operators such as +% (Zig) or methods such
as a.wrapping_add(b) (Rust): one more of each for every operator.
One wrap after ordinary arithmetic does the same job, because the
low bits of a sum do not depend on when the top ones are dropped.
In a firmware build, a u8( ) that does not fit stops the program,
like any overflow (5.5l).
2.8c A written number adapts; a sized number is an int for free
let b: u8 = 7,f(7),[1, 2, 255]where alist[u8]is wanted: a number written out takes the size of where it goes. The checker sees whether it fits, andlet b: u8 = 300is an error before the program runs.- A
u8goes wherever anintis wanted, with nothing written. Everyu8is anint. - An
intdoes not go where au8is wanted, and au8does not go where au16is wanted. Each needsu8( )oru16( ).
Why. The first is the only bearable way to write a constant, and it is checked at no cost when the program runs. The second cannot lose anything, so asking for it would be noise. The third can lose something, so it is written. (Principles 1 and 3.) It is the same line 2.3 draws between an int and a float, drawn for the same reason.
A u8 to a u16 could be allowed, since it always fits. It is
not, yet: the rule "sized to int is free, everything else is
written" has no table to remember. (Provisional.)
A list[u8] is not a list[int]. Lists are shared (3.10), so
whoever held it as a list[int] could append 300, and the first
holder would find a u8 that is not one. Copy it: [int(n) for n in
bytes].
2.8d No u64 (provisional)
Why. An int is 64 bits with a sign, so half of a u64's values
do not fit in one, and 2.8a ("the sum is an int") would stop being
true. Hashes and 64-bit registers will want it. It waits for one of
them, and for an answer to what u64 + u64 is.
3. Variables, functions and data
3.1 Each { } block is its own scope
Why. A variable lives exactly as long as the block it is declared in, so
its lifetime is visible in the layout of the code. In Python a variable made
inside an if leaks into the rest of the function.
3.2 A name cannot be declared twice in the same block
Why. It is almost always a mistake or a copy-paste slip. (Principle 1.) An inner block may reuse an outer name, since the braces make the boundary clear.
3.3 Parameters and loop variables cannot be reassigned
Why. They behave like let. A parameter that still holds what the caller
passed, all the way down the function, is easier to follow.
3.4 Functions, classes and enums can be used above where they are written
Why. The alternative is C's forward declarations, or ordering a file to suit the compiler rather than the reader. The usual way to read a program is the main logic first, helpers below.
3.5 Default arguments are worked out on every call
Why. In Python, def add(item, to=[]) creates the list once, and every
call shares it. It is one of the most-reported surprises in the language.
(Principle 1.)
3.6 Named arguments: Point(x=3.0, y=4.0)
Why. set_timer(500, true, false) cannot be read without looking up the
function. Names at the call site fix that. (Principle 2.)
3.6a A function with no name is written function(n) { ... }
let double = function(n) { return n * 2 }
readings.map(function(r) { return r * 1.8 + 32 })
Why. It is exactly a function declaration with the name left out, so there is nothing new to learn and only one way to write a function. (Principle 4.)
Turned down. Python's lambda n: n * 2 is a second, more limited syntax
for the same thing (it cannot hold statements). JavaScript's n => n * 2
needs the => that 1.9 removed.
Provisional. This is wordy for one-line functions. A shorter form may be added once there is enough real Zeno code to see how often it is needed; comprehensions already cover the most common cases.
3.6b map, filter and reduce are methods on a list
readings.filter(valid).map(to_celsius).reduce(add, 0.0)
Why. As methods they read left to right, in the order things happen:
take the readings, keep the valid ones, convert them, add them up. As plain
functions (Python's reduce(add, map(to_celsius, filter(valid, readings))))
the same chain has to be read inside out. (Principle 2.)
3.6c A function written inside another keeps the variables it can see
function counter() {
var count = 0
return function() {
count += 1
return count
}
}
The inner function uses count, and goes on using it after counter has
returned. It keeps the variable itself, not a copy: a change made by
either function is seen by the other.
Why. This is what the code says when read: there is one count. A copy
would make the example above return 1 every time, silently. (Principle 2.)
It keeps the variables that were in sight where it is written. A variable declared further down the same block is not one of them, even if it has the same name as an outer variable the function uses:
let y = 1
function g() {
let f = function() { return y } # the y above
let y = 2
return f() # 1, not 2
}
Why. The meaning of a name should be settled by where it is written, not by what happens to be declared later. This is the rule in nearly every modern language (it is called lexical scope), and it is what lets the compiler decide, once, which variables a function needs to take with it.
An earlier version of this decision made an exception for the top level
of a file, where a function could use a variable declared below it. The
checker never allowed that, so no program that zeno run accepted used
it, and it is gone (3.6d).
3.6d A function with a name follows the same rule
A named function can be called from above the line it is written on (3.4). What it can see is still decided by that line: every function, class and enum of the blocks around it, and the variables declared above it. Never a variable declared below it.
let x = 1
function outer() {
function inner() { return x } # the x above
let x = 5
return inner() # 1, not 5
}
Why. The reasons in 3.6c do not depend on whether the function has a
name. And the checker already read programs this way, so anything else
means zeno check and zeno run disagree about what a program does.
They did disagree, until the bytecode VM was written (5.10). The
interpreter made a named function at the top of its block and gave it
the block's scope, which goes on growing, so the function above returned
5. At the top of a file a let len = 5 below a function that calls len
broke the function. The VM decides what a name means when it compiles
the function, could not reproduce either, and the comparison between the
two engines failed. The interpreter was wrong, and so was zeno build
for the top-level case. Both are fixed.
A name means one variable. If a block declares x above a function,
x inside that function is that variable, from the start. A call that
comes before the line declaring x has run stops with "x is not
defined". It does not quietly use an x from further out, which would
make the same name mean two variables in two calls of one function.
JavaScript has the same rule for let.
The checker refuses it first. zeno check reports a function used
above a variable it needs: "x is declared further down than the first
use of inner, which needs it". It can do this because it checks a
function's body at the first place the function is used, so it knows
which of the variables above the function exist at that moment. The same
goes for building an object above a variable that a field's default
uses. At first the checker missed the case where there is
an x further out: it took the name for that one and passed a program
that both engines then stopped.
The check is cautious in one way. A function written as a value,
function() { return inner() }, is checked where it is written, so if
that is above x it is refused even when the value is only called
later. Telling the two apart needs to know when a function value runs,
which the checker does not track. Naming a function above the variable
(let f = inner) and calling it below is accepted.
3.7 A class lists its fields, and gets a constructor for free
Why. The fields are the shape of the data, and the shape should be written down in one place, not discovered by reading assignments inside an initialiser. A fixed list of fields is also what lets a class compile to a plain C struct. (Principle 5.) Setting a field that was not declared is an error.
Provisional. There is no inheritance and no custom constructor yet. Both are to be decided when classes are type checked.
3.8 Methods name self explicitly
Why. It shows that a method is an ordinary function that receives its object as the first argument, with no hidden variable. (Principle 4.)
3.9 let fixes the name, not the contents (provisional)
let xs = [1] followed by xs.append(2) is allowed; xs = [3] is not.
Why. It is the simplest rule to state: the name always refers to the same thing. Deeply unchangeable data was left open until memory was managed. The collector (5.7) does not need it, so it is still open, and now purely a question of what helps the programmer.
3.10 Lists and objects are shared, not copied
let b = a makes b the same list as a.
Why. It is what Python, Java and JavaScript programmers expect, and it is the cheapest thing for the interpreter. This was provisional until the memory management stage, which had to choose between sharing, copying and Rust-style ownership for compiled programs. It chose to keep sharing, with a garbage collector to make it safe. 5.7 has the reasons.
3.10a A for loop sees the list as it was when the loop began
Adding to a list, or removing from it, inside a loop over that list does not change what the loop visits.
Why. The other choice, looping over the list as it changes, is the source of two classic bugs: a loop that never ends because its body keeps appending, and a loop that skips an item because its body removed one. (Principle 1.) The cost: the loop takes a copy of the list first. (Provisional: the copy can be skipped when the compiler can see that the body does not touch the list.) Since 5.8 the copy is given back the moment the loop ends, because only the loop ever pointed at it (5.8j).
3.11 Enums carry data, and match takes them apart
Why. "A reading is either a value or a fault code" is one of the most
common shapes in real programs, and most languages make it awkward. An enum
whose cases hold their own data says it directly, and match guarantees
every case is considered.
The checker reports a match on an enum that leaves a case out, naming the
missing cases. This is the main reason to use an enum instead of a set of
integer constants: when a new case is added, every match that needs
updating becomes an error, instead of silently falling through. A match on
anything else (numbers, strings) needs a final case _, since those cannot
be listed in full.
3.12 A value that may be none must be tested first
let user = find_user(2) # str or none
print(user.upper()) # error
if user is not none {
print(user.upper()) # fine
}
Why. Using a null value is the most common crash in C, Java and JavaScript; its inventor called it his "billion-dollar mistake". Zeno makes it impossible to forget the test. (Principle 1.)
The checker follows the test itself rather than asking for special syntax:
after x is not none, or after if x is none { return }, it knows x is
present. The programmer writes the if they would have written anyway.
Turned down. Rust's match / if let Some(x) and Swift's if let x = x
need a new construct and a second name for the same thing.
A new value changes what is known. x = 5 makes an int or none
variable an int from that line on, and x = maybe() makes it uncertain
again. After if name is none { name = "anon" } the name is a str, since
it is one on both paths. A loop body forgets, before it starts, what was
known about the variables it assigns, since the body runs more than once.
Why. Without this, the two most ordinary uses of none do not check:
walking a linked list (while node is not none { ...; node = node.next })
and filling in a default. The programmer would have to copy the value into
a second variable only to satisfy the checker. TypeScript and Kotlin follow
assignments the same way. (Principles 1 and 3.)
Turned down. Following the value through every path exactly (a full
data-flow analysis, joining at every loop until nothing changes). The three
rules above are a page of code, cover the patterns people write, and when
they are unsure they forget, which is the safe direction: the worst case is
an extra if.
One gap, closed at run time. The check is trusted for as long as nothing
changes the variable. For a local variable the checker can see that. For a
top-level var, or a var shared with an inner function (3.6c), a function
that is called between the check and the use could set it back to none.
The checker does not follow that, so the running program looks at such a
variable once more where it is used, and stops with an error if the value
has gone. Variables that nothing else can reach, which is most of them, are
not looked at twice. (Provisional: Kotlin refuses to narrow such variables
at all, which is stricter and needs no check at run time. That may turn out
to be the better rule.)
3.13 A function that promises a value must return on every path
Why. In C, falling off the end of a non-void function returns garbage.
In Python it returns None, which then fails somewhere else. Here it is an
error at the function. (Principle 1.)
A function whose type says it may return none (-> int or none) is the
exception: reaching its end returns none, since that is one of the
answers it promised.
3.14 upper, lower, strip and split know plain letters and blanks only (provisional)
"straße".upper() gives "STRAßE": the letters a to z change, and nothing
else does. strip, split() with no separator, int() and float() skip
the space, the tab, the two line endings and the form feed, and no other
blank.
Why. Doing this for every alphabet needs tables of several kilobytes,
and a compiled Zeno program has to fit on a microcontroller, where that
can be most of the flash. The same program must also give the same answer
under zeno run and when built (5.5d), so the interpreter follows the
same rule even though a PC has room for the tables. (Principle 5.)
Text in any language still passes through untouched: len, indexing and
loops count whole characters, not bytes.
Provisional. A separate module can offer the full rules to programs
that have the room. Rust makes the same split, with to_ascii_uppercase
beside to_uppercase.
3.15 A pin is a kind of value of its own, and a number underneath
from board import led gives a Pin. It has on, off, toggle,
is_on and .number. It is not an int: led + 1 and led == 13 do
not compile. pin(7) makes one from a number and raises a ValueError
if the board has no such pin.
Why. Hardware is where a mixed-up number does real damage, so pins
get the treatment units got (1.15): the checker keeps them apart from
plain numbers, and after the checker they cost nothing. A pin is its
number in every compiled program, so blink needs no memory from a
collector, and builds with --no-collector. (Principles 1 and 5.)
Turned down. A class Pin written in Zeno, with the level in a
field: every stage already has objects, but an object needs the
collector, and switching an LED must not. A plain int with functions
on(13): free, and on(count) would compile.
Pin is a type name the language knows, as it knows int. A class of
the program's own called Pin is found first, so no existing program
changes meaning.
3.15a sleep takes a time with a unit, and now gives one
sleep(500 ms), sleep(2 s). sleep(500) and sleep(3 V) are
refused by the checker. now() is a quantity in ms.
Why. Whether a bare 500 is seconds or milliseconds is the question
that every delay function in every C library answers differently. The
unit answers it in the program text. (Principle 1.) Refusing 3 V at
check time and not at run time is new in this stage: before it, the
interpreter found it when it got there.
now() counts from a fixed moment that the program cannot know (on a
microcontroller, power-on). Why not the time of day: a
microcontroller does not know it, and code that measures how long
something took must not jump when a clock is set. Only the difference
of two readings is promised to mean anything.
3.15b On a computer the board is simulated, and prints a pin when it changes
zeno run examples/blink.zeno prints led on, led off, and so on.
A pin that is written with the level it already has prints nothing.
Why. A program for a board should be runnable, and testable, without the board. Printing only changes is what an instrument on the pin would show, and it keeps the output of a program that sets a pin in a loop readable. Cost: the simulated board's output is part of the program's output, so a program that also prints mixes the two. That is the point for a test, and a real board prints nothing.
The LED is pin 13 on every board, as far as a Zeno program can tell. A real board wires that number to wherever its LED is. Why 13: it is the Arduino Uno's, the number most people have already met. (Provisional: one LED and numbered pins is the least a board can be. Named pins, inputs and the rest wait for a real board to ask.)
3.16 Generics: function first[T](...), class Stack[T]
A function or a class can leave a type open and name it:
function first[T](items: list[T]) -> T {
return items[0]
}
class Stack[T] {
items: list[T]
}
Stack[int] is then a type, and first([1, 2]) is known to be an int.
Why have them. A ring buffer of bytes and one of voltages are the same code. Without generics it is written twice, or written once with the types left out, and then nothing after it is checked. A language that checks types before the program runs needs a way to say "the same type that went in". (Principle 1.)
Why square brackets. Zeno already writes "a type applied to a
type" as list[int]. Stack[int] is the same sentence, so there is
one rule and no new punctuation. The angle brackets of C++, Java and
Rust would make a < b > c a puzzle for the parser and the reader.
Go chose square brackets for that reason, and Python writes
list[int] too. (Principle 3: punctuation has to earn its place.)
Why after the name. function first[T](...) reads "first, for any
T". A keyword in front, as in C++'s template<typename T>, is one
more line for the same information.
3.16a A type parameter starts with a capital letter, and its name wins inside
function first[t] is an error. Inside class Pair[A, B], A is the
type parameter and not amperes.
Why the capital. int, str, list, ms and u8 are all lower
case or units. A capital says "this is a name for a type" without the
reader looking back at the header.
Why it wins. Zeno has dozens of units, and several are the single
capital letters people reach for first: A, V, K, W, J. The
first version refused a type parameter with a unit's name, and
Pair[A, B] and Map[K, V] would not compile. A name declared right
there, in the header of the function, is the nearest declaration, and
the nearest declaration winning is the rule everywhere else (3.1).
A quantity is still written with its unit, 3.3 V, inside such a
generic: that is a number followed by a unit, and not a type.
3.16b The types are worked out at each call, and never written there
There is no first[int](xs). The checker finds T:
- from the arguments;
- from an argument that is a function, by calling it with what the
others gave: in
convert(bytes, function(b) { ... }), what the function returns isU; - from the place the result goes, for what is still unknown:
let s: Stack[int] = Stack(items=[]).
If none of the three says, it is an error that shows how to write the third.
Why. Zeno works types out wherever it can (0.2), and a call is
where it can do it best. The cases where the arguments say nothing are
a constructor with an empty list and a function with no parameters,
and both have a result that is going somewhere with a type on it.
Writing the type on the variable is already how let xs: list[int] =
[] works. A second syntax for the same thing can wait until a
program needs it. (Principle 3, and "keep what works".)
Why arguments first. When the two disagree, the mistake is
reported against the type that was written: let x: str = first(ns)
with ns a list[int] says "x should be str, but this is int",
which is where the eye already is.
3.16c A written number waits for the others to speak
ring(4, 0) is a Ring[int]. let bytes: Ring[u8] = ring(4, 0) is a
Ring[u8]. With small a u8, same(small, 9) is a u8, and
same(small, 300) is an error.
Why. It is 2.8c again. A number written in the program can be an
int or a u8 and is checked while compiling, so it should not be
the thing that decides. Without this rule every generic used for bytes
would need u8(0) where 0 is meant, and sized numbers would be the
one corner of the language where generics are awkward.
3.16d No bounds: the body is checked for each type it is used with (provisional)
largest[T] uses >. Nothing in its header says T must have a >.
The checker checks the body once for each type the program uses it
with: for ints and for text it passes, and for a class with no > it
is an error, reported inside largest with a note naming the copy and
the line that asked for it.
Why. A bound needs something to name: T: Ordered needs
Ordered, and that is interfaces (or traits), a feature Zeno does not
have yet and should design on its own terms. Checking each use is what
C++ did for thirty years, and it finds every mistake before the
program runs. That is the promise that matters. (Principle 1.)
Cost. The error is reported where the > is and not at the call,
and a generic that nothing uses is only checked loosely (5.14d). C++
error messages show how badly this can go. The note under the error
is the guard against that, and bounds are the real fix.
3.16e What generics leave out (provisional)
Generic enums, methods with type parameters of their own, a generic
kept as a value (let f = first), an owned or borrowed value as a
type argument, and raising an object of a generic class. Each is
refused with a message that says so.
Why. Each needs a decision that deserves a use case first. An enum
is T or none in its commonest form, which exists. A generic value
would have to be every copy at once, or be turned into one copy by the
place it goes. An owned type argument means the container frees what
it holds, which is a rule about ownership and not about generics.
raises and except take a plain name. None of them is needed for a
ring buffer, a stack or a largest, which are what this stage was
for. ("We don't have to over-engineer things.")
4. Errors
4.1 Bugs and errors are different things
An error is something that can go wrong in a correct program: bad input,
a division by a value that turned out to be zero, an index from outside.
These can be caught with try / except.
A bug is a mistake in the program itself: a misspelt name, "abc" - 1,
calling something that is not a function. These stop the program and cannot
be caught.
Why. Catching a bug hides it. And the type checker reports almost every bug in the second group before the program runs, so in checked code there is nothing to catch. (Principle 1.)
A firmware build draws the line in a different place: errors that the runtime finds, such as a division by zero, stop the program there too. 5.5l says why.
4.2 Error messages say what to do
x declared with let and then assigned gives "x cannot be changed.
Declare it with var to allow that". Habits from other languages (;, &&,
def, elif, if x:) each get a message naming the Zeno spelling.
Why. An error message is the language teaching its user. The moment something goes wrong is when the explanation is most useful. (Principle 2.)
4.3 The lexer and the checker report every error; the parser stops at the first
Why. The rule is: report an error only if it is certainly real. After a bad character the lexer can skip it and carry on, so all its errors are real. After a type error the checker still knows exactly where it is, and it marks the faulty expression so that nothing built on it is reported again. After a syntax error the parser no longer knows where it is, and what follows is usually nonsense caused by the first. One accurate error beats ten misleading ones.
4.4 zeno run checks first, and does not run a program with errors
Why. A mistake found before the program starts costs seconds. The same mistake found halfway through a run, with a motor already spinning, costs more. Untyped code is not held back by this, because the checker only reports what it is certain of. (Principle 1.)
5. How it is built
5.1 The compiler is written in Rust
Why. A compiler is mostly "look at a node, decide by its kind". Rust's
enums and match fit that exactly, and the compiler forces every kind to be
handled. It also produces one fast binary with no runtime to install.
5.2 No libraries: the lexer and parser are written by hand
Why. The project exists to learn how languages work. A parser generator would hide exactly the part worth understanding. Hand-written code also gives full control over error messages (4.2), and the build has zero dependencies.
5.3 The parser is recursive descent
Why. It is the technique where the code looks like the grammar: one function per rule. It is the easiest to read, debug and extend, and it is what most production compilers (GCC, Clang, Rust, Go) use.
5.4 An interpreter first, then a compiler
Why. An interpreter is the fastest way to find out whether the language
design works, because programs can be run from the first week. Design
mistakes are far cheaper to fix before a code generator depends on them. It
stays useful afterwards as zeno run and a future interactive prompt.
5.5 The compiler outputs C before anything else
Why. Every microcontroller vendor ships a C compiler, so C output runs on every target from day one, works with existing debuggers, and can be read to see what the compiler did. Writing an optimiser can wait.
5.5a Compiling to C comes before the bytecode VM
The first roadmap had a bytecode VM next. C was moved ahead of it.
Why. A VM makes zeno run faster on a PC. C output is what puts Zeno on
a microcontroller, which is the reason the language exists. C also forces
every question about types to be answered now, while the checker is fresh:
it has already changed one rule (2.3b).
5.5b zeno build keeps the C file and calls the machine's C compiler
zeno build hello.zeno writes hello.c, then runs cc (or whatever CC
names) to make hello.
Why. The C is the compiler's work, shown: reading it is how you learn what the compiler did, and how you debug it when it is wrong. It is one file, with the runtime pasted in, so it can be copied into any vendor's project or IDE with nothing else to carry along.
5.5c zeno build always checks in strict mode
Why. A C variable must have one type. A value whose type is only known when the program runs would need a tag and usually heap memory (0.2), which the compiler does not have yet. Because parameters and fields are now worked out, most programs with no types written still pass. (Provisional: a dynamic value type can be added to the runtime later for PC targets.)
5.5d Compiled code must behave exactly like interpreted code
Same output, same errors, same line numbers. The tests compare the two byte for byte.
Why. Two implementations that disagree are two languages. The
interpreter is the definition of what a program means; the compiler is only
allowed to be faster. This is what makes it safe to develop with zeno run
and ship with zeno build. It has three direct consequences:
- Whole-number arithmetic is checked in compiled code too, at the cost of a
test after each operation. (Principle 1. An unchecked mode for hot loops
can be offered later, asked for by name.)
- The compiler pins down left-to-right order with temporaries wherever C
would leave it open. See docs/compiler.md.
- Where they cannot agree (recursion depth), the difference is written down.
When the two disagreed while the compiler was being written, and the compiled answer was the one that followed the rules of the language, it was the interpreter that was corrected. 3.6c and 3.14 came about that way.
5.5e The checked arithmetic uses GCC and Clang built-ins
Why. __builtin_add_overflow compiles to the processor's own overflow
flag: one instruction. Checking by hand in plain C is longer, slower and
easy to get wrong. GCC and Clang cover nearly every microcontroller
toolchain in use (ARM, RISC-V, AVR, ESP). (Provisional: a plain-C fallback
can be added for other compilers.)
5.5f Compiled strings were not freed at first
Until the memory management stage, memory for text built at run time was taken and never returned. Replaced by 5.7.
5.5g In C, objects, enum values and lists are pointers
Why. Zeno says objects and lists are shared, not copied (3.10): after
let b = a, changing b changes a. A C struct is copied on assignment; a
pointer to one is not. So the pointer is the faithful translation. The cost
is that each one needs memory from the heap, which the collector (5.7)
gives back. (Provisional in one respect: an object that never leaves the
function that made it could live on the stack and cost the collector
nothing. Finding those is an optimisation for later.)
5.5h One list type in C, not one per kind of item
A compiled list holds zeno_slots, a union the size of one machine word,
whatever its items are.
Why. The alternative is a full copy of the list code for every kind of
item (list_int, list_float, list_Point, ...), which is what C++ and
Rust do. That is faster by a little and bigger by a lot, and flash is the
scarce thing on a microcontroller. Every Zeno value already fits in one
slot, so one list implementation serves them all. (Principle 5.)
5.5i try compiles to C's setjmp and longjmp
A try remembers a place with setjmp. raise, and every error the
runtime finds itself, jumps back to the innermost such place with
longjmp. docs/compiler.md walks through the C.
This replaces the earlier plan, which was to compile a function that can raise into one that returns "a value or an error", with a test after each call.
Why the plan changed. That plan assumed errors come only from raise.
But 4.1 lets a program catch a division by zero, a bad index or an
overflow, and 1.17 says those need not be declared, because they can
happen on almost any line. With errors as return values, "almost any line"
means a test after almost every operation, in every function, in case some
caller far above is inside a try. That is the hidden cost the plan was
meant to avoid. With setjmp, code that is not inside a try pays
nothing at all, a try in which nothing goes wrong pays for one setjmp
(saving a dozen or so registers), and the compiled code for ordinary
arithmetic stays as simple as it was. (Principle 5.)
It is not C++ exception handling: there are no tables describing every
function, and nothing is added to code that does not use try.
What it costs.
- Objects made between the
tryand theraiseare not tidied up by the jump. This turned out to cost nothing: the collector (5.7) finds them unreachable and frees them, and notryhas to remember anything. It was one of the reasons for choosing a collector. - A local variable changed inside a
trymust be declaredvolatilein C, or its value after the jump is not guaranteed. The compiler finds those variables and does it. They are a little slower to use. - Some coding standards for safety-critical C, MISRA C among them, forbid
setjmpandlongjmpoutright. A project held to such a standard could not use the generated C as it is.
For that last reason there is a second build mode that uses checked returns, for projects that need it: 5.5l.
5.5j T or none in C: NULL for pointers, a flag for numbers
A str, list, object, enum value, error or function that may be none is
the same C pointer it always was, with NULL meaning none. A number or
bool that may be none is a small struct: the value and a present flag.
Why. For pointers this is free: no extra memory and no extra code,
because C already has a spare value. Numbers have none to spare (every bit
pattern of an int64_t is a real number), so the flag is the least that
will do. Picking a "magic" number such as -1 to mean none is exactly the
C habit this type exists to end. (Principles 1 and 5.)
The checker has already proved that the value is tested before it is used (3.12), so the compiled code reads it directly, with no test of its own.
Inside a list, where every item gets one machine word (5.5h), a number that
may be none is stored as a pointer to a copy of the number. That costs an
allocation per item, which is acceptable for a rare case and keeps every
other list at one word per item.
5.5k A function value is a pointer to its code and its variables
Every function held as a value is a pointer to a struct that starts with
the same two fields (the C function to run, and a name for print),
followed by whatever outside variables that function uses. It is always
called the same way: the struct itself is passed as a hidden first
argument.
Why one shape for all. xs.map(f) cannot know whether f is a
top-level function, or one made inside another with three variables in
tow. With one shape and one way of calling, it does not need to.
(Principle 4.) The price is an indirect call, and a small wrapper for
each top-level function that is used as a value.
Variables that never change are copied in; a var that is shared lives
in a box on the heap that both functions point to. Copying is cheaper
and is safe when nothing changes. Only a variable that can change needs
the box, and only when an inner function mentions it, so ordinary local
variables are unaffected. (3.6c is the rule this implements.)
Each function value is made fresh on the heap, even one that uses no
outside variables and could have been a single fixed value. Two function
values are equal only when they are the very same one, and with fixed
values a function written in a loop would be equal to itself from the last
trip round, which is not what zeno run says. (5.5d. Provisional: once
memory is managed, the fixed value can come back for functions that are
never compared.)
Turned down. Compiling a separate copy of map for every function
passed to it, as C++ and Rust do, makes the fastest code and the most of
it. Flash is the scarce thing here (see 5.5h).
Not done yet. A named function declared inside another function is
visible from the top of its block, before the variables it uses exist.
That needs the box treatment for every variable of the block, so for now
zeno build refuses it and asks for let name = function(...) { ... }.
5.5l A firmware build carries errors as values, with no setjmp
zeno build --firmware compiles errors without setjmp and longjmp.
docs/firmware.md walks through the C.
- A function that says
raisesreturnstrueorfalse, and gives its answer through a pointer the caller passes in. The caller tests what came back, and passes a failure on. raisenotes the error in one variable of the runtime and returnsfalse.try/exceptbecomes anif: a failure inside the body goes forward to it.- A function that cannot raise keeps a plain return and no tests. The
checker already knows which is which, from
raises(1.17). - The default build keeps
setjmp(5.5i). The same source compiles both ways, and every compiler test runs both againstzeno run.
Why. MISRA C:2012 Rule 21.4 bans setjmp and longjmp: the jump skips
whatever the functions in between meant to do on the way out, it can leave
local variables that are not volatile with no defined value, and tools
that prove how deep the stack gets, or that every path is covered, cannot
follow it. Firmware that must be certified cannot ship the C that 5.5i
produces. Zeno is meant for that firmware (principle 5), so it needs a
mode that can.
Why not make it the only way. 5.5i still holds: on a PC, and on
firmware with no such standard to meet, setjmp costs nothing where no
try is running, and the generated C is shorter.
Why one switch called --firmware, and not a setting for errors
alone. The planned build with no collector (5.9) answers to the same
standards and the same people. One switch that means "C a firmware review
will accept" is easier to remember than several that must be combined.
Why true / false plus one noted error, and not the error itself as
the return value. The error has to be somewhere the collector can see it
while it travels (5.7), and it needs the line it was raised on for the
message when nobody catches it. One variable in the runtime does both, and
the return value stays the simplest thing a reviewer can check: a bool
that every caller tests.
Why the answer goes out through a pointer, and not in a struct beside the status. A struct per return type is one more type for every function shape, and C compilers return small structs less well than they pass a pointer.
Why a goto. Inside a try, a failure has to leave the body from
wherever it is, including from inside loops. The alternative with no
goto is a flag tested around every statement that follows, which is
longer, slower and harder to read. The goto used only goes forward, to a
label in a block around it: MISRA's required Rules 15.2 and 15.3 allow
that form. Its advisory Rule 15.1 prefers none at all, and a project may
have to record a deviation for it.
Errors the runtime finds are faults in this mode. A division by zero,
a bad index, an overflow or pop on an empty list stops the program and
no except sees it. This was the open question when the stage was
planned, and it is settled this way because the other answer makes every
function one that can fail, with a test after nearly every line: the cost
5.5i was written to avoid, paid everywhere. Rust (panic beside Result)
and Swift (a trap beside throws) draw the same line. It changes 4.1 for
this mode only. So that it never comes as a surprise (principle 1), a
try whose except could only ever be waiting for a fault is refused
when the program is built, with a message that says to test first.
One difference that is not refused. except with no kind, around a
body that raises something as a value and can also fault: an ordinary
build catches both, a firmware build catches the first and stops at the
second. Refusing every except with no kind would be too strict, so this
is documented and pinned by a test instead.
Provisional: a function that can raise cannot be used as a value in a
firmware build. The two sorts of function are called differently, and the
type function(str) -> int does not say which sort it is. The fix is to
let function types say raises, which the checker needs anyway: today it
does not follow errors through function values in any build.
What this does not make true. The C of a firmware build is not thereby
MISRA-compliant. Still to do, each its own piece of work: the heap (Rule
21.3; answered for --no-collector builds by the pool, 5.9h), variable
argument lists in the runtime (17.1),
printf and exit (21.6, 21.8), the GCC and Clang builtins (1.2), and the
forward goto above (15.1, advisory).
Memory needs nothing special in either mode. The collector frees what an error leaves behind whichever way the error travels (5.7).
5.6 The checker and the interpreter are separate
Why. One stage, one job. The checker decides whether a program is sound; the interpreter runs it and never looks at a type annotation. That is what lets untyped programs run (0.2), and it means the checker can be made stricter or smarter without touching how programs execute.
The interpreter still tests types as it goes, because dynamic values need it. A program that passes the checker should never fail one of those tests on typed code; the test suite checks this by running every working interpreter test through the checker as well.
5.7 Memory: a mark-and-sweep garbage collector, in the runtime
A compiled program frees memory by itself. Now and then the runtime stops
the program, marks every block of memory that can still be reached from a
variable, and frees the rest. docs/memory.md walks through the C.
Why a collector at all. Zeno says lists and objects are shared (3.10), function values remember variables (3.6c), and errors jump out of functions (5.5i). With those three in the language, "when is this last used?" has no answer the compiler can work out in general. Something has to find out while the program runs.
Why this kind. There were three real candidates.
- Ownership, as in Rust. No cost at run time and no pauses. But it is a
different language:
let b = awould move or copy, not share, and the checker would need a borrow checker, which is the largest and hardest part of Rust. It would undo 3.10 and make the simplest programs harder to write. Turned down. - Counting references, as in Swift and CPython. Each block counts how
many places point at it and is freed when the count reaches zero. Memory
comes back at once and there are no pauses. But the compiler must add
code at every place a pointer is copied or dropped, including every early
return,breakand temporary; two objects that point at each other are never freed without extra help from the programmer; and alongjmp(5.5i) skips the code that lowers the counts, so everytrywould need a list of what to release. That is a great deal of generated code and many places to get wrong. Turned down for now. - Mark and sweep. The compiler adds almost nothing: one generated function that lists the top-level variables. Cycles are freed like anything else. An error needs no special care. The whole collector is about 200 lines in one place, small enough to read in one sitting, which matters in a compiler that is built to be learned from. Chosen.
Why it reads the stack instead of being told. A collector that knows exactly which variables are pointers needs the compiler to keep a second, shadow stack of them, or a table for every point in every function. Both are far more machinery than the collector itself. Reading every word of the stack and asking "is this one of my blocks?" needs none, and is what MicroPython does on the same class of microcontroller.
What it costs, stated plainly.
- It bends principle 5. A collector is exactly the "hidden runtime" that principle warns about: work the program did not write, done at a moment it did not choose. What keeps it within bounds is that a program which makes no blocks never runs it, and the first provisional point below.
- Pauses, in proportion to the memory in use. Not suitable inside a loop with a hard deadline. Code that uses only numbers, bools and fixed text never allocates and so never pauses.
- It depends on how C compilers behave, not on what the C standard promises. Reading the stack this way is outside the standard. It is the same bet Boehm's collector, Ruby and MicroPython make.
- It can keep too much. A leftover value in a stack slot, or a number
that looks like an address, holds a block that is really garbage. This
wastes memory and breaks nothing. (Seen in testing: built without
optimisation, one stale slot kept a 300,000-node list alive. With
-O2, which is whatzeno builduses, it was freed.) - MISRA bans
mallocandfreeas well (Rule 21.3), collector or not. See the first point below.
Provisional, in three respects.
- Certified firmware usually allows no heap at all. The answer for it is not a different collector but a build that proves a program makes no blocks, and says which line does if it cannot. The checker already knows where blocks are made. This is planned as 5.9.
- The blocks come from the C library's
malloc. On a real board the collector should own one fixed region and hand out pieces of it itself. - If pauses turn out to matter in practice, counting references can be added underneath without changing the language, since nothing in the source depends on which method is used.
5.8 Owned values and borrows, beside the collector
Values stay shared and collected by default (3.10, 5.7). A value can
instead be owned. docs/ownership.md is the walkthrough.
- It has exactly one owner. Storing it somewhere else moves it: the old name can no longer be used.
- It is freed at the end of its owner's block, at a line the programmer can point to, without a collection.
- It can be borrowed for the length of a call: the function may use it and must not keep it. A borrow cannot outlive the call it was passed to.
- An owned value may hold shared values. A shared value may not hold an owned one. Otherwise a shared value could still point at an owned one after its owner had freed it.
Why. 5.7 lists what the collector costs: pauses, and memory that comes back at no particular moment. For most code that is a good trade. For a buffer in an interrupt handler or a control loop it is not. This lets the programmer pay for precision only where it matters, and write everything else the easy way. (Principles 1 and 5.)
Why opt-in, and not ownership everywhere. 5.7 turned that down: it is a different, harder language. Opt-in keeps the first program a beginner writes free of it.
Precedent. D collects by default and has @nogc and scope. Nim
collects by default and has moves (sink) and borrows (lent). Swift
counts references by default and has added consuming, borrowing and
values that cannot be copied. Mojo has owned and borrowed arguments.
All four arrived at "automatic by default, exact where asked for".
What was settled when it was built, each with its reason:
5.8a The words are owned and borrow, and only three places take them
owned class Name, owned list[T], and borrow in front of a parameter's
type. They are whole words (principle 3), and they are not reserved: they
mean something only in those positions, so no existing program breaks.
Turned down: &x and &mut x at each use, as in Rust. Punctuation, and
it puts a mark at every call where one in the signature says the same.
Turned down: a mark at the call (send(move p)). It would make each
move visible where it happens, which is worth something, but the checker
already reports any use after a move by line, and the calls stay plain.
Provisional: if moves prove easy to miss when reading, this is the first
thing to revisit.
5.8b A class is owned as a whole, where it is declared
Not let p: owned Packet at one use and a shared Packet at another.
Why. If the same class could be owned here and shared there, a method
could not know whether self may be kept, and an owned object could be
handed to code that shares it. One word on the class settles it for every
use, and it is the first thing a reader of the class sees.
Lists are the exception, because there is only one list type: there the
word goes on the type, owned list[int].
5.8c A parameter takes the value unless it says borrow
function send(p: Packet) takes p over. function look(p: borrow Packet)
only looks.
Why. A parameter is a variable. p: Packet on a let means "this
variable holds a Packet", and a variable that holds an owned value owns it.
The parameter means the same. The exception is the one that gets a word.
A method's self is always a borrow, since calling a method on an object
that then vanished would surprise everyone.
Turned down: borrow by default, with a word for taking (Mojo, Swift). It
makes the common case shorter. But then p: Packet would mean two
different things on a let and on a parameter. Provisional.
5.8d The borrow checker is four rules, and stops there
- A borrow exists only as a parameter. It cannot be stored, returned, or passed to something that keeps it.
- One statement cannot both use an owner and give it away.
- An owner declared outside a loop cannot be given away inside it, except on a path that leaves the loop.
- An owned variable can be used only by the function whose block holds it. Not by another function, not even when it is at the top level of the file, and not by a function written inside this one.
Why so few. Rule 1 is what makes lifetimes unnecessary: a borrow that only lives for a call needs no name for how long it lives. Rule 4 is what makes freeing at the end of the block safe: nothing outside ever knew where the value was. Rules 2 and 3 close the two ways a value could be used after the same piece of code gave it away. Together they are about 200 lines of the checker.
What it costs. Programs that would be safe are refused: a function
cannot return one of two borrowed lists, and f(p.size, p) must be two
lines. Rust accepts these and needs lifetimes, two kinds of borrow and
flow analysis over a second form of the program to do it. That is the part
of Rust most often named as hard, and Zeno keeps the collector for the
cases that need the freedom. ("Start with the minimal borrow checker and
grow it only if needed.")
5.8e A place that forgets about ownership gets an error, not a second owner
Reading an owner gives a type, Borrowed, that fits nowhere. Only the
places that are known to merely look take it off, and only the places that
are known to keep a value mark the owner empty.
Why. The checker has dozens of places where a value goes somewhere, and more will be added. The two possible mistakes are not equally bad. A place wrongly treated as keeping gives the programmer a confusing error. A place wrongly treated as looking gives the compiled program two owners, and it frees the value twice. So the default is the first.
5.8f In C, giving a value away sets the variable to NULL
And every free does nothing when handed NULL. The end of a block calls the free for each owner declared in it, moved or not.
Why. The compiler then needs no knowledge, at the end of a block, of
which owners still hold something: the pointer is the answer. if branches
that move on one side and not the other need nothing special.
Turned down: working out at build time exactly which owners are live at each exit, and emitting only those frees. The checker has that knowledge for errors, but passing it to the compiler exactly, for every exit of every block, is far more machinery, and the C compiler removes a free of a variable it can see is NULL.
5.8g An owned value is still one of the collector's blocks
zeno_free takes the block out of the collector's table at once. Until
then the block is in the table like any other.
Why. Two reasons. The collector must look inside an owned value while it lives, since it may hold the only pointer to a shared one, and a block in the table is looked into with no extra code. And if a free is skipped, the collector frees the block later, so a skipped free costs time and never correctness.
Turned down: taking owned memory straight from malloc, outside the
table. The collector would then need a list of every live owned value to
look inside, kept up to date at every move.
A block finds its own line in the table (it keeps its index in the field the collector's waiting list uses, since the two are never needed at once), so freeing costs the same however many blocks there are.
5.8h Errors: an ordinary build frees what it can, a firmware build frees everything
In an ordinary build an error is a longjmp (5.5i). The function that
raises frees its owners first. The functions the jump passes through free
nothing, and what they owned waits for the collector (5.8g). An owner in a
function with a try is declared volatile, because C does not promise
that a local changed since setjmp keeps its new value after the jump,
and an owner that forgot it had given its value away would free it twice.
In a firmware build an error is a return value (5.5l), and each failed call frees the block's owners before it passes the failure on.
Why not make the ordinary build exact too. It would need what 5.7
turned down for counting references: every try keeping a list of what to
release. The collector is already there for it.
5.8i What is refused for now (provisional)
- An owned value inside ~~a list,~~ an
or none, an enum case or a function type, and moving a field out of an object. Each needs a rule for what is left behind in the hole. (A list that has an owner can hold owned values since 5.15c:pop()takes one out and leaves no hole.) - Replacing an owned field through a borrow, including
self. Somebody else may have been lent the old value at that moment. Telling "may change" borrows from "may only read" borrows would lift this, and is the first growth to consider (5.8d). - A shared list becoming an owned one. Only a list that was just made
can start life owned (one written out, a comprehension, or what
split,map,filteror+gives, since 5.9s): those are the only lists certain to have no other name. matchon an owned value, raising one, and a method of one kept as a value.
Each is an error with a message that says what to do instead. Allowing any of them later breaks no program that works now.
One refusal that was on this list is gone. print(look(make())) used
to be "this Packet has no owner yet. Give it a name with let first".
It is accepted since 5.9r: the statement owns the value.
5.8k xs += ys on an owned list is xs = xs + ys
The checker refused += where the left side was an owned list: +
was asked to add an owned list[int] to a list[int]. It now looks at
the left side as any other reader does, and the list that + makes
takes the old one's place, which the owner frees.
Why. x += y means x = x + y for everything else in Zeno, and
xs = xs + ys was already accepted for an owned list. One of the two
being refused was a surprise with no reason behind it. (Principle:
unambiguous and intuitive.)
What it does not do. It does not grow the list in place. + makes
a new list in every build, and xs.append(y) is the way to add one
item without that. Through a borrow (self.items += more in a method)
it is still refused, by 5.8i: replacing an owned field needs the owner.
5.8j No escape analysis; two frees that are always safe instead
The plan mentioned freeing shared values that never leave the function that made them. That was not built: working out "never leaves" for a shared value means following it through every call it is passed to.
Two cases need no analysis at all, and were built: a list that grows frees
its old storage, and the copy a for loop walks (3.10a) is freed after
the loop. Only the list ever pointed at either. Every program gets these,
with or without the word owned.
5.9 A firmware build with no collector
zeno build --no-collector writes C with no garbage collector in it.
Every list and object in the program must be owned (5.8). Whatever else
would take memory that no line gives back is refused when the program is
built, with the line that does it. docs/no-collector.md is the
walkthrough.
Why. A collector pauses the program for a time nobody can promise,
finds its roots by reading the C stack (which no C standard describes),
and frees a block at a moment nobody can name. Firmware with a deadline
or a memory budget cannot always accept those. With 5.5l (no setjmp)
this is the build such a project can start from. (Principle 5: this is
where "no hidden runtime" comes closest to being true without an
exception.)
Why after 5.8. Without owned values, "no collector" would mean "no lists and no objects", which is too little to write real firmware in.
The heap. At first this build still took owned values from the
heap, and MISRA C bans malloc and free (Rule 21.3). It now takes
them from a pool of a fixed size, and the heap is not used: 5.9b and
5.9h to 5.9k.
5.9a --no-collector is a firmware build
The flag turns --firmware on.
Why. In an ordinary build an error jumps (5.5i), and the jump skips the lines that free owned values in the functions it passes through. The collector picks those up (5.8h). With no collector they would leak, and the only fix is the one a firmware build already has: an error that travels as a return value passes every one of those lines.
Turned down: a separate, fourth kind of build with jumps and no collector. It could not be made sound without unwinding tables, which is C++ exceptions by another name.
5.9b Owned values came from calloc and free at first; now from a pool
The first no-collector build used calloc and free. Why then. It
was the least that worked, and the compiler's part of the job, a free
on every path, is the same whatever sits underneath. A fixed pool was
turned down "for now", as a change to runtime.c that touches nothing in
the compiler.
That is how it went. The pool (5.9h) replaced the two functions, and not a line of either compiler changed.
5.9c Built text lives for one statement, in a room of fixed size
Text built while the program runs (f"...", str(n), a + b,
.upper()) is written into one fixed array and given back when the
statement that built it is over.
Why text needs an answer at all. Firmware prints. A build that
refused print(f"temperature {t}") would be of no use.
Why one statement. It is the shortest life that still lets text be built and used, and it has an end the compiler can see without following the text anywhere. Giving it back is setting one number, so there is nothing to search and nothing to fragment. Calls nest inside statements, so the room behaves as a stack, and its size is the answer to "how much text can be in the making at once", which the report measures.
Text that has to last longer is copied out of the room (5.9l). At first it could not be kept at all: not in a variable, a field or a list, not returned, not passed to a function. That rule was cheap, one flag on a compiled value, and it was too tight: a program could not build a line and keep it for later.
Turned down: printing piece by piece with no text at all. It only
helps print, and if name.upper() == "ON" still needs somewhere to put
"ON".
5.9d The except that catches an error frees it
An error is made only by raise. The except block that catches it
frees it when the block ends, however the block is left. Inside the
block the error can be read. It cannot be kept, passed to a function or
raised again.
Why. An error has to outlive the function that raised it, so no
block's end in that function can own it. The one place every caught
error is certain to reach is its except, and after that block nothing
can refer to it, as long as it was not kept. So the block's end is a line
that can free it. An error nobody catches stops the program, and needs
no freeing.
Why the message is copied into the error. The message is often built
text, which lasts one statement (5.9c). The error keeps its own copy, in
the same block, so one free gives back both.
The cost. raise e to pass a caught error on is refused. Raise a new
one. (Provisional: passing it on could be a move.)
Since 5.15h the checker says this, and the error may be lent to a
borrow parameter.
5.9e The compiler refuses, not the checker
(Taken back in part by 5.15a: who owns what is the checker's question now. What cannot be stored yet is still refused here.)
What needs a collector is found by emit_c.rs, at the spot where each
kind of value is made.
Why. Whether a program needs a collector depends on the build, and
the checker checks the language, which is the same in every build. The
same program is right for zeno run and wrong for --no-collector. It
also keeps the list honest: a refusal sits beside the line that would
have written the allocation, so a new way to allocate cannot be added
without meeting it. The checker helps in one place, where it already
knew the answer: which lists start life owned (Types::fresh).
5.9f A value between two owners is held
A list being filled by a comprehension, an owned value on its way into a
call, and the copy a for loop walks (3.10a) each belong to nobody for a
moment. If the block is left early in that moment, the value is freed
with what the block's owners hold.
Why. 5.8 left these three to the collector. Here there is none, so each needed a line. One mechanism covers all three: the compiler lists the value among the block's owners until it arrives. The first and third apply to every build, so every build is a little tidier for it.
5.9g What a build with no collector refuses (provisional)
(The first two entries are gone since 5.15: a plain list and a plain class have an owner now. The list as it stands is in 5.15j.)
A list kept by something that is not an owner (a plain variable,
parameter or field, or another list); objects of a class that is not an
owned class; enum cases that carry values; functions written as
values; and lists of numbers that may be none.
Keeping built text was on this list until 5.9l. split, map,
filter and + on lists, for over the characters of a str, and a
list written where it is only looked at were on it until 5.9r. The
rest are being taken off it one step at a time, under one rule:
whoever receives a value owns it.
Why refuse and not support. Each could be given an owner or held for a moment, and each would be more machinery. None was needed to build the example or the tests, so none was built. (Principle: keep what works.) The error for each names the line, and where there is one, the thing to write instead.
5.9h The pool: fixed sizes that double, and a list of spares for each
In a build with no collector, lists, objects and errors come from one
static array of ZENO_POOL bytes. Blocks come in sizes of 16, 32, 64
bytes and so on. A request gets the smallest size it fits. A block
given back goes on a list of spare blocks of its size, and the next
request of that size takes it. When there is no spare one, a new block
is cut from the part of the array that has never been used. A block is
never cut in two and two are never joined. docs/pool.md is the
walkthrough.
Why a pool at all. MISRA C:2012 Rule 21.3 bans malloc, calloc,
realloc and free. A small board may have no heap. And the two
things firmware cannot plan for with a heap are how long a request
takes and whether the heap has broken into pieces too small to use.
Why these sizes and no joining. Three properties follow, and each can be said in a sentence.
- Taking and giving are a few instructions, the same however full the pool is. Nothing is searched.
- The pool cannot break up. A block of a size the program has given back is there, whole, for the next request of that size. So a piece of work that fitted once fits every time after.
- How much pool a run needed is one number,
zeno_pool_top, and a pool of exactly that size is enough.
Cost. Memory cut for one size never serves another. A list that
grows by doubling leaves each old place behind as a spare block, so it
costs about twice its final size. A request is rounded up to the next
size. buffer.zeno takes 25,384 bytes of pool for 13,336 bytes in use
at once. (principle: firmware would sooner pay a known cost than risk
an unknown one.)
Turned down: a free list with joining (the allocator in K&R, and
most small mallocs). It wastes less. It searches, and it can break
up, which are the two things the pool is there to remove.
Turned down: a buddy allocator, which cuts a block in two halves and joins them again when both are spare. It keeps the doubling sizes and gets back the memory a growing list leaves behind. It is about three times the code, and giving back is then a loop of joins. Worth another look if a real program is short of RAM because of growing lists. (provisional)
Turned down: TLSF, the usual answer when a real-time system must have a general allocator. It is constant-time and wastes little, and is some hundreds of lines. Too much machinery for what is asked of it here.
Turned down: one pool for each type, each with a count chosen by
the programmer (pool Frame[8]). That is the strictest design and the
one a certifying project may want, because the compiler could then say
when it builds that the program cannot run out. It needs new syntax,
and a list has no one size. It can be added beside this pool later.
Turned down: one block size for everything. A list of a thousand items would not fit, or every object would take a thousand items' room.
The sizes start at 16 bytes. A spare block keeps the address of the next spare one in its own first bytes, so it must hold a pointer, and 16 keeps every block lined up for a 64-bit number.
A new block is wiped whole, not only the bytes asked for. The compilers rely on a new list or object being all zeros, and wiping the whole block means a spare block never shows the next owner what the last one left.
5.9i Eight bytes in front of each block say its size and how much was asked for
zeno_free is handed an address and nothing else. In front of the
block are two 32-bit numbers: step (the block is 16 << step bytes)
and asked (how many of them the program asked for, and 0 while the
block is spare).
Why not have the caller pass the size. The compilers know the size of an object, but not of a list's items or an error's message when they free them. A size passed in is also one more thing that can be wrong.
Why asked. Two uses for four bytes that alignment would have
left empty anyway. The memory report needs it to count bytes in use,
so a build with --memory lays memory out exactly as a build without
it does, and the numbers it reports are true of the program that
ships. (The heap version added two words to each block only when
reporting.) And 0 means "spare", which is how a block given back twice
is caught.
Why catch that, when the compiler writes one free for each block. Because of what it would do: the block would be handed to two owners, and the fault would show up somewhere else, later. The check is one comparison. (principle: a fault stops at the line that caused it.)
Cost: eight bytes a block, which is half as much again for the smallest.
5.9j A full pool stops the program, and the size is chosen with -DZENO_POOL
When a request does not fit, the program prints error: the memory
pool is full (ZENO_POOL is N bytes) and stops, as it does when a
statement overflows the text room (5.9c).
Why stop, and not raise a MemoryError. In a firmware build what
the runtime finds for itself, such as an index past the end, is a
fault that stops the program (5.5l), and running out of memory is one
of those. An error is also itself a block from the pool.
The size is a C macro, 32768 unless given. Why a macro. The
text room is chosen the same way (5.9c), and one build of the C can be
compiled for boards of different sizes. Why 32768. It is the
smallest round number in which every example runs. On a board it is
meant to be changed, and --memory prints the number to change it to.
(provisional: zeno build --pool N is an exercise in docs/pool.md.)
The report says pool: N bytes taken, which is how much of the
array was ever cut into blocks. Why that number, and not the most
bytes in use at once (which the line above it already gives). It is
the one that answers "how big must the pool be", and a test checks
that it is exact: the program runs in a pool of N bytes and not in
N - 1.
5.9k The heap is gone from a no-collector build, not switched off
There is no flag that puts calloc and free back. The runtime of a
build with no collector does not contain the words, comments included,
and the tests search the C, the IR and the runtime file for them.
Why. The same reason the collector's code is left out and not
switched off (5.9): a reviewer who searches a firmware build for
malloc must find nothing, and must not have to work out which
#ifdef is live.
Both backends get it for nothing. The pool is in the runtime, and the LLVM backend links the same runtime (5.11d). The IR did not change. The two builds must still write the same memory report (5.11t), which now includes the pool.
Cost: a heap checker cannot see inside the pool. Valgrind and
AddressSanitizer watch malloc and free. To them the pool is one
array that is always in use, so a block read after it was given back
is no longer reported. What stands in for them: the compilers null an
owner when it gives its value away, the report must end with 0 blocks
left, the two backends must agree on every number, and a block given
back twice is caught by the pool itself. Poisoning spare blocks in a
checking build is an exercise in docs/pool.md. (provisional)
What this does not make true. MISRA Directive 4.12 is against dynamic memory of any kind, and a pool is still that. The pool removes the directive's stated reasons one by one, and a project would still record a deviation.
5.9l With no collector, text is kept by copy
Whatever stores text has a copy of its own, in the pool, and frees it
when it is done: a variable at the end of its block, a field with its
object, a list item with its list. let a = b leaves both usable.
Text written in the program is never copied. docs/text.md is the
walkthrough.
Why a copy, when a list is moved. A list can be changed after it
is made, so two names for one list can tell that they share it. That
is what moving and borrowing are for (5.8). Text cannot be changed
after it is made, so no program can tell a copy from the original.
Copying therefore needs no rule at all: nothing new is refused, the
checker is not changed by a line, and a program means under
--no-collector exactly what it means under zeno run. (Principles:
unambiguous and intuitive; one meaning in every build.)
The cost is the copying: a strlen, a block from the pool and a
memcpy for each keep, and no sharing, so ten variables with the same
built line hold ten copies. Each cost is at a line that can be pointed
to and takes a time that depends only on that text's length, which is
what firmware asks of memory (5.9).
Turned down: moving text, as Rust moves a String. let a = b
would end b. That is the most surprising rule in Rust for people who
arrive from every other language, and Zeno's text gives it no reason:
there is nothing a second holder could do to the first. Turned down:
a count of holders in front of each text (reference counting). It
saves the copies and it is the natural next step if copying proves too
dear, but it adds a count to every keep and drop and a way for text
to stay alive that no line of the program shows. (provisional)
Turned down: borrow str, which 5.9c used to suggest. It would
bring the borrow rules to the most common value in the language to
save a copy.
5.9m Where text is tells who must free it
Text is in one of three places: written in the program, in the text room, or in the pool. The runtime tells which by the address, with one subtraction and one comparison for each array.
Why not a flag beside the pointer. A str stays one pointer, which
is what the C code that reads it expects, and what a list slot and a
field already hold. A flag would have to travel with every str
through every call, and could disagree with the truth. The address
cannot.
What rests on it. zeno_text_keep copies only what is in the room
or the pool. zeno_text_drop frees only what is in the pool. So a
variable may hold a literal at one moment and a copy the next, and the
same line frees it correctly either way. Cost: two comparisons on
each keep, drop and lend. The casts to uintptr_t are there because C
does not define comparing pointers into different arrays.
5.9n A function's text answer travels in the text room
return text moves the answer down to where the top of the room was
when the return began, and leaves the top just above it. The
caller's statement gives it back with the rest of its text.
Why the room. The function's own copies are about to be freed, and
the caller has not yet said whether it will keep the answer.
print(label(3)) never does. The room is the one place that needs no
owner. It stays a stack: a function leaves exactly its answer on it.
Turned down: the answer in the pool, owned by the caller. Then
every call that returns text would need a statement-long owner to free
it, on every way out of the statement, including a failure half way.
That is what hold does for lists (5.9f), and it would have to be
written for every text call in the language. Cost of the choice: an
answer that is kept is copied twice, into the room and then into the
pool, and the room must be big enough for the longest answer. Text
written in the program, and the caller's own text passed in and
returned, are given back as they are.
5.9o Text read from a place a function can change is lent
A read of a var at the top of the file, a field or a list item gives
the statement a copy in the text room, if the text there is a kept
copy. A read of a local variable, a parameter or an unchanging let
does not.
Why. In print(last, note("x"), last) the function gives last
other text, which frees what the first read is still holding. A
collector would have kept it alive. Here the statement must have text
of its own. The places listed are exactly those a called function can
assign to (the same test 3.12 uses for looking again at a value that
was checked for none).
The short cut. Text that is read and stored at once
(let before = last) is copied straight from the place: nothing can
run in between. In C the compiler sees that the value is still the
bare read. In IR the zeno_text_lend has already been written, as the
last line, and is taken out again.
Cost, and what is turned down for now. print(channel.label)
copies the label though nothing is called after the read. The compiler
knows which statements call functions and could skip the copy.
(provisional: an exercise in docs/text.md.) Turned down: copy
only if a later part of the statement calls something. It is right,
and it makes whether a copy is made depend on what else is on the
line. The simple rule came first.
5.9p Objects, lists and errors free the text they keep
The function that makes an object keeps the text for its fields, and
the function that ends it drops them. A list of text drops its items
with itself, and the copy a for loop walks (3.10a) has copies of the
items too. An error that carries an object is told which function ends
that object (zeno_error_of).
Why the copy for for is deep. The body may give the list a new
item, which frees the old one, while the loop is still holding it. A
shallow copy would be a list of pointers to freed text. Cost: a
block for each item before the first trip. (provisional: the checker
could say when the body cannot change the list.)
Why the error needs a function. To the runtime an object is a
void *. Before this stage freeing one was zeno_free, because an
object that could be raised held no memory of its own. With text in
its fields that leaks. Turned down: refusing text in a raised
object. raise TooHot(channel.label, reading) is the natural thing
to write.
In a list literal every item is lent and then kept, without the short cut of 5.9o. All the items are worked out before any is stored, so something may run between a read and its keep. The two backends do the same so that their memory reports agree (5.11t).
5.9q match and for give built text back as soon as they can
A for whose first line builds text gives it back before the first
trip: by then it has its numbers, or its copy of the list. A match
gives it back when a case is chosen, and a name the pattern gives
(case other) keeps a copy first. Both were refused before (5.11r).
Why not hold the text for the whole statement. A loop that runs
for ever would hold it for ever, and every break, continue and
return inside would have to give it back. Giving it back at the one
point every path passes through needs no such list.
Known gap, for the audit. C does not say in which order the arguments of a call are worked out. Showing a list uses the room for a moment while it works, so a statement that shows a list and builds other text can fill the room a few bytes higher in the C build than in the LLVM build. What is printed is the same. The test programs avoid the mix so that 5.11t can stay strict. The fix is to work each argument out into a temporary in order, which changes most of the C this project's pages quote, so it waits for the audit stage.
5.9r A value that is made and given to nobody belongs to its statement
print(count(line.split())) makes a list and gives it to nobody. The
statement owns it and frees it when the statement is over. The same
goes for an owned value that a function gives back and that is only
looked at, print(size(make())), and that one in every build. The
walkthrough is docs/temporaries.md.
Why. With no collector the question for every block is "which line frees it?". For a value with no name there are few candidates, and the end of the statement is the only one that is both certainly late enough (nothing in the statement is still looking) and certainly reachable (nothing after the statement can mention the value). It is also the rule a reader would guess, and the rule Rust and C++ have for what they call temporaries. (Principle: unambiguous and intuitive. Nothing new is written in the program, and a line means the same in every build.)
Turned down: keep refusing, and ask for a name. That was the rule
until now. let words: owned list[str] = line.split() followed by
count(words) is two lines and a type for what reads as one thought,
and it holds the list to the end of the block and not the statement.
The refusal taught nothing that the rule does not.
Turned down: free it after its last use. In
print(len(a.split()), slow()) the list could go before slow()
runs. That needs the compiler to know which calls keep looking at what
they were given, for built-ins and for the program's own functions
alike, and then two lines that look the same would free at different
moments. One fixed moment is easier to reason about, and a let in a
block of its own says anything else.
Turned down: free it at the end of the block. A loop with such a statement in it would hold one list per trip until it ended.
Turned down: count references. Decision 5.7 turned it down for the whole language, and nothing here is different.
How it leaves early. The value is held (5.9f) from the line that
makes it. So a return, a break or a failed call in the middle of
the statement frees it by the path that already frees what the owners
hold. No second mechanism was built.
5.9s A made list is the statement's unless an owned list is wanted there
Six things make a new list: writing one out, a comprehension, split,
map, filter and +. The checker notes each (Types::made). Where
the place a made list arrives at wants an owned list (a let with
the type, an owned parameter or field, a return from a function that
gives one back), it starts life owned there (Types::fresh, which
5.9e already had for the first two). Anywhere else, in a build with no
collector, it is the statement's (5.9r), and stays refuses to let
anything keep it: "this list has no owner".
Why the type of the place decides. It is already written in the program, by the person who knows whether the list is meant to last. The alternative is to work out from how a name is used whether it needs to own, which is 5.8j's escape analysis, turned down there.
A plain list[T] parameter may not be given a made list
(provisional). A borrow parameter may: the checker already makes
sure such a function cannot keep what it was lent. A plain parameter
makes no such promise, so the list could be kept past the statement.
The next step makes a plain list in this build an owned one, and then
the function takes the list over.
With a collector nothing changes for a plain made list: it is shared, and collected.
5.9t A condition is over when it has its answer; a for and a match are over after their last block
The end of the statement is the end of the line for a let, an
assignment and a call. The others:
- The condition of an
if,else iforwhile, each side ofandandor, and whatnotis in front of, are over as soon as they have their one-bit answer. What they made is freed there, before any block runs, and on every trip of awhile. - One item of a comprehension is over when it is in the list.
- A
forand amatchkeep what their first line made until their last block is over.
Why the conditions free early. Nothing can be looking: the answer
is a bool. And a while that held its condition's list for the whole
loop would hold one for every trip.
Why for and match hold on, when text is given back before the
first trip (5.9q). Text is copied when it is kept, so the loop has
its own. A list is not. In for x in make().data the loop walks the
very list inside the object the statement owns, so the object must
outlive the loop. Rust has the same rule for the same reason.
Turned down: free what can be freed before the first trip. Telling
which values the loop still looks into is the analysis 5.9r turned
down.
A change that came with it. In C, the sides of and and or used
to share the statement's text mark. They are conditions now, as they
always were in the LLVM backend, so the two backends give text back at
the same points.
5.9u The statement's variable is declared where the statement begins, holding nothing
zeno_list *t1 = NULL;
t1 = zeno_split_blanks(z_line);
...
zeno_list_drop_text(t1);
Why not zeno_list *t1 = zeno_split_blanks(z_line);. The value
may be made inside an if that the C needs for another reason
(a < f(x) < g([1, 2]) is nested ifs), and the line that frees it
is after that if. A variable declared inside would be out of reach.
Declared first and set to NULL, it is in reach everywhere, and
freeing NULL does nothing if the if was skipped. In IR the same
thing is a slot with store ptr null (a name from a block that may be
skipped cannot be used after it). Both lines are inserted where the
statement began once it has been compiled: only then is it known how
many there are.
Turned down: free at the end of whichever nested block made it.
Every place the compilers open a block inside an expression would need
to know about it, now and whenever one is added. One rule at the
statement, plus NULL, covers them all.
5.9v A list that nobody else can reach is walked without a copy
A for loop, map, filter and reduce walk a copy of their list
(3.10a), so that a body which changes the list cannot confuse them.
Two kinds of list need no copy: one that was made right there (the
loop frees it after the last trip), and one that the statement owns
(the statement frees it after the loop). Neither has a name, so no
body can change it.
Why bother. In a build with a pool of a fixed size, the copy of a
list of text is a block for the list, a block for its items and a
block for every text. for word in line.split() would have made all
of them twice.
5.9w for ch in text makes a list of the characters, which the loop owns (provisional)
It was refused, with advice to count with range(len(text)) instead.
It is accepted: the list is a made list like any other.
Why provisional. It costs a block of the pool for every character
while the loop runs. Walking the text itself, one character at a time,
would cost none, and is an exercise in docs/temporaries.md. It was
not done here because the text may be in the text room, which a for
gives back before the first trip (5.9q).
5.9x Lists made from text have their own copies, made the cheapest way
By 5.9p a list frees the text it holds, so each made list must own
what it holds. split and the characters of a for cut their pieces
straight into the pool (zeno_piece), never into the text room.
map copies each answer and gives the text room back before the next
call, so a map over a thousand items needs room for one answer.
filter copies what it takes. + joins and then copies
(zeno_list_keep_text).
Turned down for now: move text out of a list that is about to be
freed, as filter on a statement's own list could. It saves a copy
and a free per item and needs each of these loops in two forms. An
exercise.
5.10 A bytecode VM, beside the interpreter
zeno run --vm compiles a program to bytecode and runs that. The
walkthrough is docs/vm.md.
Why build one. Three reasons, in order of weight.
- It is the next thing to understand. Python, Java, C#, Lua, Ruby and every JavaScript engine run programs this way. Stacks, frames, closures over cells and handler tables are the working parts of all of them, and they are easier to see in fifty instructions of your own than in someone else's five hundred.
- It is a second, independent reading of what a Zeno program means. Two engines that must agree find mistakes that one engine and its tests do not. It found one the day it first ran (3.6d).
- It makes
zeno runfaster: about three times on calls, twice on loops. That matters least. Speed on a PC was never the point of Zeno, andzeno buildis far faster than either.
Why now, after the C backend and not before. Decision 5.5a.
5.10a A stack machine
Instructions take their inputs from a stack and leave their result on
it. a + b is "push a, push b, add".
Why. It is the simplest thing to compile to. The code for an expression is the code for its parts followed by one instruction, with no decision to make about where anything goes. The JVM, CPython and WebAssembly are stack machines for the same reason.
Turned down: a register machine, where instructions name the slots
they read and write (add r2, r0, r1), as in Lua 5 and Dalvik. It needs
fewer instructions for the same work and is faster. It also needs the
compiler to choose a register for every temporary, which is a problem of
its own. The LLVM stage will meet that problem properly.
5.10b One meaning, written once
The VM uses the interpreter's Value, its built-ins, and the same
functions for every operator, field access, index, call binding and
error rule. Those functions moved out of the interpreter into
src/ops.rs, and the built-ins now take a Machine (the two things
they need from an engine) and not the interpreter.
Why. "Compiled code must behave exactly like interpreted code"
(5.5d). For the C backend that had to be earned test by test, because C
cannot call Rust. Here it can be true by construction: if both engines
call ops::binary, they cannot disagree about 7 // -2. What is left
for the VM to get wrong is control flow and where variables live, and
that is what its tests aim at.
Turned down: a compact value of the VM's own (8 bytes, with small numbers and pointers packed into one word, as fast VMs do). It is where most of the missing speed is. It would also mean two of everything, and two places for each to go wrong.
5.10c An instruction is a Rust enum, not bytes (provisional)
Op::GetLocal(3) is a value of an enum, 12 bytes wide, in a Vec<Op>.
Why. The Rust compiler checks every match on it for a missing
case, an operand cannot be read with the wrong width, and the printer is
nearly free. Real bytes would make the loop a little faster and the code
a good deal harder to read, for a file format nobody needs yet.
When it changes: when compiled programs are saved to disk, or a VM has to fit on a microcontroller. Both want the code to be bytes. It is the last exercise in the walkthrough.
5.10d Variables are stack slots; shared ones are cells
A variable used only by its own function is a slot of the stack, known
when the program is compiled. A variable that a function written inside
also uses lives in a cell on the heap, which both hold. Which is which
is decided by name: if any inner function mentions count, every
count in the enclosing function gets a cell.
Why slots. A read is one index into an array. No name exists while the program runs.
Why cells. 3.6c: the inner function can outlive the call that made the variable, and both must see one variable, so it cannot stay on the stack. The C backend made the same choice for the same reason (5.5k).
Why by name. It is one walk over the function before compiling it, and it can only err toward a cell that was not needed, which is slower and never wrong.
Turned down: Lua's upvalues, where a shared variable stays on the stack until its function returns and is moved to the heap only then. Faster, and a good deal more machinery. Also turned down for now: working out exactly which variable each inner function uses. It is an exercise.
There are no global variables. The program is a function called main,
and a top-level variable that a function uses is a variable of main in
a cell. One mechanism, not two.
5.10e The bytecode compiler never refuses a program
A mistake it can see while compiling (a name that does not exist, an
assignment to a let, break outside a loop) becomes a Fatal
instruction at the place where the interpreter would have found it.
Why. The interpreter runs a program up to its mistake and then
stops. If the VM refused the whole program, the two would differ for
every program with a mistake in it, and the tests that compare them
could not cover those programs. Refusing programs is the checker's job
(4.4), and zeno run --vm runs the checker first.
5.10f Every test program runs on both engines
vm::run_both runs a program on the interpreter and on the VM and
requires the same output and, when it stops, the same message at the
same line and column. The interpreter's tests and the C compiler's
tests go through it.
Why. It is the cheapest strong test there is: about two hundred programs that were already written, each now checking the VM with no expected output to write. And it works in both directions, as 3.6d showed.
5.10g The interpreter stays the default (provisional)
zeno run uses the interpreter unless given --vm.
Why. The interpreter is the plainest statement of what Zeno means:
a while loop is run by a while loop. It is what the VM and the C
backend are compared against, so it should be the one people meet first
and the one that is hardest to break. The VM is new.
When it changes: when the VM has run everything for a while without a difference turning up, the default can flip, and the interpreter stays as the reference behind a flag.
5.10h A Zeno call does not use the Rust stack, with one exception
A call adds a frame to a list. The loop does not call itself.
Why. It is what makes an error cheap to unwind (drop frames) and
what would let a later stage pause a function and come back to it, for
generators or async. The interpreter cannot do either without help
from Rust's own stack.
The exception is a built-in that calls a Zeno function, such as
map. It is Rust code, so the loop is started a second time underneath
it. Turned down: making map, filter and reduce instructions of
their own so that they never leave the loop. It would be right for
async, and it is not needed until then.
5.11 An LLVM backend, beside the C backend
zeno build --llvm writes the program as LLVM IR and hands it to clang
(docs/llvm.md). zeno build without it still writes C.
Why. IR is what clang turns C into, so writing it directly takes
one language out of the path. The order of evaluation, overflow and
temporaries, which emit_c.rs works hard to get right in C, are plain
in IR. LLVM then gives the optimiser and code for every processor it
knows, WebAssembly included, which the roadmap wants later.
Turned down: replacing the C backend. Firmware is built with the vendor's C compiler, a reviewer reads C, and MISRA is a standard for C. The two backends are for two jobs.
5.11a The IR is written as text, with no LLVM library
emit_llvm.rs builds a .ll file with format!. Zeno still has no
dependencies.
Why. The text is the lesson: every instruction the backend emits can
be read in the file it writes, and tried by hand with opt and clang.
Turned down: a binding to LLVM's own library (inkwell, llvm-sys).
It needs the LLVM development files of one exact version installed
before Zeno itself will build, and it hides the IR behind method calls.
Cost: clang must be installed to get a program, mistakes in the IR
are found only when clang reads it, and the text form changes between
LLVM versions. This one needs LLVM 15 or later.
5.11b A let is a value and a var is a slot; LLVM builds the SSA
A name in IR is given a value once. A let and a parameter are that
already, so they are IR values with nothing more to do. A var gets an
alloca, read with load and written with store, and LLVM's
mem2reg pass turns it into values and phi.
Why. Placing phi for variables that change in loops and branches
is the hard part of building SSA, and LLVM has it. Every front end on
LLVM does this. Zeno gets something extra from its own rule that let
cannot change: most variables need no slot at all, and the IR reads
close to the program.
phi is written by hand in two places, and/or and a < b < c,
where the shape is always the same.
5.11c Overflow is checked in the IR, and //, % and / are written in IR
+, - and * on ints use llvm.sadd.with.overflow and its
relations. // and % on ints, and /, are small functions written
in IR (zeno.floordiv, zeno.mod, zeno.div, in the table WRITTEN),
copied into a program that uses them. **, and // and % on floats,
still call runtime.c.
Why. The first version called the runtime for all of them, because
the runtime already had their rules right (which way // rounds, the
sign of %). That was measured: the runtime is in another file, so the
calls were not inlined, and a loop of 300 million % took 0.76 s where
the C build took 0.29 s. Written in IR they are in the same file as the
program, LLVM inlines them, and the loop takes 0.28 s. They follow the
C line for line, and the tests compare both with the interpreter.
Turned down: -flto, which optimises the two files together. It
got the loop to 0.45 s, not 0.29, and needs the LLVM linker installed.
Cost: two copies of three small functions to keep in step.
5.11d The runtime is the C backend's, with static taken off (provisional)
An LLVM build links to the same runtime.c. emit_llvm::runtime()
removes static from the start of each line, so that the functions can
be called from the IR file, and zeno build --llvm writes the result
beside the IR as name_runtime.c.
Why. One runtime means text, printing, float formatting and the collector behave the same in both backends with nothing to keep in step. Writing the file out, instead of piping it to clang, lets the build be repeated by hand, which the exercises need.
Turned down: a macro in runtime.c in place of every static
(a change to 150 lines for the same result), and a runtime rewritten in
IR. Cost: the whole runtime is linked in, used or not, because
without static the compiler cannot tell. A build is about 20 KB
bigger.
Three small functions are added at the end for try (FOR_TRY in
emit_llvm.rs, see 5.11l). They are only in the copy an LLVM build
writes, so a C build is as it was.
There are three runtimes, as for C: ordinary, firmware, and firmware
with the collector left out (5.9). emit_llvm::runtime(build) takes
whichever emit_c::runtime would give that build.
5.11e The LLVM backend compiles what the C backend compiles
It began with what the C backend began with: numbers, bools, text,
functions, if, while, for over a range, f-strings. That was the
smallest part that teaches all of IR's ideas (types, blocks, SSA, phi,
calls into C). The rest of the language followed in one step: classes,
lists, enums and match, none, try and raise, functions as
values, units and import math (5.11h to 5.11n).
Why in one step. The pieces lean on each other. Showing an object
shows its fields, which may be lists of enum values that may be none.
Text, comparing and list slots had to exist for all kinds before any
one of them could be tested against the interpreter.
What it refuses is what zeno build refuses (a named function inside a
function, the time module, a default that is not a literal, a method
used as a value). --firmware, --no-collector and --memory were
refused at first and came later (5.11o, 5.11p, 5.11q).
One thing it takes that the C backend does not: an assignment through
any place, such as grid[0][1] = 5. In IR the order of the steps is the
order they are written, so the C backend's reason for refusing it (the
value must be worked out before the place) does not arise.
5.11f The IR names no processor, and assumes 64 bits
The file has no target triple and no datalayout. clang supplies the
machine it is running on.
Why. One .ll then builds on Linux and macOS, x86 and ARM, with
nothing to detect. Cost: the IR does take a pointer and size_t to
be 64 bits wide, so it is not for a 32-bit microcontroller. The C
backend is.
5.11g An LLVM build must print what the interpreter prints
The tests in emit_llvm.rs run each program through vm::run_both,
build it with clang, and require the same output and the same error at
the same place, with the collector running before every allocation.
Why. The reason is the one in 5.10f: the programs are already written, and a third engine that disagrees with the other two is wrong somewhere.
Every program the C backend's tests compile is also built through LLVM
(same_as_interpreter in emit_c.rs ends by calling the one in
emit_llvm.rs). Why. Those 52 programs were written one hard case
at a time while the C backend grew: order of evaluation, errors inside
loops, closures that outlive their function. Writing them again would
find fewer.
5.11h An object is a struct type, and an enum value starts with its tag
A class becomes %z_Point = type { double, double }. An object is the
address of a block from zeno_alloc, and a field is reached with
getelementptr. An enum value is the address of a block that starts
with an i64 tag. Each case that carries values has a struct type of
its own (%z_Shape.Circle = type { i64, ptr, double }), and a case
that carries nothing is one fixed constant.
Why. It is 5.5g again: in Zeno these values are shared, so they
are held by address. getelementptr with a named struct type lets LLVM
work out where each field is, on any processor. Turned down: a C
union for the cases of an enum, as emit_c has. IR has no unions. It
does not need one: after the tag has been read, the block is simply
looked at through the struct type of that case. A block is as big as
its own case, not as big as the biggest.
5.11i A list item crosses into the runtime as an i64 (provisional)
Lists are the runtime's (5.5h): one list type, each item in a
zeno_slot, a C union one register wide. The IR calls
zeno_list_append(ptr, i64) and puts an item into the i64 with
bitcast (a float), zext (a bool) or ptrtoint (an address).
Why. One runtime for both backends (5.11d). This leans on how C
hands a small union to a function: as one whole number in an ordinary
register. That was checked by asking clang for the IR of runtime.c on
x86-64, and it is the rule on 64-bit ARM too. It also takes the
processor to be little-endian, where a C bool in the union is its
lowest byte. When it changes: on a processor where either is not
so. Then the runtime gets entry points that take int64_t.
5.11j T or none is null for an address and a { i1, T } pair for a number
As 5.5j. An address can already say "nothing", so Point or none is a
ptr that may be null. An int or none is the pair { i1, i64 }:
a flag, then the number. IR passes such a pair to a function and
returns it like any other value (insertvalue and extractvalue put a
part in and take one out).
Inside a list a pair does not fit a slot, so it is put in a small block
and the slot holds the address. The runtime has functions for that
(zeno_box_int), which take a C struct. They are not used: how a
struct is handed to a C function differs from processor to processor,
and getting it wrong is silent. The same six functions are written in
IR (zeno.box.int and its relations), where the pair never leaves IR.
5.11k What shows and compares a value is written the first time it is needed
print(point) calls @zeno.show.Point, a function the compiler writes
in IR. So with @zeno.same.Point for ==, the same two for each enum
and each T or none, and the small functions the runtime's list code
is handed to show or compare one item. Each is written when the first
use of it is compiled, and the set written remembers.
Why. emit_c writes show and same for every class as it meets the
class, used or not, and the C compiler drops the unused ones. Here that
would be IR the reader has to scroll past. One care: a class can
hold itself (next: Node or none), so showing a Node needs the
function that shows a Node. The name goes into written before the
body is compiled, and the inner use becomes a plain call.
5.11l Errors jump, as in the ordinary C build; slots are volatile near a try
try and raise use the runtime's setjmp and longjmp (5.5i). The
IR calls _setjmp itself, marked returns_twice, and the runtime's
zeno_raise does the jump.
Why. Errors raised by the runtime (an index out of range, a
division by zero) already travel this way, and a try in IR has to
catch those too. Turned down: LLVM's own exceptions (invoke and
landingpad). They are what C++ uses, cost nothing until something is
raised, and need a "personality" function and the system's unwinder,
which runtime.c would have to be rebuilt around. Errors as values, as
in a firmware build (5.5l), are not the ordinary way here for the reason
they are not in C (5.5i): a test after every call that can raise. They
are what --llvm --firmware does (5.11o).
Two things this costs.
- How big is a
jmp_buf? Only C knows, and it differs by processor and C library. The IR asks while the program runs (zeno_try_size()), makes that much room on the stack, and leaves the fields to two more small functions. All three are added to the runtime copy an LLVM build writes. volatile. After the jump back, avarchanged inside thetrymay hold its old value, because LLVM kept it in a processor register and the jump puts the registers back. So in a function that has atryin it, every slot of the function is read and writtenvolatile, which keeps it in memory. The C backend marks only the variables changed inside atry. Marking all of them is simpler and costs speed only in those functions. It was tested by taking the word out of the IR by hand: theexceptblock then sees the wrong numbers.
(provisional) _setjmp is the name on Linux and macOS. It is the one
that does not save the signal mask, which is what C's setjmp means
there.
5.11m A function value is a block: its code, its name, its variables
As 5.5k. A function value is the address of a block whose first field is the address of its code. It is called with itself as a hidden first argument. A function written inside another finds the variables it uses after the two fields every function value has. A named function used as a value gets a small wrapper that takes the hidden argument and ignores it.
A let is copied into the block: it cannot change, so a copy is as
good. A var that an inner function mentions lives in a box from
zeno_alloc from the start, and the block holds the box's address, so
the two functions see each other's changes, and the variable outlives
the function that declared it.
Why a box for every such var, even one the inner function only
reads: finding out whether it is ever changed after the inner
function was made needs a pass over the outer function that the
compiler does not have. Cost: one small allocation for each such
variable.
5.11n In an LLVM build, an owned value is freed where its owner ends
At first the LLVM backend compiled an owned list or object exactly as a shared one and left it to the collector, because every LLVM build had the collector. That was provisional, and it is gone. An owned value is now freed where its owner ends, in every LLVM build, as in every C build (5.8). 5.11p says how.
Why in every build, and not only where the collector is left out.
One way of compiling owned values is one thing to test: the ordinary
and firmware builds exercise the same frees that --no-collector
depends on, with the collector underneath to catch a mistake. And
--memory then means the same in both backends: the same program
frees the same number of blocks without the collector.
5.11o --llvm --firmware: the firmware design again, in IR
zeno build --llvm --firmware carries errors as values, as the C
firmware build does (5.5l), and for its reason: no setjmp.
docs/llvm.md walks through the IR.
- A function that says
raisesgives back ani1, and takes one more argument,ptr %result, where it stores its answer. - A call to it is followed by a
bron thati1. A failure goes to theexceptof thetryaround it, or out of the function withret i1 false, or, at the top of the file, tozeno_uncaught. raisecallszeno_noteand fails the same way.- A
tryis plain blocks and branches. There is no_setjmp, nozeno_try, and no slot isvolatile. - The runtime file written beside the IR is the firmware one, so an error the runtime finds stops the program.
Why the same design, and not something LLVM is better at. Three
reasons. The firmware runtime already exists and is used as it is: not
one line of C was written for this. What a firmware build means stays
one thing, whichever backend makes it: the same programs are accepted,
the same ones are refused in the same words, and a fault stops both
(principle 1). And the tests come free: every test program was
already built for firmware through C and compared with zeno run, so
building it once more through LLVM needed no new programs.
Why this is the better half of the LLVM backend. In an ordinary
LLVM build, every slot of a function with a try in it is volatile
(5.11l), which stops the optimiser from keeping those variables in
registers. Here nothing jumps, so nothing is volatile, and opt sees
ordinary calls, tests and returns, which it can inline and simplify.
Turned down: giving back a pair, { i1, T }. IR can return two
values at once, and for small ones they travel in registers, which is
what Rust does with a Result. Three things against it today. The C
runtime's own functions that can fail (zeno_parse_int) use the
pointer form, so a build would have both forms in it. A pair type is
needed for every return type. And how a pair is returned depends on the
processor, where a pointer is the same everywhere. After inlining, LLVM
removes the pointer for small functions anyway. (provisional: worth
measuring when there is a real board to measure on.)
Turned down: invoke and landingpad, as in 5.11l. They need the
system's unwinder, which is the very thing a firmware build is there to
avoid.
The room for the answer is made at the top of the function, one
alloca for each call that can fail. An alloca inside a loop would
take new stack on every turn. Cost: a function with many such calls
has that many slots, until opt merges them.
--llvm with --no-collector or --memory was still refused when
this was written. Both needed owned values freed where their owner
ends. That came next: 5.11p and 5.11q.
5.11p An owner is a slot, set to null when it gives its value away
In an LLVM build a variable that owns a value is always memory: an
alloca in a function, a global at the top of the file. That holds
for a let too, and for a parameter that takes a value over, which is
stored into a slot on the first line of the function. Giving the value
away is load and then store ptr null. Each way out of a block
(its }, return, break, continue, raise, a failed call in a
firmware build) loads every owner of the blocks being left and calls
zeno_list_drop or the class's zeno.drop.Name, which do nothing for
null.
Why a slot, when 5.11b makes a let a plain value. A value in IR
is written once. "This owner is empty now" is a second write, so it
needs memory. mem2reg turns the slot back into values, and the null
is then a constant the optimiser can see. It removes the drop of an
object that is certainly null. The drop of a list stays as a call that
does nothing, because zeno_list_drop is in the runtime file
(provisional: a null test written in IR would let it go, and is an
exercise in docs/llvm.md).
Why null, and not working out which owners are full. The emitter
could follow each owner through the function and write a drop only
where one is certainly full. It would need the checker's whole flow
analysis again (an owner given away in one branch of an if is full
on one path and empty on the other), and a mistake there is a block
freed twice. With null the pointer answers the question when the
program runs. This is the C backend's design (5.8), and the two
backends now free the same blocks at the same lines.
Turned down: a phi for each owner. That is what the slot becomes
after mem2reg, and writing it by hand is 5.11b's turned-down choice
again.
Turned down: drop flags (a separate i1 for "still full"), which
is what Rust uses. The pointer already holds that bit.
A value between owners is noted (hold, let_go): the list a
comprehension is filling, the copy a for walks, an owned argument
while later arguments are worked out. Whatever is noted is freed with
the owners when a block is left early. Why. Without it a failure
in that moment leaves a block nobody frees, and with no collector that
is a leak. The C backend does the same (5.9).
In an ordinary build a jump still skips frees in the functions it
passes over, and the collector takes those, as in C. In the function
with the try, owner slots are covered by the volatile rule that
already applies to every slot there (5.11l), so an owner emptied
before a call that raises is still empty afterwards.
5.11q --llvm --no-collector: the same design, the same runtime, the same refusals
zeno build --llvm --no-collector is 5.9 in IR. Owned values are
freed by their owners (5.11p). Built text goes in the text room and is
given back at the end of its statement. An except frees the error it
caught. The runtime written beside the IR is the C build's no-collector
runtime. The IR has no zeno_roots, no stack base and no call that
marks anything.
Why not a design of its own. The reasons are those of 5.11o. What
--no-collector means stays one thing whichever backend makes it. The
runtime is used as it is. And the test programs already exist.
Refusals are made by one function. Every "this build cannot do
that" error in both backends goes through emit_c::without_collector,
which adds the same closing words. A table in the tests gives each
refused program to both backends and requires the same message. Why.
A user who moves a program from one backend to the other should not
meet a new error, or the old error in new words.
Which text is in the room is tracked on the value (fixed in
Val): set for a literal and a variable that cannot change, clear for
text that is in the text room. (Until 5.9l the bit decided what could
be kept at all. Now it decides what the statement must give back.) Why on the value. The
C backend asks the same question of the syntax tree. In the LLVM
emitter the value is what every "keep" site already has in its hand,
and the bit travels with it through if expressions and or none.
Cost: each place that makes a Val has to say which it is. The
default is "built", which is the safe side: a missed case is a refusal
the C build would not make, and the test table shows it.
--memory is asked for in the IR: main calls
atexit(zeno_memory_report), and the runtime file is compiled with
-DZENO_MEMORY_REPORT. Why atexit, and not a call at the end of
main: an error that stops the program should still print the report,
and the C build does it this way.
5.11r The text mark is a load put in afterwards
A statement that builds text is compiled with %mark = load i64, ptr
@zeno_text_top before it and store i64 %mark, ptr @zeno_text_top
after it. Whether a statement builds text is known only once it has
been compiled. So the emitter remembers the line where the statement
began, and inserts the load there afterwards if it is needed.
Why not write both lines for every statement. They cannot be
removed later. zeno_text_top is a global the runtime changes, so
LLVM must assume any call in between touches it, and a loop of
arithmetic that calls one function would load and store on every trip.
Why not ask the syntax tree first, as the C backend does with a walk over the expression. The walk has to agree exactly with what the emitter then does, for every kind of expression, and a disagreement is a leak in the text room. Here the emitter sets one flag at the two places where text is made, and cannot disagree with itself.
Cost: an instruction is inserted into lines already written. That is safe because names in IR do not depend on position, and the mark is inserted at a point where the block is open.
A for or a match whose first line builds text was refused at
first, as in C. Now each gives the text back at the first point where
it is no longer needed (5.9q).
5.11s In an f-string a number waits to become text
f"{a} then {next()}" works out a, then next(), and only then
turns the two into text. A list, an object or an optional is still
turned into text at once.
Why. With no collector, text made for {a} sits in the text room
while next() runs. If that call builds text of its own, or recurses
into this line, the room holds more at once than it needs to. The C
backend had never done that, and one test program used 44 bytes of
text through LLVM where C used 41. The comparison of memory reports
(5.11t) found it.
Why only numbers, bools, quantities and text. The value read for
{a} cannot be changed by a later call, so showing it later prints
the same. A list can be appended to by next(), and the f-string must
show it as it was when its turn came (principle 1: same output as
the interpreter).
It is done in every LLVM build, not only with no collector. Why. One shape of IR for an f-string, and the order of the runtime calls is not something a program can see.
5.11t With no collector, the two backends must write the same memory report
Each no-collector test program is built through C and through LLVM
with --memory, and the two reports are compared as text: blocks
made, bytes, the most in use at once, blocks left, and the most text
in the room.
Why so strict. "Prints the same and leaves no block" does not notice a free that comes late, or text held a statement too long. A board with a 256-byte text room notices. The C build's numbers were checked by hand when 5.9 was built, so they are the reference.
Turned down: fixed numbers in the tests. They would have to be changed by hand whenever the runtime changes the size of a header. Comparing the two builds needs no numbers.
With ZENO_VALGRIND=1 the same tests run each program under valgrind.
Why valgrind and not AddressSanitizer: clang's sanitizer library
is not installed everywhere clang is, and valgrind needs nothing at
build time. It is off by default because it is slow. (Since the pool,
5.9k, it sees less: blocks no longer come from the heap.)
5.12 The board is four functions
Everything a compiled program does to hardware goes through four C functions, and a count:
void zeno_board_write(int pin, bool level);
bool zeno_board_read(int pin);
void zeno_board_sleep(double seconds);
double zeno_board_now(void); /* milliseconds */
#define ZENO_PINS 64
on, off, toggle, is_on, the check in pin(n) and the check in
sleep are written once in the runtime, on top of these.
Why so few. A port to a new board is these four functions and
nothing else, and each is one call to the vendor's library. Everything
that can be decided without knowing the board (is the pin number in
range, is the time positive, what does toggle mean) is decided above
the line, once, and tested once. Turned down: a function for each
thing a pin can do (zeno_board_toggle and so on). Faster on some
processors, which have a register for toggling, and four more
functions for every port to get wrong.
5.12a The simulated board is written twice, in Rust and in C
src/board.rs for zeno run, and thirty lines of runtime.c for a
compiled program. Each stage holds its own board: the interpreter and
the VM through the Machine trait, a compiled program in a C array.
Why twice. The interpreter is Rust and a compiled program is C, and the tests hold the two to the same output, line for line, so they cannot drift apart unnoticed. Why not one board for the whole process in the interpreter: the tests run a program on the interpreter and then on the VM, and the second must start with every pin low.
5.12b A real board is a header, named with -DZENO_BOARD
cc -O2 -DZENO_BOARD='"my_board.h"' -o blink blink.c -lm
The runtime includes that file in place of the simulated board. A test builds a program on a two-pin board of its own this way, for all three kinds of C build.
Why a header and not a library to link. A Zeno program is one C file with nothing to link (5.5). A header keeps that true, and lets the C compiler inline a register write into the loop that does it.
5.12c zeno build does not cross-compile
It writes the C and builds it for the computer it is on. Building for a microcontroller is done with that microcontroller's own compiler, flags and linker script, by hand or from its project.
Why. Those tools exist, differ for every vendor, and are what a
firmware engineer already has set up. A --target option would be a
second, worse copy of each of them. (Principle: keep what works.)
5.13 A sized number is an ordinary int when the program runs
In the interpreter and the VM a u8 is a Value::Int. In C it is an
int64_t, and in LLVM an i64. Only the checker knows its size. The
two things that make one, u8( ) and u8.wrap( ), are the only code
that runs because of it.
Why. It is how units (1.15) and pins (3.15) already work: the
type exists to stop mistakes, and is gone afterwards. Because
arithmetic gives an int (2.8a), there is nothing a sized number does
differently once it exists, so there is nothing for a second kind of
value to do. The four engines agree for free, where a Value::U8
would have needed every operator taught about it four times.
(Principle 5, and "keep what works".)
Cost. In a program that is not strict, a value whose type is not
known until run time can be put in a u8 variable unchecked, as it
can be put in an int variable today (0.2). A compiled program is
always strict (5.5c), so this is the interpreter only.
5.13a Only a field of an object is stored small (provisional)
A class with the fields kind: u8 and length: u16 is a struct of a
uint8_t and a uint16_t in C, and { i8, i16 } in LLVM. A value is made
small as it goes into the field and an int64_t again as it comes
out. Variables, parameters and list items stay 64 bits wide.
Why. Memory is spent on what there are many of, and what there are
many of lives in objects. A local variable is in a register, where a
u8 saves nothing and can cost an extra instruction. So the one place
the size reaches the generated code is the one place it pays.
Why the read is cast. In C, a uint32_t field used straight in a
sum is unsigned arithmetic: -field and field < -1 come out wrong.
The cast to int64_t at the read is what keeps 2.8a true in C.
Lists are the gap. A list[u8] still uses eight bytes an item,
because the runtime has one list type (5.5h). A packed list, or a
bytes type, is the stage that fixes it.
5.14 A generic is compiled once for each set of types
first[T] called with a list[int] and a list[str] becomes two
functions, first[int] and first[str]. Stack[int] and Stack[str]
are two classes, each with its own struct. The generic itself is never
compiled.
Why. There are two ways to build generics. The other is erasure,
which Java uses: one function for every type, with each value behind a
pointer and its type forgotten. Erasure makes small programs. It also
means a Stack[u8] holds pointers to bytes and not bytes, every >
inside a generic is a call that is looked up while the program runs,
and a Cell[float] cannot hold its float in the struct. On a microcontroller those are the costs
that matter: memory per object, and time per operation. With copies, a
Cell[u8] is one byte, and largest over ints is a loop of integer
compares, exactly as if it had been written by hand. C++ and Rust made
the same choice for the same reason. (Principle 5.)
Cost. Code size. A generic used with ten types is ten functions. The programmer can see this: each copy has a name in the C. Erasure could be offered later for a generic where size matters more than speed, as an explicit choice.
Why it was cheap. By the time the compilers run, every copy is an
ordinary function or class. The C backend and the LLVM backend each
changed in three small places: register the copies and not the
generic, call the copy the checker names, and spell the name. Sized
fields, owned classes, --firmware and --no-collector all worked
with generics without a line written for them.
5.14a A copy is a copy of the syntax tree
The checker makes first[int] by copying the tree of first
(Function::fresh), renaming it, and checking it in a scope where T
is a name for int.
Why not substitute types in a table. Every stage after the parser
keeps its notes by the address of a node: "this expression is a
list[int]", "this call gives its argument away". One body with two
sets of notes would need every table keyed by (node, copy), in the
checker and both compilers. A copy of the tree has addresses of its
own, so every table works as it is.
Why fresh and not clone. A function written inside another is
behind an Arc, which clone shares. Two copies would share the
inner function's notes, and the second would overwrite the first. A
test (doubled, in generics_match) fails if fresh is replaced by
clone.
Cost. Memory in the compiler, in proportion to the copies. The
checker stops with an error when a generic needs a bigger copy of
itself each time (class Node[T] { next: Node[list[T]] or none }),
which is the one way the copies can fail to end.
5.14b The interpreter and the VM run the generic itself
Neither engine changed. They run first as one function.
Why. When a program runs under zeno run, every value carries its
type, so there is nothing for a copy to add. This is erasure, in the
one place where erasure is free. The tests hold all four engines to
the same output, so a difference between "one function" and "many
copies" would show.
5.14c In C a copy is called z_Stack_int; in LLVM, "z_Stack[int]"
Why. C names allow letters, digits and _, so Stack[int] loses
its brackets: each run of other characters becomes one _. LLVM IR
allows any name in quotes, so there the copy keeps the name the error
messages use.
Cost. In C, Stack[int] and a class the programmer called
Stack_int would get the same name. The build refuses the program
and says why. A scheme that could never clash (C++ mangles Stack<int>
to 5StackIiE) would make the C unreadable, and reading the C is half
the point of the C backend (5.5). (Provisional.)
5.14d A generic that nothing uses is checked with T as "anything"
Why. With no call there is no type to check the body with, and a
misspelt name inside it would wait for the first user to find. So the
checker makes one more copy in which every type parameter is the type
that fits everywhere, and checks that. It finds wrong names, wrong
counts of arguments and missing returns. It cannot find a > that
only some types have, because "anything" has every operator. That is
the gap bounds would close (3.16d).
5.15 With no collector, whoever receives a value owns it
In zeno build --no-collector every list and every object has one
owner, without the word owned. A list owns its items, in every build.
The walkthrough is docs/one-owner.md.
Why. 5.9g refused every value that had no owner and told the
programmer to write owned. A rule that is the same on every line is a
rule the compiler can apply itself, and a word that must be written
everywhere says nothing. (Principle: punctuation, and words, have to
earn their place.) Rust is built this way, and Rust is the proof that
the model is enough to write firmware in.
Why only in that build. With a collector, sharing is the easy
default and costs nothing to offer: let b = a gives two names for one
list, as in Python. Taking that away from zeno run would make the
first hour with Zeno harder for no gain there.
5.15a The checker is told which build it is checking for, and may only refuse
checker::Rules { strict, one_owner } replaces the single strict
flag. Build::rules() says what each build asks for, and
zeno check --no-collector asks for the same without building. This
takes back 5.9e ("the compiler refuses, not the checker") for one
question: who owns what.
Why take it back. 5.9e kept the checker the same for every build.
But the answer to "who owns this list?" decides which later lines are
errors (print(a) after let b = a), and only the checker follows a
value from line to line. Doing that a second time in two compilers
would be the borrow checker written three times.
What keeps the language one language. The mode adds errors and
changes nothing else. A program it accepts gives no value two owners,
and such a program cannot tell "owned" from "shared" by what it
prints. So the same file means the same under zeno run, zeno build
and zeno build --no-collector, and the tests run each accepted
program all three ways. (Principle: one way to say a thing, with one
meaning.)
Turned down: a different default per build that changes behaviour,
such as copying a list on let b = a when there is no collector. The
program would print different things in different builds.
What the compilers still refuse themselves: what has no place to
live yet (an enum case with values, a function value that captures, a
list of int or none). Those are about how a value is stored, not who
owns it, so 5.9e still holds for them.
5.15b A plain parameter takes, and borrow looks, as before
No new word came with this step. function retire(task: Task) takes
the task and frees it on return. function report(task: borrow Task)
looks.
Why not borrow by default (5.8c turned it down once already, as
provisional). It was weighed again, since in this build most
parameters only look and each now needs the word. It stays as it is:
task: Task then means the same on a let, a field and a parameter
("this holds the task"), and the error for a forgotten borrow names
the fix in its first line. (Provisional, and a question for the
audit: count how often each is written in real programs.)
5.15c A list owns its items, in every build
An owned list may hold values that have one owner: objects of an owned class, and with no collector other lists. Rule 6 of 5.8 ("shared may not hold owned") stands. What changed is that an owned list is no longer barred from it, which lifts the first line of 5.8i.
Why. A list of objects is the most ordinary data structure there is: tasks, packets, readings. A no-collector build that cannot hold one is a demonstration and not a tool.
Why the list and not each item's maker. The list is the one place that knows when the items are no longer reachable: when it is freed itself. That is rule 1 of ownership applied once more, with the list in the role a field has in an object.
The cost in an ordinary build. A list of owned things must itself
be owned, and the annotation has to say so: owned list[Packet].
list[Packet] of an owned class Packet is an error that says what
to write.
5.15d [ ] and for look, append gives, pop() takes, xs[i] = v replaces
Each way of touching an item has one meaning, whatever the item is.
xs[i] and the loop variable are looks: they can read, change and
lend the item and cannot keep it. append hands the list a new item.
pop() hands the item to whoever receives it. xs[i] = v frees the
old item and puts v in its place.
Why [ ] does not take. let first = xs[0] would have to leave a
hole in the list, or shift every later item, and neither is what
xs[0] reads as. pop() already means "take it out" in every
language that has it. (Principle: read like plain words.)
Why xs[i] = v frees on the spot. The old item has no other owner
and no name, so that line is the last that can free it. The new value
is worked out first, so a failure while making it leaves the list as
it was.
+, += and filter are refused for lists whose items have one
owner: each would put one item in two lists. The message for each says
what to write. map and reduce lend each item to the function, so
they stay.
5.15e Taking an item out needs the owner, no loop over the list, and a line of its own
Three rules guard pop(), xs[i] = v and obj.field = v when what
leaves has one owner. The path must start at an owner, not at a
borrow, which includes self. No for or comprehension around it may
be walking a list reached from that owner. And the line must not have
looked at that owner already. After a pop(), the rest of the line
may not use the owner either. Inside such a loop the owner itself
cannot be given away or given a new value: both free every item.
Why these three. A look lives for one statement at most (5.8d rule 1), or for a loop's body. So there are exactly three places a look can be alive when an item leaves: in a caller that lent it, in a loop around the line, and earlier on the same line. Each rule closes one. This is 5.8i's "only the owner replaces a field", said once for every way an owned part can leave.
The cost. Safe programs are refused. xs.append(xs.pop()) must be
two lines. A method cannot take an object out of a list that its own
object holds, so Scheduler cannot have remove_last(self) -> Task
yet. (Popping a number or text is fine: those are copied out.) The
second is the one that will be felt.
Turned down: a second kind of borrow that may take out (Rust's
&mut, with the rule that only one exists at a time). It is the
complete answer and the largest single piece of Rust's checker: every
borrow then has a region and two may not overlap. 5.8d chose four
rules a reader can hold in their head. (Provisional. If methods that
remove turn out to matter, the smallest growth is "a method may take
out when the object was not lent to anything else on that line", which
needs no new word.)
Turned down: check it when the program runs (a count of looks in
each list, as Rust's RefCell does). It moves a mistake from build
time to run time. (Principle 1.)
5.15f One free function for each kind of list, written when a program needs it
zeno_list_drop_1_Task frees a list of tasks: each task with
zeno_drop_Task, then the list. zeno_list_drop_2_Task frees a list
of those. Lists of plain lists and of lists of text are
zeno_list_drop_lists1 and zeno_list_drop_texts1. Drops in
emit_c.rs picks the name, both backends use it, and each function is
written only if something calls it.
Why not one runtime function that takes "how to free an item". It would be shorter. The generated function has a name that says what it frees, is a direct call the C compiler can inline, and shows up by that name in a profile or a stack trace. (5.5: reading the C is half the point.)
Why the compilers do not carry "owned" in each type. By the time
code is written, the checker has settled who owns what. All a compiler
needs is whether a kind of item must be freed with its list, and that
follows from the kind alone: an object of an owned class always, a
list when there is no collector. So Kind stays as it was and one
function, Drops::owns, answers.
5.15g The copy a loop walks is freed alone
for task in tasks walks a copy (3.10a). The copy is freed with
zeno_list_drop, never with the function that frees items.
Why. The copy holds the same items as the real list, which still
owns them. Freeing them through the copy would free them twice.
Turned down: walk the real list when the items have one owner. An
append in the body may move the list's storage, which is what 3.10a
is for. Skipping the copy when the body cannot append is an exercise
in docs/one-owner.md.
5.15h The except owns the error it caught, and the checker says so
5.9d's rule moved into the checker for this build: e in
except Bad as e is a look at the error. It can be read and lent to a
borrow parameter, which is new. Keeping it is refused in words about
errors, not about borrows.
Why. It is the same rule as a loop's item: something else owns the value and frees it when the block ends. One mechanism now covers both.
5.15i The C backend assigns through an index, value first
points[1].x = 30 and grid[1][0] = 5 were refused by zeno build
and accepted by zeno run and the LLVM backend. They compile now. The
value is worked out first, then the list or object, then the index.
Why now. Lists of objects make these everyday lines. Why that order. It is the interpreter's, and four engines must agree. The test uses functions that print when they are called, so a change of order fails it.
5.15j What a no-collector build still refuses (provisional)
This replaces the list in 5.9g.
- A list or object inside an
or none, an enum case with values, a list ofint or none. (Step 4.) - A function written where a value goes, a function type that mentions a list or an object, an error that is kept or raised again. (Step 5.)
- A list or object as what a type parameter stands for. The copy made
for
Twould have to be checked as a value that moves. - A function that uses a list or object declared at the top of the
file (5.8d rule 4). A table of constants that several functions read
is the commonest shape of firmware data, so this one matters. A
top-level
letthat the program never gives away could be lent to every function without being passed. (For the audit.) - A method that takes out what its object owns (5.15e).
A note for 5.9q's known gap. It is wider than was written there:
one print that shows two lists is enough for the C and LLVM builds
to report text-room peaks a byte or more apart. What is printed is the
same. The fix described there still stands, and the test programs
still avoid the mix.
This page is docs/decisions.md in the language repository.