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.

  1. 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.
  2. Read like plain words. Code is read far more often than it is written. A whole word beats a symbol or an abbreviation.
  3. Punctuation has to earn its place. A mark stays only if removing it would make code harder to read or easier to get wrong.
  4. One way to say a thing, with one meaning. An operator or keyword should not change meaning depending on the values it is given.
  5. 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 + 100 is 44, quietly. That is the bug 2.2 exists to stop.
  • Check at the size, as Rust does in a debug build: (a + b) // 2 fails for two u8s 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 + offset needs 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 a list[u8] is wanted: a number written out takes the size of where it goes. The checker sees whether it fits, and let b: u8 = 300 is an error before the program runs.
  • A u8 goes wherever an int is wanted, with nothing written. Every u8 is an int.
  • An int does not go where a u8 is wanted, and a u8 does not go where a u16 is wanted. Each needs u8( ) or u16( ).

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:

  1. from the arguments;
  2. from an argument that is a function, by calling it with what the others gave: in convert(bytes, function(b) { ... }), what the function returns is U;
  3. 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 try and the raise are not tidied up by the jump. This turned out to cost nothing: the collector (5.7) finds them unreachable and frees them, and no try has to remember anything. It was one of the reasons for choosing a collector.
  • A local variable changed inside a try must be declared volatile in 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 setjmp and longjmp outright. 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 raises returns true or false, and gives its answer through a pointer the caller passes in. The caller tests what came back, and passes a failure on.
  • raise notes the error in one variable of the runtime and returns false.
  • try / except becomes an if: 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 against zeno 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 = a would 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, break and temporary; two objects that point at each other are never freed without extra help from the programmer; and a longjmp (5.5i) skips the code that lowers the counts, so every try would 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 what zeno build uses, it was freed.)
  • MISRA bans malloc and free as well (Rule 21.3), collector or not. See the first point below.

Provisional, in three respects.

  1. 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.
  2. 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.
  3. 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

  1. A borrow exists only as a parameter. It cannot be stored, returned, or passed to something that keeps it.
  2. One statement cannot both use an owner and give it away.
  3. An owner declared outside a loop cannot be given away inside it, except on a path that leaves the loop.
  4. 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, filter or + gives, since 5.9s): those are the only lists certain to have no other name.
  • match on 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 if or while, each side of and and or, and what not is 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 a while.
  • One item of a comprehension is over when it is in the list.
  • A for and a match keep 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.

  1. 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.
  2. 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).
  3. It makes zeno run faster: about three times on calls, twice on loops. That matters least. Speed on a PC was never the point of Zeno, and zeno build is 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.

  1. 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.
  2. volatile. After the jump back, a var changed inside the try may 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 a try in it, every slot of the function is read and written volatile, which keeps it in memory. The C backend marks only the variables changed inside a try. 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: the except block 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 raises gives back an i1, and takes one more argument, ptr %result, where it stores its answer.
  • A call to it is followed by a br on that i1. A failure goes to the except of the try around it, or out of the function with ret i1 false, or, at the top of the file, to zeno_uncaught.
  • raise calls zeno_note and fails the same way.
  • A try is plain blocks and branches. There is no _setjmp, no zeno_try, and no slot is volatile.
  • 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 of int 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 T would 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 let that 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.