Declarations
Bindings, functions, structs, enums and type declarations — what each form states, and what it leaves to be inferred.
A declaration is a statement, so anything that can be declared at the top of a file can also be declared inside a function body. A helper function used by one function belongs inside it, and needs no separate rule to allow it.
Bindings
Three forms, and the differences between them are about when the value is fixed rather than about where it lives.
| form | mutable | value | type |
|---|---|---|---|
var name = v | yes | optional — a var may be declared and assigned later | optional |
val name = v | no, written once | required | optional |
const Name: T = v | no, and known while compiling | required | required |
The type is mandatory on a const and optional on a val, and that arrangement is deliberate in
both directions. A const states an interface — its value is substituted where it is used, so what
that value is matters less than what it is a value of. A val has its type readable off the
value it was given, and a val with no value is not a declaration of anything.
A const inside a body
A const may be written inside a body too. Its value is computed while compiling, where it is
written, and every use of the name is that value — so it stands wherever a constant is wanted: an
array length, a value argument, a pattern, an @assert. It belongs to its block, as a val does,
and may read a value parameter, another constant, and any function whose body can run:
sq(x: int) -> int = x * x
twice[const N: usize]() -> usize
const D: usize = N * 2
D + 1
main()
const L: int = sq(6)
var a: [L]int
print(L, a.len, twice[4](), twice[L]())
36 36 9 73
That last call is the point of it: twice[L]() hands L to a value parameter, which a val could
not be. A val over the same call is computed when the program runs, unless the optimiser folds it.
It reads nothing the body binds, since a binding of the body holds a value only once the program runs:
main()
var n = 3
const A: int = n + 1
print(A)
'A' is a constant, so its value is known while compiling, and it reads 'n', which the body binds and which holds a value only once the program runs. A name bound to such a value is a 'val'
Its type follows the rule for a module’s const — a scalar —
and a value its type does not admit is refused while compiling. It has no storage of its own, so
nothing assigns to it and & does not reach it.
The value may be an indented block
A binding’s = takes one expression, or an indented block whose trailing expression is the
value — the same arrangement a function body has, and the same one an if branch has. What the block
binds is the block’s own, and goes out of scope with it:
val limits =
val raw = 7
val capped = raw * 2
capped
print(limits)
14
Reach for it when the value takes more than one step to work out. The alternative is a name in the enclosing scope that exists only to be read once on the next line, which says the value is available to everything below when it is not.
It composes with the branching forms rather than competing with them — an if too long for its line
goes on the line under the =, which is the shape the form was built for:
pick(c: bool) -> int
val n =
if c then
val a = 3
a + 1
else
0
n * 10
print(pick(true), pick(false))
40 0
A block of one expression is that expression. Moving a value to the next line to fit the margin
changes nothing else about the declaration: a module val written that way is still laid into the
object file, rather than becoming something the program computes before it starts.
A block that ends in something other than an expression yields unit, exactly as an if with no
else does, and the complaint arrives where the value is used rather than where it was bound.
A const does not take one. A constant is folded into every use of it rather than run, so there
is nowhere for the statements to happen. Its value may still sit on the next line — that is one
expression rather than a block, by the rule above — and what is refused is a block that binds
something:
const Limit: int =
val base = 3
base * 2
print(Limit)
'Limit' is a 'const', so its value is folded into every use of it
A module member states its type
Where a binding is a member of a module rather than a local, the annotation stops being optional
even on a val. That is a rule about where the binding was written, so the grammar accepts either
form and the analyzer applies the rule.
It is worth reading as one rule rather than two, because it is one: anything visible outside its
file states its types, and a module member always could be, while a local states nothing to anyone
and so infers exactly as a var does. Most of it the syntax already enforced — a parameter type and
a field type are mandatory, and an absent return type means unit rather than being inferred — so
a module-level binding is simply the last declaration the rule had left to reach.
The payoff is that interface extraction is parse-only. A file’s exported surface can be read off its syntax tree without resolving a name, checking a body, or having compiled anything the file imports, which is what lets the collect pass depend on nothing but parsing — and that is what a fast, parallel, eventually incremental build rests on. Scala infers types for public members and pays for it with a far heavier extraction step; this is a deliberate divergence, and a cheap one here because sysl’s signatures were already explicit for other reasons.
const Limit: int = 3
static val Scale: int = 2
show()
var count = 0
val doubled = Limit * Scale
val name = "sysl"
count += 1
print(count, doubled, name, Limit)
show()
1 6 sysl 3
The two vals in that program are one keyword doing one thing: the module member says : int
because it is visible outside its file, and the one inside show is visible to nobody and does not
have to. The static is what asks for the member — this is the file the program starts in, so a plain
val there would be a local of its body, and a local infers exactly as a var does.
A module-level val may hold a counted value, and never releases it. Storage that exists for the
whole run is never let go of, so the one release with nowhere to go is the one at exit — and not
taking it is what a static is. A &T, a weak T, a slice and a string the program builds
are all admissible:
static val name: string = str(2026)
print(name)
2026
That was refused until the reason was read again, and what it cost was every shape that needs a
value to outlive every frame. What the initializer is still decides where the storage gets filled —
a literal‘s bytes are a constant in the object file and its owner word is null, so nothing is
allocated and the storage is complete before the program starts, while a built value is stored by a
prologue. That difference is why a module with no alloc may still hold a table of literals:
val greeting: string = "hello"
val names: [3]string = ["alpha", "beta", "gamma"]
var i = 1
print(greeting, names[i])
hello beta
This is what a module with no allocator uses for its messages. A const could not serve it: a
constant is folded into its uses and has no address, so it cannot be indexed at a position computed
while running. Anything the program had to build is a local instead, which is ordinary — that is
what the first program above does with its val name.
A raw pointer may be held, because it counts nothing and so there is no release to write. What a
val promises is that its own storage is written once and never again, and holding an address
keeps that promise exactly as holding a number does. This is the shape that has no substitute — a
device register block named at file scope, reached by every function in the driver rather than
re-materialised in each:
const UART: usize = 0x1000_0000
val regs: *Uart = ptr_cast(UART)
An address that is a constant is laid into the object file rather than stored by a prologue, so a
val at pointer type needs nothing ordered and is readable before the first initializer runs — which
is what a freestanding program starting at a reset vector requires.
A variant that carries nothing is constant data too, which is what lets a module-level slot start
out unset. None is the one that matters: a sink nobody has installed, a handle nobody has opened,
a cache nobody has filled.
static val held: Option[int] = None
static var chosen: Option[int] = None
pick(n: int) = chosen = Some(n)
report() -> int = chosen match
Some(v) -> v
None -> held.unwrap_or(0)
print(report())
pick(7)
print(report())
0
7
Both slots are laid down rather than filled, so a module holding one is reachable from an @export
on a target with no loader to run an initializer. A variant that carries something is a different
matter and is stored by a prologue: a payload lives in a region every variant shares, so laying one
down means knowing which bytes of that union the variant’s fields occupy, and that is a store rather
than a constant.
An enum whose variants all carry nothing never had this question — it lowers to its discriminant,
so static val mode: Colour = Colour.Red has always been a number in the object file.
Writing to a val twice is refused:
show()
val name = "sysl"
name = "other"
print(name)
show()
a 'val' is written once, so assignment has nothing to write through
Read-only means read-only at every depth. k = …, k[0] = …, k[0] += 1 and k[0]++ are all
refused on a module-level val, and so is &k[0] — a *T is a licence to write, and handing one
out would move the mistake one step away from where it could still be reported.
Slicing is allowed, and yields a []const T. That is what lets a table be read and passed
rather than only read: the read-only property travels with the view, through a name, through a call,
and through a second subscript, so every write above is refused through it too. &v[0] on such a
view is a *T, the tier the memory model excludes on purpose, and is how the view reaches C.
The one module-level storage none of this reaches is an extern variable. Every rule above is a
promise this program makes about storage it laid down; an extern variable is storage the linker
supplies — stdout, environ, optind — so there is no such promise to keep. It is a place, it may
be written, and it holds whatever the other side put there.
A module var is one object the whole program shares, which is what makes it the thing two threads
can disagree about. @thread_local in front of one gives every thread a copy of its own instead, so a
name that holds this thread’s machine, arena or scratch buffer needs no lock and no handle passed
down every call. The cost is that its initializer has to be a constant — every copy is made from one
image in the object file — and that a target with no thread-local storage refuses it rather than
emitting something silent. reference/attributes.md § @thread_local has the
rule and the refusals.
Several at once
var and val both take a comma list, binding several names to several values. Each part’s type is
inferred from its own value.
show()
val a, b = 1, 2
var lo, hi = 0, 10
print(a, b, lo, hi)
show()
1 2 0 10
Two or more names and an initializer are both required: one name is the ordinary form, and a multiple binding with nothing to take apart names nothing. The right side is produced before any name is bound, so a value there still means whatever the enclosing scope calls it — the binding does not shadow itself half way through its own right-hand side.
It is a local form. The parts carry no type annotation and there is nowhere to write one, so a
multiple val at the top of a file collides with the rule above and is refused rather than becoming
a quiet local of the entry point. A module member that wants the form declares its names separately.
The same spelling also takes a result list and a tuple apart, which is why a function with several results needs no special form at the call.
By pattern, when the shape matters
A comma list says how many things to bind. A pattern says the shape, and so reaches inside a tuple that holds another one — which is the whole of the difference between the two forms.
show()
val (a, b) = (1, 2)
val ((x, y), z) = ((3, 4), 5)
val (first, _) = (6, 7)
var (lo, hi) = (0, 10)
hi = hi + 1
print(a, b, x, y, z, first, lo, hi)
show()
1 2 3 4 5 6 0 11
A _ binds nothing and skips its part. A var pattern makes every name it binds assignable, and a
val pattern makes each of them write-once, exactly as the single-name forms do.
A struct pattern stands here on the same terms, naming fields rather than positions. It may name them in any order, may leave fields out, and may rename one to a sub-pattern:
struct Point
x: int
y: int
struct Line
a: Point
b: Point
show()
val Point{x, y} = Point(3, 4)
val Line{a: Point{x: ax}, b} = Line(Point(1, 2), Point(5, 6))
print(x, y, ax, b.x, b.y)
show()
3 4 1 5 6
A field the pattern does not name simply binds nothing — unlike a match arm, a binding has no
exhaustiveness to discharge.
The positional spelling Point(a, b) stands here too, and takes the fields in declaration order.
It differs in the one way it differs in a match: it names every field, so a struct that grows one
turns each positional binding into a checked to-do rather than one that goes on binding the same
names. See Patterns.
Only a pattern that cannot fail may stand at a binding — a tuple pattern, a struct pattern, a name, a wildcard, and those nested inside one another. A struct qualifies because it has exactly one shape, which is the same property that makes a tuple pattern irrefutable.
A literal, a range, or a variant is a test, and a binding has no other arm to take when the
test does not match, so each is refused with that as the reason. Those belong in a match (see
Patterns).
A pattern binding may state the type of the whole value, written after the pattern as a plain binding writes it after the name. It is the position the value is read in, so a literal inside the value takes its width from it, and the parts are taken apart at the stated type:
show()
val (a, b): (u64, u8) = (4000000000, 250)
var (lo, hi): (i16, i16) = (-1, 1)
hi = hi + 1
print(a + u64(b), lo, hi)
show()
4000000250 -1 2
A value the stated type does not fit is refused in the words a plain binding’s is:
show()
val (c, d): (u64, u64) = (5, "x")
print(c, d)
show()
cannot initialize the parts of this binding: declared (ulong, ulong) but the value is (ulong, string)
Like the comma form, this is a local form, so one at the top of a file is refused rather than becoming a quiet local of the entry point.
The same pattern stands in a
for header, where it takes the element
apart as the loop hands it over.
Functions
A name, a parameter list, an optional -> result, and a body. There is no keyword: the shape is what
identifies it. An absent result type means unit.
The body is either = expr — whose value is the result — or an indented block, whose trailing
expression is the result.
double(n: int) -> int = n * 2
sum(a: int, b: int) -> int
var t = a + b
t
greet(name: string)
print("hi", name)
print(double(21), sum(40, 2))
greet("you")
42 42
hi you
= may also open an indented block, so a body does not have to change shape when it outgrows a
line.
Tail calls
A function whose last act is a call to itself does not open a second frame. The call becomes a branch back to the function’s own entry, so the recursion is bounded by the arithmetic rather than by the stack:
count(n: int, acc: int) -> int =
if n == 0 then acc else count(n - 1, acc + 1)
print(count(1000000, 0))
1000000
A million frames is not a stack any machine has. Nothing is written to ask for this — it applies wherever it applies.
In tail position is the last thing the function does: the body’s trailing expression, the operand
of a return, and the arms of the if and match those reach through. Nothing may wait on the
result — n + count(n - 1) is an ordinary call, because the addition happens after it comes back.
A tail call is a call, so the jump lands where a call would: every require is checked again on
the arguments the jump wrote, and every old(e) is snapshotted again. A recursion that violates
its own precondition at depth four stops at depth four.
Three things end a tail position instead of being optimized around:
- a
deferin scope, which runs on the way out of a scope — after the callee returns for an ordinary call, and before it is entered for a jump; - an
ensureson the function, which is checked when a call returns, and a tail call never returns; - an argument that may point into the frame. A jump reuses the frame: the locals are released on
the way to it, and the next round writes every slot again, so
&subhanded down would be read aftersubwas freed or written over. Where the body takes the address of its own storage anywhere —&of a local, a parameter or a temporary, or a slice of a local array — a self-call whose arguments can hold an address (a pointer, a slice, or a value made of one) stays a call. A parameter passed on as it arrived — never written, its address never taken — is the caller’s, and does not count.
Any of them leaves the function compiled exactly as written:
import sysl.buf.{Buf, buf}
struct P
toks: Buf[string]
pos: usize
decl(p: *P, base: string) -> string
if p.pos < p.toks.len() && p.toks[p.pos] == "*" then
p.pos += 1
return decl(p, base + "*")
if p.pos < p.toks.len() && p.toks[p.pos] == "("
var inner: Buf[string] = buf()
inner.push("*")
var sub = P(inner, 0)
return decl(&sub, base + "()")
base
var toks: Buf[string] = buf()
toks.push("(")
var top = P(toks, 0)
print(decl(&top, "void"))
void()*
The call that passes &sub keeps its frame, so sub‘s buffer is alive while the callee reads it.
It is self-recursion only. Mutual recursion and calls through a Fn or a *extern are ordinary
calls, however they are written.
@tailrec
The jump is silent, which is what you want until an edit takes it away. @tailrec asserts it is
there and is refused when it is not:
@tailrec
sum(n: int) -> int =
if n == 0 then 0 else n + sum(n - 1)
print(sum(5))
calls itself nowhere the jump can replace
A self-call in tail position that may hand down an address in the frame is refused with its own reason:
struct S
n: int
@tailrec
walk(s: *S, acc: int) -> int
if s.n == 0 then return acc
var next = S(s.n - 1)
walk(&next, acc + s.n)
var s = S(4)
print(walk(&s, 0))
its tail call may pass an address in its own frame
It changes nothing about what is emitted — write it on the functions where losing the jump silently would be a bug, and leave it off the rest.
become — a call that replaces the frame
A tail call to the same function is the jump above, and it is an optimization: missing it costs speed. A tail call to a different function cannot be one. A chain of them is a loop only if every call in it is eliminated, so one that is not is an immediate stack overflow rather than a slowdown — and a program cannot rely on something it has no way to ask for.
become asks for it. It is return with the jump guaranteed:
even(n: int) -> bool
if n == 0 then return true
become odd(n - 1)
odd(n: int) -> bool
if n == 0 then return false
become even(n - 1)
print(even(1000000), odd(1000000))
true false
A million alternating frames is not a stack any machine has, and neither is ten million. Written as
an ordinary return odd(n - 1) the same program is a million frames deep and whether it survives is
a question about the optimizer.
The callee may be chosen while the program runs, which is what the technique this exists for needs: one function per state, each ending in a call to the next out of a table of addresses.
step(vm: *int) -> int
vm[0] += 1
if vm[0] >= 5 then return vm[0]
var table: [2]*extern(*int) -> int = [&step, &step]
become table[vm[0] % 2](vm)
var n = 0
print(step(&n))
5
become is not a reserved word. It is read as this form only where a call follows it, so a
variable or a function of that name goes on working:
var become = 41
become = become + 1
print(become)
42
What a become requires
The callee’s signature must match this function’s — the same parameter types in the same order, and the same result. That is what makes replacing one frame with another something a machine can do at all: the arguments land where the replaced frame’s were, and the result comes back the way this frame’s caller is waiting for it.
f(n: int) -> int
become g(n, 1)
g(a: int, b: int) -> int = a + b
print(f(1))
same shape
No parameter may carry a reference count. A frame lets go of what it holds along the edge it
returns on, and a become replaces the frame before that edge — so the releases happen before the
jump, and an argument read out of a slot that is about to be let go would name storage that is gone.
Pass what the callee needs as a value that carries no count, or through a *T.
struct Node
v: int
f(n: &Node) -> int
become g(n)
g(n: &Node) -> int = n.v
var x: &Node = Node(1)
print(f(x))
counted value
No argument may point into the frame being replaced. The callee runs in the replaced frame’s
place, so & of a local, a parameter or a temporary — or a slice of a local array — would hand it
storage that no longer exists. The test is the one a self tail call is held to: where the body takes
the address of its own storage anywhere, an argument that can hold an address (a pointer, a slice,
or a value made of one) is refused, unless it is one of the function’s own parameters passed on as
it arrived, which points at its caller’s storage:
struct S
n: int
f(s: *S, acc: int) -> int
if s.n == 0 then return acc
var next = S(s.n - 1)
become g(&next, acc + s.n)
g(s: *S, acc: int) -> int = f(s, acc)
var s = S(4)
print(f(&s, 0))
an argument may point into this frame
struct S
n: int
f(s: *S, acc: int) -> int
var probe = S(0)
val seen = &probe
if s.n == 0 then return acc + seen.n
s.n -= 1
become g(s, acc + 1)
g(s: *S, acc: int) -> int = f(s, acc)
var s = S(4)
print(f(&s, 0))
4
The rest are the tail call’s own, for the reasons Tail calls gives: an ensures is checked when a
call returns and a become never returns; a defer runs on the way out of a scope and the jump is
the way out; a decreases measure is checked against the frame being replaced; a variadic function’s
tail lives in that frame. An extern is refused too — its frame is C’s — and so is a result too
large to come back in a register, which travels through a pointer the replaced frame’s caller
supplied.
@abandons — a call that leaves its caller for good
A call to a function answering never does not come back, and two kinds of departure look alike at
the call. A trap, a panic, C’s exit: the program is over, and what its frames hold is left exactly
as it is — the state a debugger or a core file wants to see, which is why a trap runs nothing. A
thread that ends, or a jump back to a context saved earlier: the program goes on, and the stack
the call was made on is never resumed, so every count a frame on it holds is storage nothing will
ever free.
@abandons says a function is the second kind. A call to one ends the caller’s frame first,
exactly as a return would — its deferred statements run, then its counts are given back — and
then the call is made:
struct Node
v: int
impl Drop for Node
drop(self)
print("dropped", self.v)
@abandons
finish() -> never
exit(0)
work()
val n: &Node = Node(7)
defer print("deferred", n.v)
print("working")
finish()
work()
working
deferred 7
dropped 7
Without the annotation the same program prints working and nothing else: exit ends the process
with the node still counted, which is what a trap should do. That is why panic, assert and the
library’s exit are not @abandons, and why nothing changes for a program that never writes it.
It marks an extern as well, which is where a context switch or a longjmp written in assembly is
declared:
@abandons
extern "leave_user" leave_user(kernel: *u64, status: u64) -> never
What an @abandons function requires
It answers never, since a call that comes back has left nothing:
@abandons
finish() -> int = 0
print(finish())
'@abandons' says a call to 'finish' never comes back, so 'finish' answers 'never' — this one answers 'int'
No parameter may carry a count. The caller’s frame is given back before the call, and the
callee never returns to give back what its own parameters hold, so nothing would. Pass what it needs
as a value that carries no count, or through a *T — become‘s rule, for become‘s reason. A type
parameter is refused for the same one, since it may be such a type:
@abandons
finish(why: string) -> never
exit(1)
finish("done")
nothing is left to give back the count its parameter 'why' holds
A frame further down holds nothing across the call
The caller is given back; the frames below it are abandoned too, and cannot be, because a function
that merely reaches an @abandons call may also come back. So the compiler follows the calls — as
it does for @no_alloc — and refuses a frame that holds something across a
call that can arrive at one: a counted local, a binding a match took a share of, a counted
temporary the enclosing expression made, or a defer still to run.
@abandons
leave(code: int) -> never
exit(code)
handle(n: int)
if n == 0 then leave(1)
run()
val s = str(1) + "x"
handle(2)
print(s)
run()
'handle' can reach a call that abandons every frame below it and never comes back, and this frame holds 's' across the call
The repair is the one the message names: hold the value in a function that returns before the call is made, which leaves the frame that makes it holding nothing.
@abandons
leave(code: int) -> never
exit(code)
handle(n: int)
if n == 0 then leave(1)
show()
val s = str(1) + "x"
print(s)
run()
show()
handle(2)
print("done")
run()
1x
done
A call through a value or a trait object cannot be followed, so nothing that can arrive at an
@abandons call may become one — not the function’s address, not a closure or function that reaches
it, and not a type whose table for a trait object holds one:
@abandons
leave(code: int) -> never
exit(code)
val p = &leave
print(1)
'leave' is '@abandons', and a call through a value cannot be followed to it
Several results
A signature may declare more than one result, and the trailing expression or return supplies them
as a comma list.
minmax(a: int, b: int) -> int, int
if a < b
a, b
else
b, a
show()
var lo, hi = minmax(9, 4)
print(lo, hi)
show()
4 9
This is not a tuple. A tuple is a value with a type of its own; a result list is several values handed back at once, and the multiple binding above is what receives them.
A result list is a whole line by construction, so it cannot be the body of an inline branch:
if a < b then a, b else b, a does not parse, because the comma there would have to belong to the
branch rather than to whatever expression the branch is part of. The block form above is how a
conditional supplies several results.
Default parameters and named arguments
A parameter may say what a call that leaves it out gets instead. The default is a full expression, so a call or a conditional may stand there — it is evaluated at the call site that omitted it, and it may not name anything local to the declaration. Its declaration’s type parameters are not local in that sense; see below.
An argument may be written name = value, which stands at the parameter it names rather than at the
one its position would have given it.
box(w: int, h: int = 1, fill: string = "*") -> string
var s = ""
for i in 0..<w * h
s += fill
s
print(box(3), box(2, 2), box(2, fill = "#"))
*** **** ##
name = value is also a legal expression — assignment yields the value stored — so the two readings
collide, and the named argument is the one taken. It applies only where the name is a bare
identifier: p.x = 1 and b[i] = v are stores as they always were, since neither is a name a
parameter list could have written. A store to a plain variable is still reachable in an argument by
parenthesizing it.
A default is read at the type its parameter declares, so a value that says nothing about its own
type needs no annotation beside it: written where an Option[int] is wanted, None is that
parameter’s None. A method reads its defaults the same way, and the receiver is not one of the
arguments a call writes.
struct Room
seats: int
free(self, taken: Option[int] = None) -> int = taken match
Some(n) -> self.seats - n
None -> self.seats
end Room
var r = Room(40)
print(r.free(), r.free(Some(12)))
40 28
A default may itself be a closure, and it is typed by the same thing that types one written at
the call: the parameter it stands at. Neither spelling of a callable parameter needs an annotation
inside the closure, and the placeholder form _ * 2 needs none either.
apply(g: int -> int = y -> y * 2) -> int = g(21)
print(apply(), apply(x -> x + 1))
42 22
A default may name its declaration’s type parameters, in either position: an associated function reached through one, a conversion written at its name, or a type written inside the expression. A type parameter is part of the signature rather than something local to it, and a call knows it once the call is solved — from the arguments it wrote and the type it is expected to have — so the default is read at each call, at the type that call settled.
import sysl.math.Float
offset[T: Zero + Add](x: T, step: T = T.zero()) -> T = x + step
tuned[F: Float](f: F, a4: F = F(440.0)) -> F = f + a4
print(offset(2.5), offset(7), tuned(0.5))
2.5 7 440.5
The default takes no part in settling that type: a call that leaves the argument out and gives
nothing else to settle T from — start[T: Zero](x: T = T.zero()) called as start() with nothing
expected of it — is refused as one whose type argument cannot be inferred. What the default may not
name is unchanged: another parameter, and anything else local to the declaration, are as undefined
there as anywhere outside a body. And it is held to what the bounds promise where it is written, as a
body is — T.zero() with no Zero among T‘s bounds is refused at the declaration, whether or not
anything calls it — while a conversion written at T is checked at each call, as one in a body is
(Converting through a parameter).
A closure’s parameter declares no default. A call reaches a closure through the Fn traits,
which carry the types and not the names, so there would be nothing at the call to fill one from.
A trait member’s defaults and parameter names are part of the trait’s signature, so they reach
every call made through it: through a bound on a type parameter (a method, an associated function, a
where clause, an associated type’s bound) and through a trait object, exactly as on the type that
implements it. The default is the trait’s on every path, which is why an impl block’s member
declares none — a call through an object or a bound has no implementation to read one from — and an
implementation that replaces the member keeps the trait’s.
trait Greet
greet(self, n: int = 3, m: int = 10) -> int = n + m
struct A
x: int
impl Greet for A
via[G: Greet](g: G) -> int = g.greet()
named[G: Greet](g: G) -> int = g.greet(m = 100)
obj(g: &Greet) -> int = g.greet(n = 0)
print(A(1).greet(), via(A(1)), named(A(1)), obj(A(1)))
13 13 103 10
A trait’s default may name the trait’s own type parameters, and it is read at what the impl
fixed them to — f64 below, on every one of those paths. Where the impl is generic itself, at what
each instantiation gives its parameter. The default is still held to the trait’s bounds where it is
written: T.zero() in a trait Pad[T] with no Zero among T‘s bounds is refused, though every
type that implements it might have a zero.
trait Pad[T: Zero + Add]
pad(self, x: T, by: T = T.zero()) -> T = x + by
struct A
x: int
struct W[U]
u: U
impl Pad[f64] for A
impl[U: Zero + Add] Pad[U] for W[U]
via[P: Pad[f64]](p: P) -> f64 = p.pad(2.5)
obj(p: &Pad[f64]) -> f64 = p.pad(4.5)
val n: int = W(3).pad(4)
print(A(1).pad(1.5), via(A(1)), obj(A(1)), n)
1.5 2.5 4.5 4
trait Pad[T]
pad(self, x: T, by: T = T.zero()) -> T = x
'zero' needs 'T: sysl.Zero'
trait Greet
greet(self, n: int = 3) -> int = n
struct A
x: int
impl Greet for A
override greet(self, n: int = 5) -> int = n * 10
a member of an 'impl' block declares no default
A parameter that has no default is still owed through a bound, and the refusal names the trait’s member, since that is the signature the call was read against:
trait Greet
greet(self, n: int, m: int = 1) -> int = n + m
via[G: Greet](g: G) -> int = g.greet(m = 2)
method 'Greet.greet' was given no value for 'n', and that parameter declares no default
A parameter may collect the rest of the call
xs: ...T collects a call’s trailing arguments into a []const T, so a function that takes a
variable number of values has one parameter rather than an arity per shape. The body sees an ordinary
slice; the call writes the values out.
total(xs: ...int) -> int
var s = 0
for x in xs
s += x
s
print(total(1, 2, 3), total(7), total())
6 7 0
The parameters in front of it are ordinary and may be named; a call that supplies nothing for the rest parameter gets the empty slice, which is what “the rest” means when there is none.
label(tag: string, xs: ...int) -> string
var out = tag
for x in xs
out = out + " " + str(x)
out
print(label("sum", 1, 2))
print(label(tag = "none"))
sum 1 2
none
The heterogeneous form is the same parameter at a trait object. ...&Display is what a checked
print-alike takes, and each argument is erased where it stands.
show(xs: ...&Display) -> unit
for x in xs
print(x)
show(1, "hi", true)
1
hi
true
The array a call packs is laid out where the call is written, not on the heap — it is the array
literal [a, b, c][..] the program would otherwise have written by hand — so a variadic call costs no
allocator and a module that gave one up may still make one.
xs... at a call hands an existing slice straight through, which is the case a rewrite cannot
cover: a function forwarding its own tail has a slice and not a list of values.
total(xs: ...int) -> int
var s = 0
for x in xs
s += x
s
forward(xs: ...int) -> int = total(xs...)
var have = [7, 8]
print(forward(4, 5, 6), total(have[..]...))
15 15
Nothing may follow it, because the arguments that would be the next parameter’s are already inside it.
f(xs: ...int, last: int) -> int = last
print(f(1))
collects the rest of the call, so nothing can follow it
Its arguments are positional: a name picks out one parameter and this one takes as many as are left. It declares no default either, a call that leaves it out having an answer already.
A trait member may declare one, and it works through a trait object. The array a call packs is a temporary of the caller’s frame, and a trait object may hold on to what it is given — so such a call is one where the temporary has to outlive the frame that made it, and it is given storage of its own exactly as a declared array would be. That costs an allocation at a call that escapes and nothing at one that does not.
trait Sink
take(self, xs: ...int) -> int
struct Adder
base: int
impl Sink for Adder
take(self, xs: ...int) -> int
var s = self.base
for x in xs
s += x
s
var a = Adder(10)
val d: &Sink = a
print(a.take(1, 2, 3), d.take(1, 2, 3), d.take())
16 16 10
It is not C’s ellipsis, which is the other variadic and is written ... with no name and no type
— that tail is walked with va_arg and is the foreign one; see ffi. A declaration
may write one of them, and an extern may write only C’s: what this form hands over is a sysl slice,
which is three words with an owner in them and not what a C function was compiled to read.
Overloading
A name may be declared more than once, and every use of it still means exactly one declaration. Which one is decided by the arguments a use passes: how many, and what type each is.
show(x: int) -> string = s"int $x"
show(x: string) -> string = s"str $x"
show(x: int, y: int) -> string = s"pair $x $y"
print(show(1))
print(show("a"))
print(show(1, 2))
int 1
str a
pair 1 2
The result is never part of it. A pair differing only in what they return is refused where the second is written. sysl reads an expected type inwards, from the context to the expression, so a call whose meaning depended on its own result would need its context typed before it could be resolved and would need resolving before its context could be typed.
h(x: int) -> string = "s"
h(x: int) -> int = 1
function 'h' is already declared — which declaration a call means is decided by its arguments and never by what it returns, so two that differ only in the result have no call that tells them apart
That is the plain duplicate message with its sentence finished, and the wording is deliberate: the two parameter lists are the same, so this is one declaration written twice. Somebody who wrote that by accident is told so plainly; the clause is added only where the results differ, because that is the pair a reader wrote on purpose and expected to work.
The rule behind it is wider than the identical case. Each declaration takes a range of argument counts — from its parameters that have no default up to all of them — and two collide when their ranges overlap at some count and their first that-many parameters agree in type. So a difference hidden behind a default is refused too, and there the point is sharper: the default is unreachable, because no call could ever supply one argument to the longer declaration.
g(x: int) -> string = "one"
g(x: int, y: int = 0) -> string = "two"
'g' is already declared with parameters this one could not be told from — a call passing 1 argument would fit both, and which declaration a call means is decided by its arguments and never by what it returns. Two declarations of one name have to differ in a way a call site can show
Reporting that at the declaration rather than at the call is the point. The mistake is in the pair; reporting it where the name is used would report one mistake once per call site, in files whose authors did not write it.
A use that fits none of them, or several, is refused where the use is, and the message carries the roster — the reader’s question at that point is which declarations exist:
k(x: int) -> string = "a"
k(x: string) -> string = "b"
print(k(1.5))
no 'k' takes these arguments — the declarations of that name are:
k(x: int)
k(x: string)
Three tie-breaks decide a use that fits more than one. A candidate that needed no default fitted the call as written and beats one that did; and a candidate whose parameters are exactly the arguments’ own types beats one reached by a conversion — which is what lets a literal’s natural type choose between two widths:
width(x: int) -> string = "int"
width(x: i64) -> string = "i64"
print(width(1))
print(width(1i64))
int
i64
What is deliberately absent is any ranking between conversions. Two candidates each reached by a different one are ambiguous, and saying so beats a ladder of precedences nobody could predict from the source.
The one exception is a bare integer literal, which prefers the signed widening. Where the
candidates differ only at positions holding an integer literal with no suffix, and none takes it at
int, a candidate whose parameter there is a signed integer at least as wide as int beats an
unsigned, a narrower signed or a float one, and among several the narrowest wins. Two unsigned
candidates offer nothing signed and stay ambiguous. The rule is the same for a member several
implementations of one trait answer, so BigInt.from(3) is its From[long]:
pick(x: long) -> string = "long"
pick(x: u64) -> string = "u64"
print(pick(3))
print(pick(3u64))
long
u64
Exactness is asked of the types a candidate was fitted at, which for a generic one is what the call
solved it to. g[T] below takes the []int at T = []int, as it was written; the other takes it
only by giving up the ability to write. So the generic declaration is the exact one:
g(s: []const int) -> string = "const"
g[T](x: T) -> string = "generic"
var a = [1, 2, 3]
val v: []int = a[..]
print(g(v))
generic
The third tie-break is that a candidate that named its parameters beats one that was solved for
them, where both fitted at the same types. f(x: int) and f[T](x: T) both fit f(0) at int,
and the ordinary declaration is the one that said what it takes:
f(x: int) -> string = "plain"
f[T](x: T) -> string = "generic"
print(f(0))
plain
That ranks a declaration against a declaration, which is a different question from ranking the routes the arguments took — the thing the paragraph above refuses.
It reads “at the same types” rather than “exact” because the third question is worth asking where
the second found nothing. A type parameter is often inside a parameter’s type rather than being
the whole of it, and then an argument may reach every candidate by a conversion — leaving no exact
candidate at all, and two declarations that nonetheless fitted the call at one signature. Both calls
below reach g(x: []u8), and the array is the case where nothing is exact:
g(x: []u8) -> string = "plain"
g[T](x: []T) -> string = "generic"
var a: [3]u8 = [1, 2, 3]
val v: []u8 = a[..]
print(g(v))
print(g(a))
plain
plain
Where the candidates fitted at different types it stays ambiguous, which is the same refusal as before: two routes out of one argument, and nothing to choose between them that is not a preference among conversions.
An address chooses by the type the context wants, which is the mechanism a generic function’s address already uses. With no expected type there is nothing to read, and the address is refused rather than guessed at.
add(a: int) -> int = a + 1
add(a: int, b: int) -> int = a + b
val one: *extern(int) -> int = &add
val two: *extern(int, int) -> int = &add
print(one(41), two(6, 7))
42 13
Overloading a member
A type’s own members overload by exactly the rules above. A method or an associated function
may be declared more than once in a type’s body, and a call — s.f(…) or S.make(…) — means the
one its arguments fit, with the same tie-breaks. The receiver is not part of what tells them apart:
it is the same type in every one of them.
struct S
n: int
f(self, x: int) -> int = x + self.n
f(self, x: string) -> int = int(x.len)
f(self, x: int, y: int) -> int = x * y
make(n: int) -> S = S(n)
make(s: string) -> S = S(int(s.len))
val s = S.make("abcdefghij")
print(s.f(1), s.f("abc"), s.f(3, 4), S.make(2).n)
11 3 12 2
On a generic type the choice is made at the receiver’s instantiation, so a member taking T
beside one taking string is two members on a Box[int] and the call picks by its arguments, in a
generic body as well as outside one:
struct Box[T]
v: T
put(self, x: T) -> string = "same"
put(self, x: string, y: int) -> string = "pair"
g[T](b: Box[T], x: T) -> string = b.put(x)
val b = Box(1)
print(b.put(2), b.put("x", 3), g(b, 7))
same pair same
A pair a call could not tell apart is refused at the second, in the words a function pair gets, and its body is not read against the first’s parameters:
struct D
n: int
h(self, x: int) -> string = "s"
h(self, x: int, y: int = 0) -> int = 1
type 'D' already has a member named 'h' with parameters this one could not be told from — a call passing 1 argument would fit both
A call that several fit equally, or that none fits, is refused at the call with the roster:
struct W
n: int
width(self, x: u8) -> string = "u8"
width(self, x: u16) -> string = "u16"
print(W(0).width(1))
'W.width' is ambiguous here — 2 of its declarations take these arguments:
W.width(x: u8)
W.width(x: u16)
Three things do not overload, each because it is one name with one meaning rather than a set of calls:
-
A property is read without parentheses, so there is no argument list to choose by. A property and a method of one name are refused as before.
-
A trait’s member. An implementation supplies each member of its trait by name, and a bound reaches each by name, so a trait declares a name once:
trait Tr f(self, x: int) -> string f(self, x: string) -> stringtrait 'Tr' already declares a member named 'f' — an implementation supplies a trait's members by name, so a trait declares each name once -
A type’s own member and a trait’s member of one name still collide, whatever their parameters: the trait’s member is reachable only where the trait is in scope, and a name meaning a different set of declarations in different files is exactly what that rule keeps out.
Overloading an extern
Two externs of one name are two functions exactly when they name two symbols. A C library’s
naming is not sysl’s, and a family C spells _solid/_shaded/_blended is one operation with an
option — which a binding may now say, without inventing a sysl name per C symbol.
extern "strlen" size(s: *u8) -> usize
extern "strnlen" size(s: *u8, cap: usize) -> usize
Two naming the same symbol are refused. That is one C function claimed at two signatures, and the symbol is what gets emitted — both calls would reach the same code with different arguments, and nothing downstream could tell which had been meant.
extern "strlen" size(s: *u8) -> usize
extern "strlen" size(s: *u8, cap: usize) -> usize
'size' is already declared as an 'extern' for the symbol 'strlen' — two declarations of one name are two functions, and one C function cannot be two. Overloads of an 'extern' are told apart by the symbol each names, so give this one a symbol of its own or take its address and 'ptr_cast' it where the other signature is wanted
An extern and a sysl function do not overload each other, in either order, for the same reason:
what tells overloads of an extern apart is the symbol, and a sysl function declares none.
Type parameters
A bracketed list directly after the name declares type parameters, with optional bounds. See generics and traits.
A const function runs while compiling
const in front of a function’s name promises that the compiler can run it while it compiles,
so that a call to it can stand where a constant is required. The promise is
checked at the declaration, against the body and every function it calls. Called anywhere else it is
an ordinary function, and an ordinary call:
const sq(x: int) -> int = x * x
var n = 6
n += 1
print(sq(n))
49
The mark is a promise, not a permission. Any function whose body can run while compiling is run where a constant wants it, marked or not — modules § const shows one. What the mark adds is that the promise is held at the declaration: a later change to the body, or to something it calls, that would stop it running is refused there, rather than at a constant in some other package that relied on it.
The standard library marks what it promises. That covers:
sysl.math‘s integer helpers and comparisons:min,max,clamp,pow,gcd,lcm, the flooring division, the checked and overflowing arithmetic, the powers of two andmagnitude;Buf‘s constructors and members,Frozen‘s readers, andMapandSetwith their operations;sysl.slices‘ searches, comparisons and sorts;StrBuilder.
What it leaves unmarked still runs where its body can. Two things are left out on purpose: the C
library’s floating-point mathematics, and slices.copy, which compares addresses to handle
overlapping slices.
import sysl.buf.{Buf, buf}
import sysl.math.gcd
import sysl.slices.sort
const sorted_gcds(xs: []const int) -> Buf[int]
var out = buf[int]()
for i in 1..<xs.len
out.push(gcd(xs[i - 1], xs[i]))
sort(out.view())
out
const LEAST: int = sorted_gcds([12, 18, 27, 36][..]).at(0)
print(LEAST)
6
The word is the one sysl already uses for “settled while compiling” — a const declaration,
for const, a [const N: usize] parameter — and a const function is a constant with parameters.
const, then a name, then ( or [ is a function; const, a name and : is a constant. It sits
after a visibility and after the annotations, and it marks a member of a type, of an enum and of an
impl block the same way, an associated function included:
struct Money
cents: int
const zero() -> Money = Money(0)
const doubled(self) -> int = self.cents * 2
trait Weigh
weight(self) -> int
impl Weigh for Money
const weight(self) -> int = self.cents
private const heavy(m: Money) -> int = m.weight() + m.doubled()
print(Money.zero().cents, heavy(Money(5)))
0 15
A trait’s member is not marked. Whether a call can run while compiling is a fact about the body that answers it, and a trait’s member is a signature every implementing type answers with a body of its own:
trait Shape
const area(self) -> int
a trait's member is not marked 'const' — whether a call can run while compiling is a fact about the body that answers it, so each 'impl' marks the member it supplies
What its body may hold. Every integer width, f32 and real, bool, char and unit, and
structs, tuples, fixed arrays and enums made of them — payload-carrying ones included — all by value;
strings, built by +, interpolation, str and an f"…" specifier, indexed, sliced, compared and
matched; slices of arrays and of strings; constrained and new types; locals, assignment into a
local’s fields and elements and through a slice; if, match, loop, while, do … while, the C
for, for i in a..<b and for x in a fixed array or a slice; break,
continue, an early return, recursion; ?; require, ensure, a struct’s invariant and a loop’s
invariant and variant. It may call another const function, read a const and a val laid into
the object file, and reach the LLVM operations whose result is exactly specified (llvm.sqrt,
floor, ceil, trunc, round, roundeven, fabs, copysign, fma, ctpop, ctlz, cttz,
bswap, bitreverse, fshl, fshr, and the .sat integer operations):
extern "llvm.sqrt" root(x: f64) -> f64
static val base: int = 3
enum Shape
Dot
Square(side: int)
const area(s: Shape) -> int = s match
Dot -> 0
Square(n) -> n * n
const fact(n: int) -> int
require n >= 0
if n <= 1 then return 1
n * fact(n - 1)
const plus(n: int) -> int = fact(n) + base
const hyp(a: f64, b: f64) -> f64 = root(a * a + b * b)
print(area(Square(3)), plus(3), int(hyp(3.0, 4.0)))
9 9 5
What the compiler evaluates while compiling — scalars, fixed-point values, strings, and fixed
arrays, tuples, structs, enums and slices of them — is set out in modules §
const, with how a table a const function fills in a loop becomes constant
data.
A string is a value the compiler holds, so a const function may build one, take one apart and
answer one, and a val over the call is laid down as the literal it came to:
const label(i: int) -> string = s"ch$i:" + (if i % 2 == 0 then "even" else "odd")
const vowels(s: string) -> int
var n = 0
for b in s.bytes
if b == u8('a') || b == u8('e') || b == u8('i') || b == u8('o') || b == u8('u') then n += 1
n
const hex(n: int) -> string = f"0x${n}%04x"
static val NAMES: [3](string, int) = [(label(0), vowels("table")), (label(1), 0), (label(2), 0)]
static val H: string = hex(255)
print(NAMES[0].0, NAMES[0].1, NAMES[1].0, H, label(5)[0..<3])
ch0:even 2 ch1:odd 0x00ff ch5
A slice is a view of an array or a string the evaluation holds, so a write through a []T lands
in the array its caller passed. A slice may be answered only where it views what the caller handed
in, or module storage — a slice of the call’s own local would outlive it:
const bump(xs: []int)
for i in 0..<xs.len
xs[i] += 1
const local() -> []const int
var a: [3]int = [1, 2, 3]
bump(a[1..<3])
a[..]
const head() -> int = local()[0]
const K: int = head()
'local' answers a slice of, or a pointer to, storage local to its call, which ends when the call returns
A *self method, and a *T parameter, are admitted — sysl mutates through *self everywhere:
struct Counter
n: int
const bump(*self)
self.n += 1
const counted(k: int) -> int
var c = Counter(0)
while c.n < k
c.bump()
c.n
print(counted(4))
4
An address may come in, be stored, and go out, where the linker fills it in: the address of
module storage, of a field or an element inside it, and of a function — and null, which needs no
linker at all. While compiling, such an
address is held as the relocation it is, so a const constructor that stores one makes constant
data, laid into the object file as C’s static Display oled = { &bus, setup }; is
(ffi § What constant data is):
const f(p: *int) -> *int = p
var x = 1
val q = f(&x)
print(q == &x)
true
What such an address points at exists only when the program runs, so reading through one, or converting one to a number, is not evaluated — and neither is the address of thread-local or per-core storage. A function pointer may be stored and answered, and calling through it runs the function the address was taken of. On a bare machine, where storage must be constant data, the refusal says which:
module mylib
var x: i32 = 1
const peek(p: *i32) -> i32 = *p
val seen: i32 = peek(&x)
@export
begin() -> i32 = seen
reading through an address is not evaluated while compiling — the linker fills an address in, and what it points at exists only when the program runs
A reference, and a pointer to storage the run owns, are values while compiling. A box made with
&T, a local, an element or a field reached through & or * is held by the run itself, and a
write through one is seen wherever else the storage is held, as it is when the program runs. A
pointer may not outlive its call, nor be converted to a number or read as another type:
struct Node
v: int
next: Option[&Node]
bump(p: *int)
*p += 1
const total(k: int) -> int
var head: Option[&Node] = None
for i in 0..<k
head = Some(Node(i, head))
var sum = 0
var cur = head
while cur is Some(n)
sum += n.v
cur = n.next
bump(&sum)
sum
const T: int = total(5)
print(T)
11
The heap is the run’s own, so a Buf, a Map, a Set and a frozen buffer run while
compiling by their own library bodies, as they do when the program runs. A library’s panic — an
index past the end — is a trap there, as it is here:
import sysl.buf.{Buf, buf}
import sysl.container.{Map, map}
const counts() -> int
var m: Map[string, int] = map()
var seen = buf[string]()
for w in ["to", "be", "or", "not", "to", "be"]
if !m.has(w) then seen.push(w)
m.put(w, m.get(w).unwrap_or(0) + 1)
int(seen.len()) * 10 + m.get("be").unwrap_or(0)
const C: int = counts()
print(C)
42
A closure and a trait object are called while compiling as the program calls them — a closure
where it is written, handed on as a type argument or a &Fn, kept in a buffer, returned with state of
its own; a call through a trait object goes to the function its table names. An atomic operation on
storage the run owns is the plain one, since a run while compiling is one thread:
import sysl.sync.{Atomic, SeqCst}
counter() -> &Fn() -> int
var n = 0
() ->
n += 1
n
const ticks() -> int
val c = counter()
var a = Atomic[int](0)
for _ in 0..<3
a.add(c(), SeqCst)
a.load(SeqCst)
const T: int = ticks()
print(T)
6
A box of a type with a Drop is refused, since its drop runs when the box’s last share goes, and
when that is, is decided by counts kept only when the program runs:
struct Lamp
on: bool
impl Drop for Lamp
drop(self)
print("off")
lit() -> bool
val l: &Lamp = Lamp(true)
l.on
const ON: bool = lit()
print(ON)
a box of a 'Lamp', which has a 'Drop', is not made while compiling — its 'drop' runs when the last share of the box goes, and when that is, is decided by counts kept only when the program runs
What it may not hold is everything that exists only once the program runs, and the few kinds of value the compiler does not evaluate yet. Each is refused where it was written:
| construct | refusal |
|---|---|
| a call to a function whose body cannot run | 'f' is 'const', so it runs while compiling, and it calls 'noisy', which prints, which is outside the program — nothing outside the program exists while compiling — the calls are followed down to what stops them |
print | … it prints, which is outside the program — nothing outside the program exists while compiling |
the C library’s mathematics (sin, exp, pow, cbrt…) | … it calls 'c_sin', which is the C library's 'sin' — its last bit differs between C libraries, and the board's would not agree with this compiler's |
any other extern | … it calls 'abs', which is outside the program — nothing outside the program exists while compiling |
an asm block | … it holds an 'asm' block, which is outside the program — nothing outside the program exists while compiling |
| a weak reference | … it uses a 'weak P' — a weak reference answers whether its object is still alive, which is decided by counts kept only when the program runs |
a value rendered through a display whose body cannot run | … it calls 'R.display', which prints, which is outside the program — nothing outside the program exists while compiling |
| a pointer converted, a C string | … it converts a pointer — an address exists only when the program runs (and takes the address of a C string) |
module storage filled at run time — a var, a val computed at startup | … it reaches 'counter' — 'counter' is filled when the program runs |
| C variadic arguments | … it walks C variadic arguments — C variadic arguments are not evaluated while compiling yet |
a float vector’s sum | … it sums a float vector, whose lanes are added in the order the target's instructions take — so the value is known only where the program runs |
One worked refusal for each of the commonest. A function that is not marked is no reason to refuse; what its body does is:
plain(x: int) -> int = x + 1
noisy(x: int) -> int
print(x)
plain(x)
const f(x: int) -> int = plain(x)
const g(x: int) -> int = noisy(x)
print(f(1), g(1))
'g' is 'const', so it runs while compiling, and it calls 'noisy', which prints, which is outside the program — nothing outside the program exists while compiling
const f(x: int) -> int
print(x)
x
print(f(1))
'f' is 'const', so it runs while compiling, and it prints, which is outside the program — nothing outside the program exists while compiling
extern "sin" c_sin(x: f64) -> f64
const f(x: f64) -> f64 = c_sin(x)
print(f(1.0))
'f' is 'const', so it runs while compiling, and it calls 'c_sin', which is the C library's 'sin' — its last bit differs between C libraries, and the board's would not agree with this compiler's
static var counter: int = 0
const f() -> int = counter
print(f())
'f' is 'const', so it runs while compiling, and it reaches 'counter' — 'counter' is filled when the program runs
struct R
n: int
impl Display for R
display(self, out: *Writer, fmt: FormatSpec)
print("rendering")
display_int(long(self.n), out, fmt)
const f(r: R) -> string = str(r)
print(f(R(1)))
'f' is 'const', so it runs while compiling, and it calls 'R.display', which prints, which is outside the program — nothing outside the program exists while compiling
Text is rendered while compiling as the program renders it. A float goes through the library’s
own renderers — the shortest round trip for str, the exact digits under a specifier — so -0, a
subnormal, inf and NaN come out spelled as at run time. A value whose type’s display can run
while compiling runs it with a writer the evaluation keeps: write appends to it, the library’s display_* routines
and the display of every type the library renders write into it, and a nested display is handed
the same writer:
struct Pt
x: int
y: real
impl Display for Pt
const display(self, out: *Writer, fmt: FormatSpec)
out.write("(".bytes)
self.x.display(out, fmt)
out.write(", ".bytes)
self.y.display(out, fmt)
out.write(")".bytes)
const label(p: Pt) -> string = f"$p ${p.y}%.3f ${p.y}%e ${p}%4s"
static val L: string = label(Pt(-2, 0.1))
print(L)
(-2, 0.1) 0.100 1.000000e-01 ( -2, 0.1)
A generic const function is checked at its definition for everything that does not go through a
type parameter; a call through a bound is answered by whichever type fills the parameter:
const twice[T: Add](x: T) -> T = x + x
print(twice(21), twice(u8(2)))
42 4
noisy() -> int
print("asked")
1
const g[T](x: T) -> int = noisy()
print(g(5))
'g' is 'const', so it runs while compiling, and it calls 'noisy', which prints, which is outside the program — nothing outside the program exists while compiling
Calls where a type is read
A call may stand wherever a type needs a value: an array length in a field, a parameter or an
alias, a value argument, and an @assert beside the declarations. It is run while the declarations
are read. A body of arithmetic and plain calls runs where it stands. One that calls a type’s members
— push on a Buf, a method of the program’s own — waits until every declaration has been read,
and the declarations are then read again with its value in hand:
import sysl.buf.{Buf, buf}
sq(n: int) -> int = n * n
primes_below(n: int) -> int
var out = buf[int]()
for i in 2..<n
var p = true
for d in 2..<i
if i % d == 0 then p = false
if p then out.push(i)
int(out.len())
struct Table
primes: [primes_below(30)]int
cells: [sq(2)]u8
type Quad = [sq(2)]u8
@assert(primes_below(10) == 4, "four primes below ten")
val t = Table([0; 10], [0; 4])
val q: Quad = [1, 2, 3, 4]
print(t.primes.len, t.cells.len, q.len)
10 4 4
A value argument is an expression too, in a call or in a type — a call, arithmetic over a
const, a comparison for a bool parameter:
cap(n: int) -> int = n * 2
const L: int = 3
struct Ring[T, const N: int]
items: [N]T
f[const N: int]() -> int = N + 1
val a: Ring[int, cap(4)] = Ring([0; 8])
val b = Ring[int, L + 1]([0; 4])
print(f[cap(3)](), a.items.len, b.items.len)
7 8 4
A declaration whose value needs that declaration itself is refused, naming both — reading it waits for the value, and the value is worked out by reading it:
struct S
items: [f()]int
f() -> int = int(sizeof(S))
print(f())
'S' cannot be read while compiling: it needs the value of a call to 'f', and running 'f' reads 'S' itself — a declaration cannot wait on a value worked out from it
Two declarations that each wait on the other are refused the same way, each naming the other. A value argument that is not a constant is refused where it is written, and the generic is not read with it:
f[const N: int]() -> int = N + 1
var n = 3
print(f[n]())
a value argument must be a constant — a literal, or a 'const' naming one
Structs
A named product type. Fields are declared one per line; methods, properties and an invariant may
follow among them, in any order.
struct Rect
w: int
h: int
invariant w > 0 && h > 0
area(self) -> int = self.w * self.h
perimeter -> int = 2 * (self.w + self.h)
scale(*self, k: int)
self.w *= k
self.h *= k
end Rect
var r = Rect(3, 4)
print(r.area(), r.perimeter)
r.scale(2)
print(r.area(), r.w, r.h)
12 14
48 6 8
Four things are on display there.
A constructor is the struct’s name applied to its fields, in declaration order. There is no separate constructor declaration to write or to keep in step.
A field declares no default. What an unwritten field gets is decided by the constructor that
builds the value, not by the field — and the compiler says so rather than leaving a = v after a
field to fail as whatever the grammar happened to want there.
A property
A property is a method with the parameter list left off — perimeter -> int, called as
r.perimeter with no parentheses. It takes the same body forms a method does.
The parentheses are the whole of the difference at the call, and the declaration is what decides them. A member declaring a parameter list is called with one; a member declaring none is read without one. The two are not interchangeable in either direction, so the declaration settles how every call site reads:
struct R
w: int
area(self) -> int = self.w * self.w
perimeter -> int = self.w * 4
var r = R(3)
print(r.area(), r.perimeter)
9 12
struct R
w: int
perimeter -> int = self.w * 4
var r = R(3)
print(r.perimeter())
'perimeter' is a property of 'R' — read it as 'value.perimeter', without '()'
That is what makes the choice a piece of documentation rather than a preference. A property reads
as though it were a field, so it should cost what a field costs; a member that walks, allocates, or
can fail takes the list, and its parentheses are the warning. Note that self is what makes a member
a method at all — a parameter list with no receiver in it declares an associated function,
reached through the type rather than through a value. A property has no parameter list to write one
in, which is what static is for.
A property may be settable
A property may be written as well as read, by declaring a setter beside it. set is a contextual
word — an ordinary identifier everywhere else — and the parameter carries no type, because a setter’s
value is the property’s result and can be nothing else:
struct Temp
c: int
f -> int = self.c * 9 / 5 + 32
set f(v)
self.c = (v - 32) * 5 / 9
end Temp
var t = Temp(0)
t.f = 212
print(t.c, t.f)
100 212
The setter’s receiver is an implicit *self, as the getter’s is an implicit borrow, so it is held to
what any *self member is: the receiver needs an address, and a val is written once. A setter
needs a property of the same name — a set-only property would leave t.f meaning nothing, and would
have nowhere to take its value’s type from. Visibility is the ordinary modifier, so private set f(v)
is a property the world reads and only the type writes.
A write is a call rather than a store, which is what a property computing rather than naming
storage amounts to. Three things follow, and they are the same three that follow for an element
reached through Index: the expression yields unit rather than the
value assigned, the write cannot be one place of a multiple assignment, and &t.f is refused because
there is no address.
The compound forms do work, and that is where a property differs from an element: there is no index to evaluate twice, and the receiver’s address is taken once for the pair of calls.
struct Cell
v: int
count -> int = self.v
set count(x)
self.v = x
end Cell
var c = Cell(1)
c.count += 1
c.count *= 5
print(c.count)
10
An accessor may not reach the member it is defining. count -> int = self.count calls itself,
and so does a setter writing self.count; there is no reading under which either is what was meant,
so both are refused rather than left to run out of stack:
struct Cell
v: int
count -> int = self.count
var c = Cell(1)
print(c.count)
'count' reads the property it is defining, so it calls itself — the value a property computes is not the property. What a body like this means to read is the field it is in front of
Reading self.count inside set count is left alone, and terminates: that calls the getter, which
is a different member.
The receiver says how the method reaches its value, and is written as the first thing in the parameter list:
| receiver | meaning |
|---|---|
self | by value — the method gets a copy |
*self | by pointer — the method may write through it |
&self | by reference, counted |
&sync self | by reference, and safe to share across threads |
| (none) | an associated function — no receiver, called on the type |
“A copy” is a promise about what the method can observe, not a cost it pays. Nothing done to
the caller’s value while the method runs reaches its self, and nothing the method does to its
self reaches the caller. A method that only reads a large receiver is therefore not handed a fresh
copy of it: it reads the caller’s value in place, and the compiler stages a snapshot only where the
original could change during the call — through a *T aliasing it, say — or where the method
writes to its own self. So a self method on a struct of kilobytes costs no more stack than a
*self one, and the choice between them is about what the method may do, never about size.
What “could change” means is decided by type, and conservatively. A receiver of type T is
handed over in place, with no copy and no count taken on what it holds, when nothing the method
does before it returns — itself or anything it calls — writes storage that could be part of a T:
- a write to the method’s own locals never counts, nor one through a pointer, a reference or a
view to a type that neither holds a
Tnor is a part of one — an output buffer of[4]u32beside a receiver that holds no[4]u32, a lock word of another struct; - a write through a pointer to a part of
Tcounts (a*u32may point at au32field), as does one through a pointer type the program anywhere makes withptr_cast, or reads at another type with a reinterpreting view orbytes, since such a pointer may point anywhere; - so does a call into C, through a closure or a trait object, a write that replaces a counted value, and making a value whose type has a destructor, since any of those can run code the compiler cannot see;
- and so does any argument worked out after the receiver that calls or writes something, since the receiver is lent from the moment its address is taken.
Everything else gets the snapshot, so the program sees exactly what a copy would. An argument after the receiver that writes it is the case a reader may not expect:
struct Big
n: int
table: [1024]u32
look(self, k: int) -> int = self.n * 10 + k
later(*self) -> int = self.look(poke(self))
poke(b: *Big) -> int
b.n = 5
0
var b = Big(1, [0; 1024])
print(b.later(), b.n)
10 5
Both halves of the promise, on a receiver of four kilobytes. snap is handed a pointer to the very
value it was called on and writes through it, and its self does not move; bumped writes its own
self, and the caller’s value does not move:
struct Big
n: int
table: [1024]u32
snap(self, p: *Big) -> int
val before = self.n
p.n += 1
before * 100 + self.n
bumped(self) -> int
self.n += 1
self.n
var b = Big(1, [0; 1024])
print(b.snap(&b))
print(b.bumped(), b.n)
101
3 2
struct Point
x: int
y: int
origin() -> Point = Point(0, 0)
var p = Point.origin()
print(p.x, p.y)
0 0
A &self method may keep what it was called on
The three receivers differ in what a method may do with the value after it returns, and that is
the whole of the distinction. self hands the method a copy, so nothing it builds can refer back
to the original; *self hands it an address, which is good for the length of the call and cannot be
kept, because the caller’s value may be gone by the time anything read it. &self hands it a share
of the box, so a value the method constructs may hold on to the receiver and outlive the call.
That is what makes a resource handle writable as one type. A method taking self cannot name the
box it was called through — it has a copy, and constructing a fresh value around the same resource
would hand out a second owner, so a destructor would run twice. &self gives it the box itself:
struct Env
private n: int
read(&self) -> Txn = Txn(self.n + 1, self)
impl Drop for Env
drop(self)
print("env closed")
struct Txn
private t: int
private env: &Env
which(self) -> int = self.t
depth(self) -> int = self.env.n
var e: &Env = Env(7)
var first = e.read()
var second = e.read()
print(first.which(), second.depth())
print("done")
8 7
done
env closed
Two things in that output are the point. env closed prints once although two transactions
were made from one environment — they hold shares of one box rather than owners of two. And it prints
last: the environment outlives every value that kept a share of it, which is exactly the
guarantee a handle needs, since a transaction that outlived its environment would be reading freed
memory.
A &self method needs a box to be called on, and a value on the stack is not one. The receiver
has to be something a share can be taken of, so this is refused rather than silently copied:
struct Counter
n: int
keeper(&self) -> Keeper = Keeper(self)
struct Keeper
private c: &Counter
reading(self) -> int = self.c.n
var plain = Counter(5)
print(plain.keeper().reading())
'&self' needs a counted reference, and this receiver is a Counter on the stack — a '&self' method may keep hold of what it is called on, so what it wants is a share of a box rather than an address. Bind the value into one and call it on that: 'var r: &Counter = …'
Binding it into a box is the whole of the fix, and the annotation is what makes the binding one:
struct Counter
n: int
keeper(&self) -> Keeper = Keeper(self)
struct Keeper
private c: &Counter
reading(self) -> int = self.c.n
var boxed: &Counter = Counter(5)
print(boxed.keeper().reading())
5
Declaring one &self member costs the type nothing else. A type may take all three receivers,
and the other two behave on a boxed value exactly as they do on a stack one — Counter above could
carry a doubled(self) and a bump(*self) beside keeper(&self), and every one of them would be
callable on plain and on boxed alike. The receiver is a property of the member rather than of
the type, which is what lets a handle expose an ordinary read by value beside a method that hands its
box on.
The one place a &self method cannot be reached is through a *Trait, since a raw trait object
holds no box for a share to be taken of. reference/traits.md says so where it lists what a *Trait
may carry.
A static property
A property has nowhere to say self. A member’s receiver is written in its parameter list, and
a property is a method with that list left off — so the one thing that separates an instance member
from an associated one cannot be spelled, and every property above is an instance member by
construction. static is what says otherwise:
struct Temp
n: int
static freezing -> int = 32
print(Temp.freezing)
32
It is read on the type, with no parentheses — Temp.freezing. That is an associated function
whose call the reader does not write, and it is what the form is: static says the member belongs to
the type rather than to a value of it, and everything else about a property is unchanged, including
the body forms and the rule about what a property should cost.
Reading one on a value is refused, which is the mirror of the refusal an instance property gets when it is read on a type:
struct Temp
n: int
static freezing -> int = 32
print(Temp(1).freezing)
'freezing' is a property of the type 'Temp' rather than of a value of it — read it as 'Temp.freezing'
The body has no receiver either, so it cannot name self. That is why the word is required
rather than inferred: “a property that never names self is static” would make a member’s
reachability depend on its body, so deleting a self. from an expression would silently move the
member from the value to the type and break every call site.
A trait may require one, and that is what a static property is chiefly for. A type parameter is not a value, so a fact about the type — a zero, a limit, a width — has nowhere to arrive from unless a bound can carry it:
trait Bounded
static lowest -> int
struct Age
n: int
impl Bounded for Age
static lowest -> int = 18
lowest_of[T: Bounded]() -> int = T.lowest
print(lowest_of[Age]())
18
lowest_of never mentions Age, and there is no value of it anywhere in the program.
A trait’s static property is a requirement rather than a default, so it carries no body — the same rule every receiverless trait member has, since a default would give every implementation one constant:
trait Bounded
static lowest -> int = 0
struct Age
n: int
impl Bounded for Age
print(Age.lowest)
'Bounded.lowest' is a property of the type, so a default body would give every implementation the same value — drop the body and let each one supply it
It takes no type parameters, for the reason an ordinary property does not: a read has no
arguments and no receiver, so there would be nothing to solve them from. And a member written with a
parameter list is an associated function already, reached the same way and called with () — so
static in front of one is refused rather than being a second spelling of what already exists.
static is not a new word: it is the same one that makes a binding in an entry file belong to the
module rather than to that file’s body (bindings). The two are
told apart by what follows it.
invariant
A condition every value of the struct must satisfy, re-checked whenever the struct is built or one of its fields is written. Bare field names are in scope inside it. A multiple assignment re-checks it once, after every write has landed, which is what lets two fields that relate to each other be changed together.
invariant is a contextual word — an ordinary identifier everywhere else. See
errors and contracts for what happens when one is broken.
A struct with no fields
A struct may declare no fields at all. Its emptiness has to be written — the end marker, optional
everywhere else, is what says so — because a struct whose body the author forgot to indent looks
exactly like one that has no body, and that is much the likelier mistake.
struct Stdout
end Stdout
impl Fallible for Stdout
impl Writer for Stdout
write(*self, bytes: []const u8) = putbytes(bytes)
show[T: Display](x: T)
var out = Stdout()
x.display(&out, FormatSpec(0, -1, false))
printc('\n')
show(42)
show("through a sink of one's own")
print(sizeof(Stdout))
42
through a sink of one's own
0
What wants one is a sink: a value standing for a destination fixed at compile time — the console, a serial port — which has nothing to keep and so has no field to keep it in. Being a value rather than a global is what lets it be passed to a function, held in a struct, and chosen by a caller.
Such a type occupies no bytes, so embedding one costs the struct holding it nothing. The cost of that
is that two of them have nothing to tell their storage apart, and &a == &b on two such locals may
well be true. There is no state behind either address for the answer to be about.
opaque
opaque struct Name withholds the layout from every module but the one declaring it. Outside, the
type is incomplete: only *Name may be said, so a value cannot be built, copied, or have a field
read. This is a different axis from visibility — private decides who may say the name, opaque
decides who may know the shape. See modules.
An opaque struct with no body at all is C’s incomplete type, struct sqlite3; — nothing in sysl lays
one out, and the declaration exists so that *Session is a type a *u8 cannot be mistaken for.
Members in an impl block of their own
An impl naming no trait adds members to a struct or an enum, written in any file of the module
that declares the type. A type whose methods outgrow its declaration — a Quaternion with fifty
operations, an enum with a parser and a printer — keeps its fields together and its members where
they read best:
// geom/quat.sysl
module geom
struct Quat[F]
w: F
x: F
// geom/ops.sysl
module geom
import sysl.math.Float
impl[F: Float] Quat[F]
norm2(self) -> F = self.w * self.w + self.x * self.x
scaled(self, k: F) -> Quat[F] = Quat(self.w * k, self.x * k)
The block’s members are the type’s own exactly as the body’s are: no trait to import, reached wherever the type is, and one namespace with the body — so a name the body already uses is a duplicate whichever was written first:
struct P
n: int
show(self) -> int = self.n
impl P
show(self) -> int = 2
print(P(1).show())
type 'P' already has a member named 'show'
A member of the block may be private or private[m], with the same reach as one written in the
body — the file that wrote it, or the module it names. A trait’s impl has no such modifier, since
what it supplies is as visible as the trait:
struct P
n: int
impl P
private twice(self) -> int = self.n * 2
shown(self) -> int = self.twice()
print(P(4).shown())
8
The block’s header binds the type’s parameters, under names of its own, as a trait’s impl
does. A bound it adds is a condition on the members it brings: they exist only where the
receiver’s arguments meet it, while the type and its body’s members stay as they were. It is the
inherent counterpart of a conditional conformance:
struct Pair[T]
lo: T
hi: T
swapped(self) -> Pair[T] = Pair(self.hi, self.lo)
impl[T: Ord] Pair[T]
larger(self) -> T = if self.lo < self.hi then self.hi else self.lo
struct Tag
n: int
print(Pair(3, 7).larger())
print(Pair(Tag(1), Tag(2)).swapped().lo.n)
7
2
A Pair[Tag] is built and swapped; asking it for larger is refused at the call, naming the bound
the block wrote:
struct Pair[T]
lo: T
hi: T
impl[T: Ord] Pair[T]
larger(self) -> T = if self.lo < self.hi then self.hi else self.lo
struct Tag
n: int
print(Pair(Tag(1), Tag(2)).larger().n)
'Pair.larger' requires its type parameter 'T' to implement 'sysl.Ord', but Tag does not
Another module may not add members: a type’s own members are part of what its declaration promises, and what another module wants to add is a trait, which it may implement for the type:
impl[T] Option[T]
or_zero(self) -> int = 0
print(1)
'sysl.Option' is the library's, and an 'impl' naming no trait adds members to a type only in a file of the module that declares it
Enums
Two shapes under one keyword. A simple enum is a set of named discriminants over an underlying integer type; a data enum gives its variants payloads, which makes it a sum type. Both may carry members.
enum Status: u8
Ok = 0
Warn = 10
Fail = 20
severe(self) -> bool = u8(self) >= 10
print(u8(Warn), Ok.severe(), Fail.severe())
10 false true
The : u8 pins the storage; without it the compiler picks. A variant is a bare name, a name with an
explicit integer value, or a name with a payload:
enum Shape
Circle(r: real)
Rect(w: real, h: real)
area(self) -> real = self match
Circle(r) -> 3.14159 * r * r
Rect(w, h) -> w * h
print(Circle(1.0).area(), Rect(2.0, 3.0).area())
3.14159 6
A member is told from a variant by what follows it: a member needs a body after its header, so
Circle(r: real) — a header with nothing after it — is a variant.
An underlying type belongs to a simple enum alone. : u8 pins the storage of a set of
discriminants; a data enum has a payload beside its tag, so there is no single integer for the
annotation to be about and the tag’s width is the compiler’s to choose:
enum Shape: u8
Circle(r: real)
Rect(w: real, h: real)
print(Circle(1.0))
only a simple enum has an underlying integer type — 'Shape' carries data
Option[T] and Result[T, E] are ordinary data enums declared in the standard library, with no
compiler privileges.
Type declarations
type Name = Existing introduces a second spelling, interchangeable with the first. It creates no
new type and no checking — an alias is for shortening a name that has grown long. The base may be
anything a type expression can name:
struct Reading
at: int
value: f64
type Sample = Reading
type Window = [4]Sample
var w: Window = [Sample(0, 1.5), Sample(1, 2.5), Sample(2, 3.5), Sample(3, 4.5)]
print(w[2].at, w[2].value)
2 3.5
An alias may take type parameters, written in brackets after its name as a struct’s are. A generic
alias is just as transparent: Pair[int, string] is (int, string), the same type with the same
instantiation, so a value of one goes wherever the other is asked for, and a type argument is inferred
through it as through what it names. A bound on a parameter is held at every application, and a
default may be given as on a struct:
type Pair[A, B] = (A, B)
swap[A, B](p: Pair[A, B]) -> Pair[B, A] = (p.1, p.0)
val p: Pair[int, string] = (1, "one")
val q: (string, int) = swap(p)
print(q.0, q.1)
one 1
An application gives exactly as many arguments as the alias takes, less any it has defaults for:
type Pair[A, B] = (A, B)
first(p: Pair[int]) -> int = p.0
alias 'Pair' takes 2 type arguments, but 1 type argument was given
Adding new makes it a genuinely distinct type, and within and where add checked bounds:
type Meters = new f64
type Slot = new u8 within 0..<8
var d = Meters(1.5)
var s = Slot(3)
print(f64(d) * 2.0, u8(s))
3 3
A new type’s name converts from whatever its base’s name converts from: Phase(3) over
type Phase = new real is Phase(real(3)), accepted exactly where real(3) is. Only the written
conversion does it — a 3 or an int is still not a Phase where one is wanted:
type Phase = new real
val n = 3
print(f64(Phase(3)) + f64(Phase(n)))
6
new, within and where are contextual words, so a function or field may still be named where.
See errors and contracts for what a bound costs and when it is checked, and for
how a new type converts.
A new type may take parameters
A new type may take type parameters too, and they are phantom: the base is still a scalar, so
UserPtr[Frame] and UserPtr[u8] are both laid out as a u64 — and are two types, neither of which
passes for the other or for the u64. What the parameter buys is that a function can read it back. A
conversion names the arguments, or takes them from where the value goes, and u64(p) reads the base
back as for any new type:
struct Frame
sp: u64
pc: u64
type UserPtr[T] = new u64
size_at[T](p: UserPtr[T]) -> usize = sizeof(T)
next[T](p: UserPtr[T]) -> UserPtr[T] = UserPtr[T](u64(p) + u64(sizeof(T)))
val f = UserPtr[Frame](4096)
val b: UserPtr[u8] = UserPtr(4096)
print(size_at(f), size_at(b), u64(next(f)), u64(next(b)))
16 1 4112 4097
The argument is part of the type, so one written where another is wanted is refused:
struct Frame
sp: u64
type UserPtr[T] = new u64
take(p: UserPtr[Frame]) -> u64 = u64(p)
print(take(UserPtr[u8](1)))
'p' of 'take' is UserPtr[Frame], but UserPtr[byte] was given
An argument that is only a second name for a type — a measured c type, an exported alias — is that
type here as everywhere, so UserPtr[Word] over @export type Word = u64 is UserPtr[u64].
Only a plain new type takes parameters. A within, where or wrapping type is one check, and
an @export type one name in a header, so there is nothing for an argument to fill:
type Slot[T] = new u8 within 0..<8
'Slot' takes type parameters, and only a transparent alias or a plain 'new' type may
Traits, impls, and externs
trait Name declares a set of requirements; impl Trait for Type supplies them. Both are covered on
traits.
extern declares a function or a variable the other side of the link owns, and is covered on
the foreign interface.
Visibility
A declaration is public unless it says otherwise — the unmarked case is the one that writes nothing, because it is the common one.
| written | reach |
|---|---|
| (nothing) | public — anything that can see the module can see it |
private | this file only |
private[mod] | the named enclosing module and everything under it |
A struct’s and an enum’s members and fields each take their own modifier, so a type may be public
while part of its shape is not. A trait’s members and an impl block’s take none: a trait’s member is
as visible as the trait, and an implementation supplies what the trait asked for.
The details — what a module is, how the reach is computed, and how visibility interacts with opaque
— are on modules.
end markers
Every block-shaped declaration may be closed with end Name, naming what it closes. It is optional
everywhere and checked when written, so it cannot drift from the thing it claims to close.
The one place it is required is a struct with no fields, where it is the only thing distinguishing a body that is deliberately empty from one that was meant to be there.
A binding whose value is an indented block takes one too, naming the binding — end x after a
val x = or a var x = — and so does one whose value ends in a block, such as a match:
classify(n: int) -> string
val kind = n match
0 -> "zero"
_ -> "some"
end kind
kind
print(classify(0))
zero
A marker that names something else is refused, naming both — a declaration’s end Name under
another declaration, a statement’s marker under a declaration, or another name under a binding:
total(xs: []int) -> int
var sum = 0
for x in xs
sum += x
sum
end while
print(total([1, 2]))
'end while' does not match 'total'
var limit =
val base = 10
base * 2
end bound
print(limit)
'end bound' cannot close the 'var limit' above it — its marker is 'end limit'
A marker closes a block, so one after a value written on one line is refused — there is nothing for it to close, and it would only add clutter:
val x = 1
end x
print(x)
'end x' has no block to close — the 'val x' above it ends on a one-line form, which takes no marker
The statements’ own markers — end if, end while, end for, end loop and end match — are on
statements, under the same rules.
end is a soft word: it is an ordinary identifier everywhere except immediately before a name or
a construct keyword, so end stays usable as a variable — end match with arms indented under it is a
match over a binding called end.
Next: patterns and matching.