sysl

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

areanameswhere it is written up
absence and failureOption, Result, Fallibleerrors and contracts
stoppingpanic, assert, exitbelow, and attributes for @test
rendering to standard outputprint-family: prints, printi, printu, printr, printb, printc, putbytes, encode_utf8; the sink itself, Stdout and stdoutbelow
rendering to standard erroreprints, eputbytes; the sink itself, Stderr and stderrbelow
rendering to a sinkDisplay, FormatSpec, Writer, the display_* familybelow
hashingHash, hash_u64, hash_u128, hash_bool, hash_strbelow
destructionDropthe memory model
operatorsAdd, Sub, Mul, Div, Rem, BitAnd, BitOr, BitXor, Shl, Shr, Neg, Not, Eq, Ordexpressions
subscripting and walkingIndex, IndexSet, Iteratebelow
callingFn0Fn4below

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 bywhat you seeexit status
a compiler-inserted check (a bounds test, a broken contract)nothing at all — a trap instruction, and buffered output is lostthe platform’s signal status
panic, assert, unwrap, expectpanic: <message> on stdout, after everything printed before it1

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 typerendererwhat it does
stringprintsthe bytes, as they are
any signed integerprintithrough snprintf‘s %lld
any unsigned integerprintuthrough snprintf‘s %llu
real, f32printrthrough snprintf‘s %g
boolprintbtrue or false
charprintcUTF-8 encoded in sysl, not by C
anything elseits Displayvia 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:

functionrenders
display_stra string; precision truncates
display_int, display_uintan integer a caller already holds at 64 bits; precision is a minimum digit count, zero-filled
display_reala float; precision is significant digits, defaulting to 6 and capped at 40
display_bool, display_chartrue/false, and a code point encoded to UTF-8
display_digitswhere a number ends up — reads a sign off the front, zero-fills to the precision, then pads
display_padwhere the rest end up — puts finished bytes in the field the spec asked for
display_fillwrites 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:

mixerforwhat it is
hash_u64every integer up to 64 bitssplitmix64’s finalizer
hash_u128every integer above 64 bitsthe two halves mixed separately, then combined
hash_boolboolhash_u64 of 1 or 0
hash_strstringFNV-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.

Search

Esc
to navigate to open Esc to close