sysl

The buf module

sysl.bufBuf[T], the growable sequence written in ordinary sysl, and ByteSink, the one Writer the library supplies.

sysl.buf holds two types and three functions, and the interesting thing about the larger of them is what it is not: Buf[T] is not a type the compiler knows. It is a []T field for the storage, a usize for how much of it is live, and a dozen members — ordinary sysl, in a file a program could have written.

import sysl.buf.{Buf, buf}

var b: Buf[int] = buf()

b.push(1)
b.push(2)
b.push(3)

print(b.len(), b.cap(), b.is_empty())
print(b[0], b.at(2))

b[1] = 20

print(b.view().len, b.view()[1])
3 8 false
1 3
3 20

Nothing in the language reaches it. An array literal makes a []T, a for walks whatever implements Iterate, and neither of those is a growable sequence — so a program that wants one asks, and the import is where it asked. That is the core module’s rule applied from the other side: what a program cannot avoid needing arrives free, and what it has to ask for it asks for.

Why it can be written at all

A container that sizes its own storage needs three things, and sysl’s design notes once recorded all three as reasons this could not be a library type: sizeof over a type parameter, a cast to reach the elements through it, and above all a destructor, since the language had exactly one and no way to write another.

All three were the wrong answer, and none of the three is an absence now — a destructor is writable. A container does not need one if its storage is a value that already has one — the []T field is an ARC-owned buffer, so when a Buf goes, its storage goes with it, and nothing in the container has to say so. The one thing genuinely missing was the ability to ask for storage at a length worked out while running, and once a []T could be sized that way, Buf[T] was a hundred lines with no unsafe primitive in them.

A second apparent blocker dissolved the same way. A generic container cannot make its own storage, because an array is built by repeating a value and no bound promises that T has one. But a push arrives holding one — the value being pushed seeds the new storage, and the question never comes up.

struct Buf[T]
    elems: []T
    count: usize

    len(self) -> usize
    cap(self) -> usize
    is_empty(self) -> bool

    at(self, i: usize) -> T
    set(*self, i: usize, v: T)

    push(*self, v: T)
    extend(*self, xs: []const T)
    pop(*self) -> Option[T]

    insert(*self, i: usize, v: T)
    remove(*self, i: usize) -> T
    truncate(*self, n: usize)
    clear(*self)

    view(self) -> []T

It is generic over anything, including counted types — a Buf[string] retains and releases its elements like any other slice of them:

import sysl.buf.{Buf, buf}

var names: Buf[string] = buf()

names.push("ada")
names.push("grace")

print(names.len(), names[1])
2 grace

Bounds: a panic here, an Option there

at and set panic on an index past the end. pop returns an Option. That looks like two minds about the same question and is not:

An index past the end is a mistake in the program, not a value it meant to handle. Taking from an empty sequence is a question a caller asks on purpose.

It is the same bargain unwrap makes. A pop that returned T and panicked would make every drain loop write a length test it could have got from the answer.

import sysl.buf.{Buf, buf}

var b: Buf[int] = buf()

b.push(1)
b.push(2)
b.push(3)

print(b.pop().unwrap())
print(b.remove(0))
print(b.view()[0], b.len())

b.truncate(0)

print(b.len(), b.cap(), b.is_empty())

var e: Buf[int] = buf()

print(e.pop().is_none())
3
1
2 1
0 8 true
true

truncate, clear and remove are one operation seen three ways. truncate(n) lowers the count, and does nothing where n is a length the buffer does not have — a length past the end names no element, so unlike an index there is nothing for it to read and nothing to stop the program about. clear() is truncate(0). remove(i) shifts the survivors down over element i, hands that element back, and ends at truncate; an i that names no element is at‘s panic, for at‘s reason.

insert(i, v) is remove‘s other half, putting an element at i and moving everything from there on up one. Inserting at len is a push and is allowed for that reason — a loop inserting at a cursor reaches the end on its last step, and refusing there would make every caller write the case this one already handles. Anything past len is a gap, which a sequence cannot represent, and panics the way any other index past the end does.

import sysl.buf.{Buf, buf}

var b: Buf[int] = buf()

b.extend([1, 3, 4])
b.insert(1, 2)
b.insert(4, 5)

print(b.view()[1], b.len())
2 5

Hand-rolling it is a shift written backwards — a loop walking up overwrites the element it is about to read — and that is where the off-by-one lives.

What none of truncate, clear and remove does is give storage back. The cap() of 8 survives the truncate above. The elements above the count are still values in a []T that ARC owns — which is also why a copy of a Buf taken before a removal reads the shifted elements at the length it was copied at.

Subscripting goes through the checked members

impl[T] Index[usize, T] for Buf[T]
    index(self, i: usize) -> T = self.at(i)

impl[T] IndexSet[usize, T] for Buf[T]
    index_set(*self, i: usize, v: T) = self.set(i, v)

b[i] on a Buf means b.at(i), and that is worth more than the syntax. The backing slice is longer than the count — subscripting the storage would happily read a slot the buffer does not consider live, and every bounds check in the language would pass. Routing [] through at is what makes b[i] unable to see spare capacity.

The cost is that reading through Index is a call, so it yields a value rather than a place:

import sysl.buf.{Buf, buf}

var b: Buf[int] = buf()

b.push(1)
b[0] += 1
'+=' on an element read through 'sysl.Index' would evaluate the receiver and the index twice

b[0] = b[0] + 1 is the spelling, and writing it out is the point: the expansion the compiler declines to make would evaluate b twice and the index twice, which for a receiver that is a call, or an index that advances a cursor, is wrong rather than merely wasteful.

Two other shapes a first program tries. len is a method, unlike StrBuilder‘s property:

import sysl.buf.{Buf, buf}

var b: Buf[int] = buf()

print(b.len)
'len' is a method of 'sysl.buf.Buf[int]' — call it with 'len(…)'

And a Buf is not itself iterable — it implements Index, not Iterate:

import sysl.buf.{Buf, buf}

var b: Buf[int] = buf()

b.push(1)

for x in b
    print(x)
'for' iterates an integer range, an array, a slice, or a type that implements 'sysl.Iterate', and sysl.buf.Buf[int] is none of those

for x in b.view() is how it is walked, and that is not a workaround — it names the thing being iterated, which is the live prefix at the moment the loop started.

view is the bulk read, and it is a view

import sysl.buf.{Buf, buf}

var b: Buf[int] = buf()

b.push(1)
b.push(2)

var v = b.view()

print(v.len, b.cap())

b.push(3)
b.push(4)
b.push(5)
b.push(6)
b.push(7)
b.push(8)
b.push(9)

print(v.len, v[0], v[1])
print(b.len(), b.cap())
2 8
2 1 2
9 16

The seventh push overflowed a capacity of 8, so the elements moved to a new buffer of 16. v is unchanged and still valid — that is the whole of what this shows.

It does not dangle, because the storage it was made from is an ARC buffer like any other and the view keeps it alive. Go’s version of this is the famous confusion: two slices that agree until one of them grows, and afterwards agree about nothing. Here the guarantee is stronger and simpler — a program with no *T in it cannot fault, so the old storage stays until the last view of it goes.

What v does not do is grow with the buffer. It is a view of some elements and it has the length it was made with. Take it again to see more.

How a push is seen follows from how the Buf is held

sysl does not have to choose here, and that is the point:

import sysl.buf.{Buf, buf}

var p: &Buf[int] = buf()
var q = p
var c = *p

p.push(1)

print(q.len(), c.len())
1 0

q is a second name for one buffer, so it sees the push. c is a copy, because copying a struct is what *p means. Neither is a rule about growable sequences — both are the memory modes doing exactly what they do for any struct, and the author wrote which one they wanted.

Capacity, and what it costs

import sysl.buf.{Buf, buf_with_capacity}

var w: Buf[int] = buf_with_capacity(100, 0)

print(w.len(), w.cap())

w.push(7)

print(w.len(), w.cap(), w[0])
0 100
1 100 7

Growth is geometric — a full buffer doubles — and buf_with_capacity skips the reallocate-and-copy at each doubling on the way up to n. It is a guess and nothing depends on it: too small and the buffer grows the way it always does, too large and the slack goes with the rest. extend sizes the same way rather than fitting exactly, so a loop of extend calls stays amortized constant per element instead of turning quadratic the way += on a string does.

The fill is a parameter, and that is T having no zero. An array is made by repeating a value, and nothing about a type parameter says what an unused slot should hold. Leaving it out is an ordinary arity error:

import sysl.buf.{Buf, buf_with_capacity}

var c: Buf[int] = buf_with_capacity(8)
function 'sysl.buf.buf_with_capacity' takes 2 arguments, but 1 argument was given

None of those slots is ever read — count starts at zero, so every one of them is written before anything can see it. But they are real values, and that is the honest cost of a growable sequence in this language: there is no way to have storage that is merely reserved. A Buf[&T] grown to a capacity of 1024 while holding one element is holding 1024 references to whatever seeded the growth, and that object stays alive until the slots are overwritten. Capacity that is not yet values is a known gap, not a solved problem.

It needs an allocator, and says so

@no_alloc

import sysl.buf.{Buf, buf}

var b: Buf[int] = buf()

b.push(1)

print(b.len())
this reaches 'sysl.buf.buf.int', which makes heap storage, and this module declared '@no_alloc'

Both buf() and the push are named, because alloc is checked on what a module calls. There is no allocator-free Buf and there cannot be one: growing is the whole of what it does.

ByteSink

struct ByteSink
    bytes: &Buf[u8]

    text(self) -> []u8

impl Fallible for ByteSink

impl Writer for ByteSink
    write(*self, bytes: []const u8) = self.bytes.extend(bytes)

That is the entire type. It is one of the two Writers the library supplies — the other is Stdout, which stands for standard output, holds no state at all, and is therefore a struct with no fields. The buffer str and an f"…" hole render into is still the compiler’s, since a growable byte array is not something it can name at the layer it needs one.

impl Fallible for ByteSink has no block, and does not need one: every member of Fallible has a default, so implementing the latch on a buffer that has nothing to fail at is entirely a matter of opting in.

import sysl.buf.byte_sink

var sink = byte_sink()
var out: *Writer = &sink

display_int(42, out, FormatSpec(6, -1, false))
out.write("|".bytes)
display_str("ok", out, FormatSpec(0, -1, false))

putbytes(sink.text())
prints("\n")
print(sink.failed(), sink.text().len)
    42|ok
false 9

Why it is in the library rather than in each program

Because an implementation that renders more than one part cannot honour its specifier without one. A format specifier describes the field the whole value occupies, so a rendering of 1, +, 2, i has to pad what those four came to rather than each of them; padding needs the finished bytes; and the finished bytes need somewhere to land.

import sysl.buf.byte_sink

struct Complex
    re: int
    im: int

impl Display for Complex
    display(self, out: *Writer, fmt: FormatSpec)
        var sink = byte_sink()
        var gather: *Writer = &sink

        display_int(long(self.re), gather, FormatSpec(0, -1, false))
        gather.write("+".bytes)
        display_int(long(self.im), gather, FormatSpec(0, -1, false))
        gather.write("i".bytes)

        display_pad(sink.text(), out, fmt)
    end display

print(Complex(1, 2))
print(f"[${Complex(1, 2)}%8s]")
print(f"[${Complex(1, 2)}%-8s]")
1+2i
[    1+2i]
[1+2i    ]

Note the two specs. The inner FormatSpec(0, -1, false) is neutral — the parts are rendered plainly — and only display_pad at the end sees fmt. An implementation that forwarded fmt down to each part would pad the 1 to eight columns and then the 2, which is not what %8s on a complex number meant.

Every such implementation would write the same dozen lines, which is the definition of something that belongs in the library. What a program still writes for itself is an ordinary impl Writer for MyThing — a counter, a device, a bounded buffer that latches — and that remains the case the trait exists for.

A Writer may not keep what it is written

write takes a []const u8 that may be a view of the caller’s stack — that is exactly what the display_* renderers hand it, and it is why they cost no allocation. Nothing in the type says the bytes are borrowed, so it is checked: escape analysis rejects an implementation whose write lets its parameter outlive the call. ByteSink copies them into its Buf, which is what extend is.

That check is what licenses a renderer to pass a stack-backed slice through a trait object at all.

Type errors read as they should

The generic is monomorphized, so a mismatch names the instantiation rather than the parameter:

import sysl.buf.{Buf, buf}

var b: Buf[int] = buf()

b.push("x")
'v' of 'sysl.buf$Buf.push.int' is int, but string was given

sysl.buf$Buf.push.int is the push of a Buf[int] — one function, emitted for that instantiation. Generics has the rest of that story.


Next: sysl.io — reading, and the Lines cursor.

Search

Esc
to navigate to open Esc to close