The core module
sysl itself — the names every program has without asking: rendering, hashing, subscripting, iteration, callables, and the two ways a program stops.
sysl is the one module a program does not import. Every other module in this section — sysl.buf,
sysl.text, sysl.io and the rest — is an offer, reached by name or by an import. The core is
what arrives unasked-for, and the reason it does is narrow: a program cannot avoid needing what the
language desugars onto. print(x) is a call to a library function. ? unwraps a library enum.
a + b is a library trait’s method. A language whose own forms reach names a program had to import
first would be a language with a required import, which is a worse thing than an auto-imported
module.
So the rule for what belongs here is not “useful” — a growable sequence is useful and lives in
sysl.buf. It is reached by the language itself, or so close to that as to make no difference.
var maybe: Option[int] = Some(3)
print(maybe.unwrap_or(0), "— and not one import above this line")
3 — and not one import above this line
What is in it
| area | names | where it is written up |
|---|---|---|
| absence and failure | Option, Result, Fallible | errors and contracts |
| stopping | panic, assert, exit | below, and attributes for @test |
| rendering to standard output | print-family: prints, printi, printu, printr, printb, printc, putbytes, encode_utf8; the sink itself, Stdout and stdout | below |
| rendering to standard error | eprints, eputbytes; the sink itself, Stderr and stderr | below |
| rendering to a sink | Display, FormatSpec, Writer, the display_* family | below |
| hashing | Hash, hash_u64, hash_u128, hash_bool, hash_str | below |
| destruction | Drop | the memory model |
| operators | Add, Sub, Mul, Div, Rem, BitAnd, BitOr, BitXor, Shl, Shr, Neg, Not, Eq, Ord | expressions |
| subscripting and walking | Index, IndexSet, Iterate | below |
| calling | Fn0 … Fn4 | below |
None of it is a language feature. Option is a generic enum, Display is a trait, panic is a
function that prints and exits. What the compiler knows is a short list of names — it asks
Option for its variants when it lowers a ?, and it asks the print family which function renders
an int — and it implements none of the behaviour behind them. Rewriting printi is something a
program can do; rewriting ? is not.
Stopping the program
Two functions stop a program on purpose, and both are ordinary sysl:
panic(msg: string, file: string = __FILE__, line: long = __LINE__) -> never
prints("panic: ")
prints(msg)
prints(" (")
prints(file)
prints(":")
printi(line)
prints(")\n")
exit(1)
assert(cond: bool, msg: string = "", file: string = __FILE__, line: long = __LINE__)
if !cond then
if msg.len == 0 then panic("assertion failed", file, line)
else panic(msg, file, line)
panic returns never, so a call to it is an expression of any type — which
is what lets it sit in one arm of a match whose other arms produce values, exactly as unwrap
does.
The message is optional, because a failure names the line it happened on. __FILE__ and
__LINE__ are reserved identifiers, and a default is evaluated at the call —
so a parameter defaulted to one reports the caller’s position rather than the library’s. Write a
message where it says something the condition does not; leave it out where the condition speaks for
itself.
Two details of that are worth copying if you write a checking function of your own. assert passes
file and line on to panic rather than letting panic fill its own defaults — otherwise
every assertion in every program would report the line inside the library that calls panic. And the
location is composed with prints and printi instead of an interpolated string, because building a
string makes heap storage, which would put assert out of reach of a module that declared
@no_alloc — the module that wants an assertion most.
divide(a: int, b: int) -> int
assert(b != 0)
a / b
print(divide(84, 2))
42
assert_eq — the assertion that says what the values were
assert(a == b) names the line, so you know which check broke. You then run the program again to
find out what the two values actually were — and running it again is exactly what the report could
have saved you.
assert_eq[T: Eq + Display](got: T, want: T, msg: string = "", …)
assert_slice_eq[T: Eq + Display](got: []const T, want: []const T, msg: string = "", …)
sum(xs: []const int) -> int
var total = 0
for x in xs
total = total + x
total
assert_eq(sum([1, 2, 3]), 6)
assert_eq(sum([1, 2, 3]), 6, "the running total")
print("both held")
both held
A failure prints the pair, in the order it is read in — what happened, then what was meant to:
panic: got 5, want 6 (main.sysl:9)
panic: the running total: got 5, want 6 (main.sysl:10)
Why this is a function and not advice to write the message yourself. The hand-written form is
assert(a == b, s"got $a, want $b"), and it has two costs: it evaluates each side twice, and it
builds a string. Building one makes heap storage, which puts it out of reach of a module that
declared @no_alloc — the module that wants an assertion most. Rendering
through Display into the output costs neither.
One function rather than one per type. Eq says the comparison means something and Display
says the value can be shown, which together are the whole of what a report needs.
assert_slice_eq earns a name of its own because a report saying two slices differ sends you to
find out where. It checks the lengths first — a length mismatch explains every index after the
shorter one — and otherwise names the first index the two disagree at, with both elements at it:
panic: got 2 elements, want 3 (main.sysl:4)
panic: got 2, want 5 at index 1 (main.sysl:9)
Floats use a different pair, in sysl.math. == is the wrong question to ask
about a float, so assert_approx_eq and assert_approx_eq_rel take a tolerance and are built on
approx_eq / approx_eq_rel, which is where the right question already lives.
These are the runtime half of the language’s checking, and they are not the same half as
contracts. A require clause is a promise about a call, checked where
the call arrives. assert is a promise about a moment, checked where the moment is. A function
whose fifth statement has just computed something it can verify has no contract to hang that on —
the contract was about the arguments, four statements ago.
They also stop the program differently from the checks the compiler inserts, and the difference is visible:
| stopped by | what you see | exit status |
|---|---|---|
| a compiler-inserted check (a bounds test, a broken contract) | nothing at all — a trap instruction, and buffered output is lost | the platform’s signal status |
panic, assert, unwrap, expect | panic: <message> on stdout, after everything printed before it | 1 |
That is not an inconsistency. A trap is for a check the language makes, where the source line
already says everything a message could; panic is for a check a program makes, about something it
knows and the compiler does not, where the message is the entire point. The full account is on
errors and contracts.
exit is the third name here and the only extern the core offers rather than keeps:
extern exit(code: int) -> never
It takes no link name where the four platform externs in sysl.sys all take one,
because it is not a platform detail — it is the hosted exit, the thing panic and unwrap stop
with, and a program stopping itself writes exactly the same call.
Rendering to standard output
print(a, b, c) is a desugaring, not a variadic function. Each argument is looked at
individually, its static type chooses a one-argument renderer, and the call becomes that sequence of
calls with a space between and a newline at the end.
| static type | renderer | what it does |
|---|---|---|
string | prints | the bytes, as they are |
| any signed integer | printi | through snprintf‘s %lld |
| any unsigned integer | printu | through snprintf‘s %llu |
real, f32 | printr | through snprintf‘s %g |
bool | printb | true or false |
char | printc | UTF-8 encoded in sysl, not by C |
| anything else | its Display | via str, which renders into a buffer |
print(42, 3.5, true, 'é', "text", 7)
print(1.0 / 3.0)
42 3.5 true é text 7
0.333333
0.333333 rather than 0.3333333333333333 because %g is six significant digits by default —
which is C’s default and stays C’s, since the whole of printr is one snprintf call.
The renderers are ordinary functions and a program may call them directly. Nothing separates them,
so this is where the space between print‘s arguments visibly comes from — there isn’t one:
printi(-7)
prints("|")
printr(2.5)
prints("|")
printb(false)
prints("|")
printc('é')
prints("|")
printu(9)
putbytes("|end\n".bytes)
-7|2.5|false|é|9|end
Everything goes through one sink, and that is not incidental
putbytes is the bottom of the whole surface. Every function above writes through it, including the
ones that went to snprintf for their digits — they format into a stack buffer and then hand the
bytes here.
Two mechanisms would mean two buffers, and output emerging in an order the program did not write. That is the entire reason for the shape.
It also writes a byte at a time, which looks like a mistake and is not. A sysl string may hold
an interior NUL, and every shortcut through C stops at one: puts, %s, even %.*s. A string that
printed correctly right up until it contained a zero byte is a worse bug than a loop that costs a
call per byte, and the loop is what a target with a real write replaces anyway.
putbytes(b: []const u8)
var i = 0
while i < b.len
sysl_putchar(int(b[i]))
i += 1
end putbytes
putbytes is one of exactly two functions a freestanding target has to replace. Swap its body
for a write syscall and FdReader.read‘s for a read one, and the entire surface
above both is unchanged — every renderer, every Display, every f"…" in the program.
encode_utf8 — a character’s bytes, without printing them
encode_utf8(ch: char, into: []u8) -> usize
printc encodes rather than handing the character to snprintf, which has no conversion that takes
a code point — and what it uses is this, which any program may call. It writes into storage the
caller owns and answers how many bytes it wrote. No character needs more than four, so a [4]u8 is
always enough and always on the stack, which is what keeps it usable where there is no allocator.
var b: [4]u8
print(encode_utf8('A', b[0..<4]), encode_utf8('é', b[0..<4]), encode_utf8('☃', b[0..<4]))
var n = encode_utf8('é', b[0..<4])
print(b[0], b[1], n)
1 2 3
195 169 2
It is here in sysl rather than beside from_utf8 in sysl.text, and that
placement is forced. A submodule may name the standard module freely and the standard module may
not name back, so an encoder over there would be one printc could not call — and the second encoder
that would then be written for it is exactly the hand-rolled copy this function exists to prevent.
The library carried four of them before it had this one.
A slice shorter than four bytes is a mistake in the program rather than a truncation, and the bounds check says so: how many bytes are needed is not known until the character has been looked at, so there is no honest answer to “it did not fit” that is cheaper than having room.
The other stream
A program’s answer and its complaints go to different places, so that a run whose output is being
captured does not have the complaint land in the middle of the answer, and a redirect that keeps the
answer still lets a person see what went wrong. eprints writes a string to standard error, and
stderr() is the same destination as a Writer, for a value rendering itself through its own
Display:
prints("the answer\n")
eprints("something to say about it\n")
the answer
The page shows only the first line because only the first line went to standard output — which is the
whole point of the distinction, and is what sysl.args relies on when it puts a
usage error on one stream and its --help on the other.
This half goes through write directly rather than a byte at a time, because there is no buffer
between it and the descriptor: a diagnostic written just before a program stops has to have left
before it does, which is the same reason C leaves standard error unbuffered. A short write is looped
over, since a signal may cut one anywhere.
Rendering to a sink
Standard output is one destination. A value that knows how to render itself renders into a
Writer instead, and that is what Display is:
struct FormatSpec
width: int
prec: int
left: bool
trait Display
display(self, out: *Writer, fmt: FormatSpec)
trait Writer: Fallible
write(*self, bytes: []const u8)
Three decisions are packed into those six lines.
A value writes rather than returns. display produces no string, so rendering costs no
allocation and a module under no alloc can still log. str(x) is then not a
separate mechanism — it is this same rendering aimed at a buffer.
The sink is *Writer, a trait object. So a rendering is written once against any sink, and a
kernel that has a UART and no standard output supplies one with an ordinary impl.
Standard output itself is one of those ordinary impls, and the library declares it — Stdout, a
struct with no fields, because a destination fixed at compile time keeps nothing. stdout() hands
one out, and it is what print writes a value’s own rendering into:
struct Marked
n: int
impl Display for Marked
display(self, out: *Writer, fmt: FormatSpec)
display_str("<", out, fmt)
display_int(long(self.n), out, fmt)
display_str(">", out, fmt)
show(where: *Writer, m: Marked)
m.display(where, FormatSpec(0, -1, false))
where.write("\n".bytes)
var mine = Stdout()
show(stdout(), Marked(1))
show(&mine, Marked(2))
print(Marked(3))
<1>
<2>
<3>
That the destination is a value rather than a fixed global is what lets show take it as a
parameter. Nothing above is privileged: a program pointing show at a UART writes another
impl Writer and passes that instead.
Writer requires Fallible rather than declaring a failed of its own, because
Reader needs the same question answered and an open file is both. Two traits may
each declare a member of one name — a call says which by naming the trait — but failed takes no
arguments, so at a call on a file there would be nothing to say which was meant. One required trait
makes the question go away rather than move it. Fallible is on errors and
contracts, with the reason a stream latches instead of returning.
The display_* family
Every rendering the language does ends up in this family, one function per shape:
| function | renders |
|---|---|
display_str | a string; precision truncates |
display_int, display_uint | an integer a caller already holds at 64 bits; precision is a minimum digit count, zero-filled |
display_real | a float; precision is significant digits, defaulting to 6 and capped at 40 |
display_bool, display_char | true/false, and a code point encoded to UTF-8 |
display_digits | where a number ends up — reads a sign off the front, zero-fills to the precision, then pads |
display_pad | where the rest end up — puts finished bytes in the field the spec asked for |
display_fill | writes one byte n times, in 16-byte runs off a stack buffer |
Every built-in reaches its own through an impl, and the two halves get there differently.
bool, char, string, real and f32 are five types, so each has an ordinary impl Display in
the library forwarding to the function above. The iN/uN families are open — i5 and u24 are
types you may write — so no finite list of blocks reaches them; what reaches them is a single
blanket block, impl[T: Integer] Display for T, whose buffer is measured from the width it is
instantiated at.
Either way they are Display exactly the way your own struct is, and a *Display can carry any
of them. That is what the blanket bought: a []*Display holding an int, a u8, a string and
a float is ordinary code, where an integer used to be the one thing a method table could not hold.
Integer is a trait you can write a bound over — f[T: Integer](x: T) accepts every width and
nothing else — but not one you can implement. It names which types the compiler settles a family as,
so there is nothing for a block to supply.
display_int and display_uint remain for a caller who already has a long in hand. Nothing in
the library routes through them any more.
A scalar reaches its own through a method call, so a Display written for a struct can render its
fields without leaving the allocation-free path:
import sysl.buf.byte_sink
var sink = byte_sink()
var out: *Writer = &sink
42.display(out, FormatSpec(6, -1, false))
"x".display(out, FormatSpec(0, -1, false))
putbytes(sink.text())
prints("\n")
42x
FormatSpec(6, -1, false) is a width of six, no precision — which is what -1 means throughout
the family — and padding on the left. The neutral spec, which is what a plain print passes, is
FormatSpec(0, -1, false).
The allocation-free path reaches every width. A rendering works its digits out against a
frame-local buffer and hands the sink a slice of it, which is safe because a Writer borrows the
bytes it is given rather than keeping them — so a module declaring @no_alloc can render anything,
a u256 included.
It did not always. The buffer has to be sized for the width, and until an array’s length could be
written in terms of a type parameter — [sizeof(T) * 3 + 2]u8, three decimal digits per byte being
the bound at every width — the widest values fell back through str, which is heap storage. So the
values needing the most care were the ones a module without an allocator could not print.
One padder, and why
Every renderer finishes by handing its bytes to display_pad. Six renderers each growing their own
padding would be six chances for %8s to mean something slightly different, and the drift would be
invisible until someone lined two columns up.
import sysl.buf.byte_sink
var sink = byte_sink()
var out: *Writer = &sink
display_int(42, out, FormatSpec(8, -1, false))
out.write("|".bytes)
display_int(-42, out, FormatSpec(0, 5, false))
out.write("|".bytes)
display_str("héllo", out, FormatSpec(0, 2, false))
out.write("|".bytes)
display_real(1.0 / 3.0, out, FormatSpec(0, 3, false))
out.write("|".bytes)
display_bool(true, out, FormatSpec(8, -1, true))
out.write("|".bytes)
display_char('é', out, FormatSpec(4, -1, false))
out.write("|".bytes)
display_uint(7, out, FormatSpec(0, 3, false))
putbytes(sink.text())
prints("\n")
42|-00042|h|0.333|true | é|007
Read that line slowly, because four rules are visible in it.
Precision means a different thing per renderer, and matches printf’s meaning under the
corresponding conversion. -00042 is %.5d: five digits minimum, zero-filled, and the sign is
written before the zeros rather than being counted among them. 0.333 is %.3g: three
significant digits. 007 is the unsigned form of the first. h is %.2s: truncation.
Width and precision count bytes, exactly as C’s do. display_str("héllo", …) with a precision of
2 was asked for two bytes, and é is two bytes of which only the first fits — so the answer is
h. It backs off to a character boundary rather than handing the sink half of a code point, which is
the one place this family refines C rather than copying it: %.2s in C would emit the lone lead
byte.
The same byte-counting is why é in a field of four came out with two spaces and not three. A
column-aligned table of non-ASCII text is therefore not something width gives you for free —
counting display columns is a different problem (combining marks, wide CJK cells) and the library
does not pretend to solve it.
left is the whole of justification. There is no centring, and no fill character other than a
space for the field or a zero for the digits.
Writing Display for your own type
struct Point
x: int
y: int
impl Display for Point
display(self, out: *Writer, fmt: FormatSpec) =
display_pad(("(" + str(self.x) + ", " + str(self.y) + ")").bytes, out, fmt)
var p = Point(3, 4)
print(p)
print(f"[${p}%10s]")
print(f"[${p}%-10s]")
(3, 4)
[ (3, 4)]
[(3, 4) ]
The parts are gathered before anything is padded, and that is the rule rather than the style. A
specifier describes the field the whole value occupies — so %10s on a point pads the point, not
its first number. An implementation that rendered parts and forwarded fmt down to each of them
would pad the 3 to ten columns and then the 4, which is not what anybody asking for %10s meant.
Forwarding fmt straight down is right in exactly one case: when the part being rendered is the
whole rendering, as for a wrapper around a single field.
A type with no Display cannot be printed, and the diagnostic says what to write:
struct Scale
k: int
print(Scale(3))
cannot print a Scale value — write an 'impl sysl.Display for Scale' to say how it renders
A slice or an array of anything printable
One impl[T: Display] Display for []T covers every slice, so the element type only has to render
itself. A slice of a type you wrote works the moment that type does:
struct Rect
w: int
h: int
impl Display for Rect
display(self, out: *Writer, fmt: FormatSpec) = display_str("a rect", out, fmt)
var ns = [1, 2, 3]
var rs = [Rect(3, 4), Rect(1, 2)]
print(ns)
print(rs[..])
print(f"[${ns[..]}%14s]")
[1, 2, 3]
[a rect, a rect]
[ [1, 2, 3]]
A fixed-size array renders too, and by one block rather than one per length:
impl[const N: usize, T: Display] Display for [N]T. A length is a value parameter, so it is an
argument to the array shape rather than part of it, and one implementation covers every array there
is. It delegates to the block above, so an array and its [..] view render identically — which is
why print(ns) and print(ns[..]) are the same line of output.
Before value generics a length was part of a type’s shape: [2]T and [3]T were two shapes with no
way to be generic over the difference, so printing a fixed array meant taking the view by hand.
The elements are written as they are met, never gathered. Gathering would need a growable buffer,
sysl.buf is built on this module rather than under it, and an allocation on the printing path is
the one thing printing does not have — so a slice prints under @no_alloc exactly as a number does.
That leaves the width, which has to be known before the first byte goes out. It is learned by rendering once into a sink that adds up what it is given and keeps none of it, so the cost of a padded slice is a second pass rather than a buffer, and an unpadded one is a single pass.
Saying how a slice of your type renders
The block above covers every slice, which would ordinarily be the end of the matter: two
implementations for one type are refused, and a program writing impl Display for []Rect would be
told the library got there first. Say override and it is yours:
struct Rect
w: int
h: int
impl Display for Rect
display(self, out: *Writer, fmt: FormatSpec) = display_str("a rect", out, fmt)
override impl Display for []Rect
display(self, out: *Writer, fmt: FormatSpec) =
display_str(str(self.len) + " rects", out, fmt)
var rs = [Rect(3, 4), Rect(1, 2)]
print(rs[..])
print(rs[0])
2 rects
a rect
The keyword goes on the overriding side, and that is the whole of it — nothing in the library
had to permit this in advance. A more specific implementation is found first, so []Rect reaches
yours and every other slice still reaches the library’s.
Two things it deliberately does not do. It does not let you replace an implementation for a type that
is not yours — override impl Display for []int is refused, because []int names nothing of your
program’s and coherence puts that block in the library or nowhere. And it does
not silence the ordinary duplicate: leave the keyword off and the second implementation is refused
exactly as it always was, which is how a block written twice by accident still gets found.
Hashing
trait Hash
hash(self) -> u64
A u64 rather than a usize, so a hash means the same thing on every target — a table moved
between a 32-bit and a 64-bit machine does not rebucket because the word size changed.
As with rendering, the built-ins reach a named mixer — through written impls, one per closed type
and a single blanket over the integers, which is what makes a built-in erasable to a &Hash rather
than merely usable under a Hash bound. Those mixers are public, which is what lets a Hash written
for a struct mix its own fields:
| mixer | for | what it is |
|---|---|---|
hash_u64 | every integer up to 64 bits | splitmix64’s finalizer |
hash_u128 | every integer above 64 bits | the two halves mixed separately, then combined |
hash_bool | bool | hash_u64 of 1 or 0 |
hash_str | string | FNV-1a over the bytes |
A value wider than 128 bits is truncated to 128 before it reaches hash_u128. That keeps the law
a hash owes — equal values hash equal — and gives up only collision resistance among values that
agree in their low 128 bits. If something ever keys a table on values that wide, mixing in 128-bit
chunks is the fix.
hash_u64 is a finalizer and not the identity for a reason. Consecutive integers are the input a
hash table actually meets — loop counters, row ids, sizes — and an identity hash makes them collide
in a row rather than spreading them. A mixer costs three multiplies and turns a counter into
something a table can bucket on.
A bool gets its own mixer because one bit does not widen to a number in this language; a string
gets FNV-1a over its bytes because a string is a validated []u8, so there is nothing to
decode and every byte counts once.
struct Key
name: string
n: int
impl Hash for Key
hash(self) -> u64 = hash_u64(hash_str(self.name) * 0x100000001b3 ^ hash_u64(u64(self.n)))
print(Key("a", 1).hash() != Key("a", 2).hash())
true
The multiply-then-xor is the shape to copy: 0x100000001b3 is FNV’s prime, and mixing with it before
the xor is what makes order matter. A plain xor of the field hashes would give Key("a", 1) and
a hypothetical Key with the fields swapped the same bucket.
Because the memberships are impls, a built-in can be erased to a trait object — a method table
holds function pointers, and an impl is what supplies one:
val xs: [3]&Hash = [7, "abc", true]
for h in xs do print(h.hash() != 0)
true
true
true
The widening is the law rather than an implementation detail: 1 and 1 are the same number,
they compare equal across widths, and so they hash equal — each becomes the same u64 before it
reaches the mixer.
Subscripting a type of your own
A built-in array or slice is indexed by walking to an address, and that is the compiler’s business.
These two traits are what b[i] means when the receiver is a type somebody wrote:
trait Index[I, E]
index(self, i: I) -> E
trait IndexSet[I, E]
index_set(*self, i: I, v: E)
Reading and writing are separate traits because a type may offer one without the other — a view
that computes its elements has an Index and no IndexSet to give.
struct Grid
cells: [9]int
impl Index[int, int] for Grid
index(self, i: int) -> int = self.cells[i]
impl IndexSet[int, int] for Grid
index_set(*self, i: int, v: int)
self.cells[i] = v
var g: Grid
g[3] = 7
print(g[3], g[0])
7 0
The difference from a built-in subscript is not cosmetic: reading through Index is a call, so it
yields a value rather than a place. That is why compound assignment through one is refused rather
than quietly expanding:
struct Grid
cells: [9]int
impl Index[int, int] for Grid
index(self, i: int) -> int = self.cells[i]
impl IndexSet[int, int] for Grid
index_set(*self, i: int, v: int)
self.cells[i] = v
var g: Grid
g[0] += 1
'+=' on an element read through 'sysl.Index' would evaluate the receiver and the index twice — write it out as 'b[i] = b[i] + …'
The expansion would be g.index_set(0, g.index(0) + 1), which evaluates g twice and the index
expression twice. For a Grid that is merely wasteful; for a receiver that is a function call, or an
index that advances a cursor, it is wrong. The refusal makes you write the version whose cost you can
see.
This is the sysl.buf Buf[T] arrangement too, and there it earns something
specific: subscripting reaches the bounds-checked members rather than the storage, so b[i] cannot
read a slot past the count that the backing slice still has.
Walking a type of your own
trait Iterate[E]
next(*self) -> Option[E]
This is what a for asks of what it walks, when what it walks is not a range, an array or a slice.
next answers with an Option, so ending and yielding are one question with one answer. A
separate has_next would be two, and two questions can disagree — the classic iterator bug is a
has_next that says yes and a next that then has nothing.
struct Countdown
n: int
impl Iterate[int] for Countdown
next(*self) -> Option[int]
if self.n <= 0 then return None
self.n -= 1
Some(self.n + 1)
var c = Countdown(3)
for x in c do print(x)
3
2
1
The cursor is advanced through *self, and a loop iterates a copy of what it was given. c above
is untouched by the walk. That matters for a cursor meaning to report something after the walk — a
reader’s failure latch, say — which is why such a cursor has to borrow what it reports on rather than
own it. sysl.io‘s lines() is built that way for exactly this reason.
A for also walks an erased cursor, since next takes *self and mentions no Self elsewhere
— so Iterate has an object, and the loop calls its member through the table. The element type is
whatever the object was erased to, which is why there is nothing to annotate:
struct Countdown
n: int
impl Iterate[int] for Countdown
next(*self) -> Option[int]
if self.n <= 0 then return None
self.n -= 1
Some(self.n + 1)
var it: &Iterate[int] = Countdown(3)
for x in it do print(x)
3
2
1
That is the same rule that lets a trait object satisfy a bound — a for asks what may be called on
the value, and a table is an answer to that. It means a function may hand back a cursor without
saying which one it built.
Calling a value
trait Fn0[R]
call(*self) -> R
trait Fn1[A, R]
call(*self, a: A) -> R
trait Fn2[A, B, R]
call(*self, a: A, b: B) -> R
…and Fn3, Fn4. One trait per arity, because a call’s argument types are part of what it
promises and there is no way to write that variadically.
call takes *self so a callable may carry mutable state, which is what makes a counter writable:
struct Counter
n: int
impl Fn0[int] for Counter
call(*self) -> int
self.n += 1
self.n
var c = Counter(0)
print(c.call(), c.call(), c.call())
1 2 3
A function type A -> R is Fn1[A, R] at the use, so a hand-written callable goes wherever a
closure goes — the parameter does not know or care which it got:
struct Scale
k: int
impl Fn1[int, int] for Scale
call(*self, a: int) -> int = a * self.k
apply(f: int -> int, x: int) -> int = f(x)
var s = Scale(3)
print(apply(s, 5), apply(n -> n + 1, 5))
15 6
That is worth knowing when a closure is not enough: a struct can have several members, be printed, be compared, and still be passed as the function.
Tuples
A tuple is comparable, hashable and printable exactly when its parts are. The memberships are structural, so nothing has to be written per tuple type:
print((1, 2), (1, 2, 3))
print((1, 2) < (1, 3), (1, 2) == (1, 2), (1, 2).hash() != (2, 1).hash())
print(f"[${(3, 4)}%10s]")
(1, 2) (1, 2, 3)
true true true
[ (3, 4)]
Every arity, not a list of them. The four rows are written over a type pack, so one block each covers a tuple of any width:
impl[..A: Eq] Eq for (..A)
eq(self, rhs: Self) -> bool
for const i in 0..<A.len
if self.i != rhs.i then return false
true
end eq
That is the library’s own source, quoted for its shape, and it is worth reading as what a pack is
for: ..A stands for the parts, the bound on it is asked of every one of them, and for const is
unrolled so self.i is an ordinary field selection at whatever type that part has.
print((1, "a", true, 2.5, 'z'))
print((1, 2, 3, 4) == (1, 2, 3, 4), (1, 1, 1, 2) < (1, 1, 1, 3))
(1, a, true, 2.5, z)
true true
Ord on a tuple is lexicographic, and each position runs a ladder rather than a single <, because
deciding a position takes two comparisons: this one is less, or it is greater, or the two agree
and the next position decides. Every position agreeing is not-less, which is where the loop ends.
A wide tuple is still usually worse code than a struct with field names — .3 says nothing and
.hue says everything. What has changed is that the language is no longer the thing telling you so.
Operators are here, and are documented elsewhere
The fourteen operator traits — Add through Ord — are declared in this module and are covered in
full under operator dispatch, because which trait a + reaches is a rule
about the operator rather than about the trait.
Two things about them belong here, where the declarations are:
Each trait requires exactly one method. Implementing comparison means writing lt and nothing
else — a > b is lt(b, a), a <= b is !lt(b, a) — so there is no way for four functions to
disagree with each other.
Eq and Ord are independent, not a hierarchy. That is the scalar law lifted intact: bool and
the pointer modes have == and no <. Implementing one does not give you the other:
struct M
v: int
impl Ord for M
lt(self, rhs: M) -> bool = self.v < rhs.v
print(M(1) < M(2))
print(M(1) == M(2))
'==' is not defined for M
Everything else is an import away
A name that is not in the core is not in scope, and the diagnostic is the ordinary one for an undefined function rather than anything that hints at a module:
var b = byte_sink()
undefined function 'byte_sink'
That is the auto-import rule doing its job in the direction that matters. Only the standard module arrives unasked-for; a submodule is an offer, and taking it up is written down where a reader can see it.
Next: sysl.text — bytes to text, and back.