sysl

Arrays and slices

Two sequence types — storage and a view of it — and the rules for writing one down, indexing it, slicing it, and knowing what it keeps alive.

sysl has two sequence types where many languages have one:

typewhat it isshape
[N]Ta fixed array: N elements of T, a value, no headerthe elements, and nothing else
[]Ta slice: a view of elements someone owns{ owner, ptr, len } — three words
[]const Ta slice that may be read and not writtenthe same three words, with a bit

The split is Go’s, and for Go’s reason: one type for storage, one for a view, so a function that only reads takes the view and never has to care where the bytes live. Both carry a length, so every index is checked — which is what makes them the thing to reach for in place of a *T.

An array is its elements. A slice names elements that live somewhere else. Everything on this page follows from that one sentence, and a call is where it shows most plainly:

scribble(a: [4]int) -> int
    a[0] = 99
    a[0]

set_first(xs: []int)
    xs[0] = 99

var a = [1, 2, 3, 4]

print(scribble(a), a[0])

set_first(a)

print(a[0])
99 1
99

scribble was handed four integers and wrote on its own copy of them. set_first was handed three words naming the caller’s four integers, and wrote on those. Neither function said anything about copying; the types did.

A string is the second row of that table with one thing added and one taken away — its bytes are guaranteed well-formed UTF-8, and nothing may write through it. Everything here about indexing, slicing, length and ownership is therefore true of a string as well, and strings is where the two differences are.

A third shape sits beside these and is deliberately not on that table: <N>T, a vector, holds the same values an [N]T holds and is not storage at all. It lives in a register and its operators work on every lane at once, so a + b on two <4>f32 is one instruction doing four additions. Everything on this page — indexing checked while the program runs, slicing, ownership — is about things with an address, and a vector has none.

An array and a slice are how a vector reaches memory, though, and the two methods that move a run of elements between them are theirs: xs.load(i) reads the run starting at element i into a vector, and xs.store(i, v) writes one back. Both are bounds-checked like a subscript, and checked as a whole run — see vectors.

Writing one down

A literal lists its elements, and the count becomes part of the type:

var primes = [2, 3, 5, 7]
var row: [3]f64 = [1.0, 0.0, 0.0]

print(primes.len, primes[2], row[0])
4 5 1

The elements share one type, and a bare literal takes it from an element that has one — the rule the two sides of n + 1 follow. One suffix, or one typed name, anywhere in the list is enough, and a nested literal settles row by row:

const k: usize = 5

var sizes = [1, 2usize, 7]
var mixed = [1, k, 3]
var table = [[1usize, 2], [3, 4]]

var total: usize = sizes[0] + mixed[1] + table[1][1]

print(total)
10

The bare literals are read at that type, not as an int first, so one that does not fit it is refused where it stands. Two elements that each have a type still have to agree, and an integer literal beside a real is not made a real:

var bytes = [1u8, 300]
the literal 300 does not fit byte

A declaration with no initializer starts at the type’s zero value. A repeat [value; count] fills every element with one value, and is the form for an element type whose zero is not the wanted starting point — or which has no zero at all:

const n: usize = 6

var counters: [4]int
var ones = [1; 5]
var grid = [[0; 3]; 3]
var window = [0; n]

grid[1][2] = 5

print(counters[0], ones[4], grid[1][2], grid[0][0], window.len)
0 1 5 0 6

Three rules govern the repeat.

The count is a compile-time constant — a literal, a const naming one, or an expression over those — for the same reason an array bound is one: it is the bound, and the type is not known without it.

var k: usize = 4
var buf: [4]u8 = [0; k]
an array's repeat count must be a constant, since it is the array's bound

The diagnostic goes on to name the way out, and it is the section below: a count computed while running makes storage rather than an array, and storage is written where a []T is expected.

The element type comes from the value, or from the context where the value is a bare literal. And the value is evaluated exactly once, then copied into every element:

struct Counter
    n: int

bump(c: *Counter) -> int
    c.n = c.n + 1
    7

var c = Counter(0)
var three = [bump(&c); 3]

print(three[0], three[1], three[2], c.n)
7 7 7 1

One call, three elements. That is what makes the form a construction rather than shorthand for writing the value out count times — and where the element type holds a reference, each of those copies is a share of its own.

An empty literal has no element type of its own and takes one from the context, exactly as a bare None does:

var empty: [0]int = []

print(empty.len)
0

The zero-value form is not about arrays

var counters: [4]int is a var with a declared type and no =, and it means “the zero value of this type” for any type that has one. What matters here is which types do not: a type containing a &T has no zero value, because a reference always points at something live.

struct Node
    next: &Node

var n: Node
Node has no zero value, so 'n' needs an initial value

That keeps the non-null guarantee of memory true with no special case for arrays, and it is why [Empty; 16] is the form an array of enum values takes.

A table written at the top of a file

The same three forms written outside a function are a val, which is what a table somebody else fixed wants:

val order: [5]int = [16, 17, 18, 0, 8]

total(xs: []const int) -> int
    var t = 0

    for x in xs do t += x

    t

print(order[2], total(order[1..<3]), order.len)
18 35 5

The difference from a const is an address. A constant is folded into every use and has none, so it can size an array but cannot be one; a val is storage, so it may be indexed at a position only known while running, sliced, and iterated. What it may not be is written, and slicing one therefore yields a []const T — which is the whole of why a val is sliceable at all.

A val may hold a value the program had to build. Storage that exists for the whole run is never let go of, so the count it takes is never given back — which is what a static is, and is why this is admitted rather than refused:

squares(k: usize) -> []int
    var xs: []int = [0; k]

    for i in 0..<k do xs[i] = int(i) * int(i)

    xs

static val table: []int = squares(4)

print(table[0], table[3], table.len)
0 9 4

What the value is still decides where the storage gets filled. A string literal’s bytes are a constant in the object file and its owner word is null, so a table of them is storage all the way down and needs no code to run first — which is what lets a module with no allocator hold one, exactly as order above is:

val names: [3]string = ["alpha", "beta", "gamma"]

var i = 2

print(names[i], names[0].len)
gamma 5

That is the case a const could never have served: a constant is folded into its uses and has no address, so there is nothing to index at i. A raw pointer may be held for the same reason a literal may — it counts nothing, so there is no release to write.

A byte array from a string literal

On-disk formats are full of fixed-width text: an OEM name in eight bytes, a volume label in eleven, a magic number in four. A string literal is accepted wherever a [N]u8 is expected, when its UTF-8 length is exactly N — a binding’s annotation, a field, an argument, an element of an outer array and a function’s result:

struct Boot
    oem: [8]u8
    label: [11]u8

magic() -> [4]u8 = "FAT1"

sum(b: [3]u8) -> int = int(b[0]) + int(b[1]) + int(b[2])

val boot = Boot("MSWIN4.1", "NO NAME    ")
val kinds: [2][3]u8 = ["FAT", "EXT"]

print(boot.oem[0], boot.label[10], magic()[3], kinds[1][0], sum("abc"))
77 32 49 69 294

The literal is its bytes and nothing else: no NUL is added, and an escape counts as the bytes it encodes to, so a non-ASCII character takes its full UTF-8 width:

val e: [2]u8 = "é"
val nl: [2]u8 = "\n\0"

print(e[0], e[1], nl[0], nl[1])
195 169 10 0

It is an array literal of those bytes, so a module val or var written this way is constant data: on a bare-metal target it is laid into .rodata or .data with nothing run before the first statement, exactly as [77, 83, …] would be.

The length has to match exactly, either way. A shorter literal is not padded, because what the rest should hold — spaces, NULs — is the format’s to say, not the language’s:

val oem: [8]u8 = "MSWIN"
this string literal is 5 bytes and [8]byte holds 8 — a string literal fills a byte array only when its UTF-8 length is exactly the array's

Only u8 (and a name for it) takes one. A [N]i8 or a [N]char is not a byte array the literal spells — a char is a scalar value, not a byte — so there the literal stays a string, and the position refuses it as it refuses any other mismatch:

val a: [2]char = "ab"
cannot initialize 'a': declared [2]char but the value is string

A byte above 0x7F that is not part of a UTF-8 character — a boot sector’s 0xEB jump — cannot be written in a string literal at all, since a string is valid UTF-8; that is an array literal’s job.

A file’s bytes, embedded

A table somebody else made is often a file — an image, a font, a program a kernel carries before it has a disk. embed("path") is that file’s bytes, read while compiling, as a []const u8 over a constant the object file carries, as include_bytes! is in Rust and #embed in C. This program is main.sysl, and embeds itself:

// hello, from the file itself
val me = embed("main.sysl")

print(str_cast(me[3..<8]), me[0] == u8('/'))
hello true
  • A relative path is read from the directory of the file that writes it, never from wherever the build was started, so a source file and the data beside it move together and the tree builds from any directory. An absolute path is taken as written.
  • The bytes start on a 16-byte boundary, as aligned as any scalar a target has, so a header read in place from the start of an image is aligned even on a machine that faults on an unaligned load. embed("disk.img", align = 4096) asks for more; the boundary is a constant power of two.
  • It is constant data, so a val holding one is laid down rather than filled by code — a module with no allocator and no entry point, a kernel’s, may hold one — and a file embedded in several places is laid down once.
  • The build depends on the file: its bytes are in the key of every cache that replays a build, so an edit to it is a different program, exactly as an edit to the source is.
  • embed is a form only where nothing else claims the name: a function or a local called embed is what a call reaches.

A file that is not there is refused while compiling, naming the path that was tried:

print(embed("logo.bin").len)
cannot embed 'logo.bin': there is no file at '

The path is a string literal and nothing else. A build that is replayed has to know which files it read without being compiled again, and a literal is what can be found in the source text; a const or a computed string is refused:

val name = "logo.bin"

print(embed(name).len)
'embed' reads its file while compiling, and a build that is replayed has to know which files it read without compiling again — so the path is written as a string literal, not computed

Storage sized while running

Every form so far fixes its length in the type, and that is the one thing a program reading a file cannot do: the size is in the header, and the header is read by the code that needs the buffer.

A length that is not in the type is precisely what a []T is, so nothing here needs a new spelling. The expected type decides which reading a literal or a repeat gets:

decode(header: []const u8) -> []u8
    var out: []u8 = [0; usize(header[0])]

    for i in 0..<out.len do out[i] = header[1] + u8(i)

    out

var frame: [2]u8 = [3, 10]
var body = decode(frame)

print(body.len, body[0], body[2])
3 10 12

Under a [N]T a non-constant count is the error above; under a []T a constant one is simply the easy case. The three spellings side by side:

var buf: [64]u8 = [0; 64]     // an array — the count is in the type, so it is constant
var raw: []u8   = [0; n]      // a view of fresh storage — n is any expression
var xs:  []int  = [1, 2, 3]   // likewise, with the elements written out

The storage is the view’s own. The owner word is an ordinary counted reference to the elements, so everything under ownership is already true of it: a sub-slice retains it, the last view to go releases it, and the elements are destroyed before the bytes go back. Nothing about indexing, slicing, .len or iterating tells such a view apart from one over somebody else’s elements — which is the point, and why a second type would have been a second type for nothing.

What it adds is leaving the frame, which is what the program above does: a decoder that learns its size from what it is decoding returns a result, instead of asking its caller to size a buffer whose size is in a header the caller has not read.

Three things are checked, because a computed length is where arithmetic goes wrong. The count is widened to the address width and read unsigned, so a negative one arrives as a very large one; the byte size is computed with an overflow-checked multiply and add, so a count that would wrap cannot allocate a small buffer that is then written past; and a failed allocation traps rather than handing back a null the elements are then stored through.

Indexing

a[i] reads the element, and it is a place — so a[i] = v, a[i] += 1, a[i]++ and &a[i] all follow from the same machinery assignment and address-of use everywhere else, with nothing said about arrays in particular:

var a = [10, 20, 30, 40]

a[0] += 5
a[1]++

var p = &a[2]

*p = 99

print(a[0], a[1], a[2])
15 21 99

The index may be any integer type. Requiring usize would make for i in 0..<10 do a[i] … need a conversion for no benefit, since the check has to happen anyway:

var a = [10, 20, 30, 40]
val narrow: i8 = 1
val unsigned: u8 = 2
val sized: usize = 3

print(a[narrow], a[unsigned], a[sized])
20 30 40

The index is widened to the address width and compared unsigned against the length. That is one comparison rather than two, and it rejects a negative index as a very large one — the trick a bounds check has always used. (It is the address width rather than a flat sixty-four because what it is compared against is a length, and a length is a usize.)

An index already wider than an address — a u128 anywhere, or an ordinary int on a 16-bit machine — is asked whether it fits and then narrowed, in that order. Nothing holds more than usize elements, so a value that does not fit names no element and traps like any other index out of range; testing before narrowing is what stops 2^64 + 5 arriving as 5 and passing on a six-element array. So for i in 0..<n do a[i] … needs no conversion on any machine, which is the whole point of the index being any integer type.

A failed check traps

var a = [1, 2, 3]
var i: i32 = -1

print("before")
print(a[i])

The process stops at the index. This is the same runtime-safety category as the partial char(u) conversion and a mid-character string slice, and it gets the same treatment: a trap instruction, no message, and no unwinding. Output already written but not yet flushed goes with it — so before does not appear, which is worth knowing before reading a truncated log as evidence of where a program got to.

A check a guard already paid for is folded

Plain + on an unsigned integer wraps, so a guard written as n + len > out.len proves nothing about out[n + k]: a huge n wraps the guard’s sum small, the guard passes, and the subscript’s own check is the one thing that stops the program. Written so that nothing in it can wrap, the guard does settle the subscripts after it:

fill(out: []u8, n: usize, len: usize, v: u8) -> bool
    if n > out.len || len > out.len - n then return false

    for k in 0..<len
        out[n + k] = v

    true

var buf = [u8(0); 8]

print(fill(buf, 2, 3, 7), fill(buf, 6, 3, 7), fill(buf, 18446744073709551615, 3, 7))
print(buf)
true false false
[0, 0, 7, 7, 7, 0, 0, 0]

Past that guard n <= out.len and len <= out.len - n, so the compiler lays n + len and n + k (for k running below len, in a loop entered after the guard) down as additions that cannot wrap — LLVM’s add nuw — and repeats the guard’s conclusion as an llvm.assume where the fall-through begins. The optimizer can then see that every out[n + k] is inside, and the check goes. It costs nothing at run time: the flag and the assumption emit no code.

The fact lasts until the first statement that writes n, len or out — a loop whose body writes one of them is outside it from the start — and a local whose address is taken anywhere in the function never takes part. The same flag goes on any unsigned add both of whose operands are bounded by half the word: a view’s .len (no object reaches past half the address space), a literal no larger than isize::MAX, a narrower unsigned value widened, a & with one of these, a % by one, a right shift or a division by a literal, or a local never written after its declaration, or a loop variable, bounded by one of them.

A raw pointer is indexed anyway

A *T is the one receiver with nothing to check against, and it is subscripted regardless — that is C’s subscript, the address arithmetic, unchecked. Slicing one needs the end written, because nothing in the type can supply it:

total(xs: []const int) -> int
    var t = 0

    for x in xs do t += x

    t

var back = [7, 8, 9]
var p = &back[0]

print(p[1], total(p[0..<2]))
8 15

The resulting view owns nothing, since a *T region has nothing to keep alive. The check is a property of the type, not of the syntax: a *[N]T, whose length is in its type, keeps every check an array has. Reaching for a slice is how a program stays safe; reaching for a pointer is how it talks to hardware and to C, and the language supplies both rather than withholding the second.

A type with no elements of its own is indexed through a trait

Everything above is about the built-in subscript rather than about [] as a token. A user type — the library’s Buf[T], a lookup table, anything a program writes — implements IndexPlace or Index and is read with the same syntax:

import sysl.buf.{Buf, buf}

var b: &Buf[int] = buf()

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

b[0] = 9

print(b[0], b.len())
9 2

A type that implements IndexPlace has elements that are places, as an array’s are. Its one method answers the element’s address, checked against the bounds, and b[i] means *b.index_place(i) — so b[i].f reads one field where it lives, b[i] += 5 writes the element, and ref e = b[i] names it. Buf is one:

import sysl.buf.{Buf, buf}

var b: &Buf[int] = buf()

b.push(1)

b[0] += 5

print(b[0])
6

A program’s own container takes the same trait. It is declared *self, so a container whose elements are its own bytes answers an address in the container and not in a copy of it:

struct Ring
    cells: [4]int
    head: usize

impl IndexPlace[usize, int] for Ring
    index_place(*self, i: usize) -> *int = &self.cells[(self.head + i) % 4]

var r = Ring([1, 2, 3, 4], 2)

r[0] *= 10

ref last = r[1]

last = 0

print(r.cells)
[1, 2, 30, 0]

A type that implements only Index is read by a call, which yields a value rather than a place. The index is whatever the implementation takes, and need not be an integer; b[i] = v reaches a second trait’s method, IndexSet, and the compound forms are refused, since expanding them would evaluate the receiver and the index twice (core).

Note also that b.len() has parentheses where a.len does not: the built-in length is a property of a type the compiler knows, and Buf‘s is an ordinary method. Nothing a program writes competes with the built-in subscript — an array, a slice and a string are indexed by the compiler.

Slicing

A slice expression is an index whose subscript is a range, and the two range operators keep the meanings they have everywhere else: .. includes its high end, ..< excludes it.

var d = [1, 2, 3, 4, 5]

print(d[2..4].len, d[2..<4].len, d[2..].len, d[..<2].len, d[..].len)
3 2 3 2 5

The inclusive .. is the odd one against C-family habit, and the alternative is worse: for i in 0..<n and a[0..<n] must mean the same thing, and a language that has already chosen two range operators does not get to give them different meanings in a subscript. So “the first n“ is a[..<n], matching the loop that walks it.

Both ends are optional: an omitted low end is 0, an omitted high end is the last element. Because “through the last” is not a question of including or excluding anything, only one spelling of the open-ended form exists:

var a = [1, 2, 3, 4]
var v = a[1..<]
an open-ended slice is written 'a[lo..]'

The check is on the half-open interval the slice ends up naming. With s the first index and e one past the last, s <= e and e <= len must both hold, and the inclusive form additionally requires that its named high element exist. An empty slice is legal, including at the very end:

var d = [1, 2, 3, 4, 5]

print(d[d.len..].len, d[2..2].len)
0 1

What can be sliced: a fixed array, a slice, or an array reached through a &[N]T or a *[N]T — one-level auto-deref applies to a subscript as it does to field selection, so the expression reads the same whether the name is the array or a reference to it:

total(xs: []const int) -> int
    var t = 0

    for x in xs do t += x

    t

var boxed: &[4]int = [1, 2, 3, 4]

print(total(boxed), boxed[2], boxed.len)
10 3 4

That call says total(boxed) rather than total(boxed), which is the next section.

An array where a view is asked for

A [N]T standing where a []T is wanted is a[..], written by the position instead of by hand. An array is the one value that already knows both halves a view is made of — where the elements are, and how many — so nothing is taken on trust and no bound is guessed.

It matters most where a whole API is caller-supplied storage, which is how anything freestanding is written: the caller lends, the callee fills, and nobody allocates.

fill(s: []int, v: int)
    for i in 0..<s.len do s[i] = v

var a: [4]int = [0; 4]

fill(a, 7)

print(a[0], a[3], a.len)
7 7 4

It happens at every position that asks for a view rather than merely requiring one — an argument, a val or var with the view’s type written on it, a return, a parameter’s default — and a &[N]T converts on the same terms, with the reference as what the view is taken over.

What it does not do is change the storage’s own terms. A view of a val array is a []const T, exactly as a[..] would give, so a val array reaching a []T is refused and told which half is missing:

fill(s: []int, v: int)
    for i in 0..<s.len do s[i] = v

val a = [1, 2, 3]

fill(a, 7)
is a licence to write them — so the one does not become the other

A &sync [N]T does not convert either, for the reason it cannot be sliced. And a *[N]T is left out on purpose: the raw-pointer tier is written out where it is used, and a view of pointed-at storage taken silently is the one case where nothing in the type says the elements are really there.

An empty literal is a view of nothing, and needs no allocator

[] where a view is wanted is the empty view, which is the zero value of what a view is made of — no elements, no length, and nobody owning them. So it makes no storage and reaches no allocator, which is what lets a module that has given the heap up write one.

@no_alloc

val none: []const int = []
var also: []int = []

print(none.len, also.len)
0 0

It takes its element type from the position it stands at, having none of its own — an [] with nothing asking for a view is refused for exactly that reason.

A literal with elements in it is different, and the difference is where the storage comes from: at a []T such a literal builds a buffer of its own, which needs an allocator. Where the elements are laid out in the caller’s frame instead, what is written is an array and a view of it — [1, 2, 3][..] — and that is the form a variadic call packs its arguments into; see declarations.

A sliced array literal takes its element type from the view that was asked for, which is the one receiver with no type of its own:

show(xs: []const &Display) -> unit
    for x in xs
        print(x)

show([1, "hi", true][..])
1
hi
true

[]const T — a view that may not be written

The const sits after the brackets, where sync sits after the &, and for the same reason: it is a property of the view, not of the element type. It is the signature a function that only reads should have.

scale(xs: []const int)
    xs[0] = 99

var data = [1, 2, 3]

scale(data)
which views elements it may not write, so there is nothing to assign through

The same diagnostic answers xs[i] += 1 and xs[i]++, and finishes by naming the way out: elements you may write are elements of your own, so copy them into a []T first.

It is one type with a bit, not two types. Both forms are the same three words, reach through the same instructions, and keep the same thing alive; what the bit changes is only what may be done with the view. So a []T is accepted wherever a []const T is wanted — and never the other way round:

writes(xs: []int) -> usize
    xs[0] = 9
    xs.len

hand_over(xs: []const int) -> usize
    writes(xs)
is a licence to write them — so the one does not become the other

Giving up the ability to write is a promise the caller can always make; inventing one is the whole of what the type exists to stop.

Where two of them have to agree, they meet at the read-only view. A pair of operands, the branches of an if, the arms of a match, a loop’s break values — each is a place two types have to become one, and the two views are the one pair of known types in the language with somewhere to meet. Everywhere else two concrete types agree or the form is refused, since there is no subtyping to widen towards.

var a = [1, 2, 3]
var b = [1, 2, 3]

val c: []const int = b[..]
val k = if a.len > 0 then a[..] else c

print(a[..] == c, c == a[..], k.len)
true true 3

Without it the order would decide: the first of the two settles the type and the second is read again at it, so one direction converted and its mirror was refused for the same two values.

What they meet at is the read-only view, so this is not a way of inventing a licence to write:

var a = [1, 2, 3]
val c: []const int = a[..]

var k = if a.len > 0 then a[..] else c

k[0] = 9
views elements it may not write, so there is nothing to assign through

Two writable views are untouched — nothing is read-only, so there is nothing to meet — and a form with only one branch keeps that branch’s view whichever it is.

What produces one. Slicing a val array, since read-only storage gives a read-only view — and so is a val array standing where a view is asked for, which is the same rule reached without the brackets. An array inside a val struct value is the binding’s storage too. s.bytes, whose elements are a string’s own and may be a literal’s. Re-slicing one, because a bit a second subscript dropped would make xs[..] the way around xs. And a buffer literal written where one is wanted, since storage an expression makes has no other holder to disagree with it.

readonly(xs: []const int) -> usize
    var again = xs[1..]
    again.len

first(xs: []const int) -> int
    val p = &xs[0]
    *p

var a = [1, 2, 3, 4]

print(readonly(a), first(a))
3 1

A val holding an address is not read-only storage of elements. val is a promise about the name’s own bytes, and a *T, a &[N]T or a []T is an address: the elements it leads to belong to whoever laid them down, so a view taken through one is exactly as writable as that owner allows.

fill(s: []int, v: int)
    for i in 0..<s.len do s[i] = v

var store = [1, 2, 3, 4]

val p: *int = &store[0]
val whole: []int = store[..]
val heap: &[2]int = [5, 6]

fill(p[0..<1], 7)
fill(whole[2..], 8)
fill(heap, 9)

print(store[0], store[3], heap[1])
7 8 9

What it does not refuse is &. &xs[0] is a *T the moment it is written, which is the tier where the guarantees stop, and it is how a view reaches a C function taking a pointer and a length. The library’s own find_byte is memchr over exactly this. A read-only view that could not yield an address could not do the job it was added for. This is not the rule for a val itself, where &k[0] is refused: a val is storage whose promise is kept where it was made, and a view is a value whose promise is about writing through it.

[N]const T is not a type. An array is storage rather than a view of one, and storage written once is what val declares:

frozen() -> int
    var table: [4]const int
    table[0]
'const' says a view refuses writes, and an array is storage rather than a view of one

What a view does not record is whose elements these are, whether they outlive the program, and whether their owner’s count is atomic. Those are properties of the owner, and a view can only report on them — which is what the refusal below is about.

Length

a.len is the number of elements, as a usize. On a [N]T it is the constant N and costs nothing; on a []T it is the third word.

It is a property — a member read without parentheses — because a length is a projection of what is already there rather than a computation over it. On the built-in array and slice types it is compiler-provided, so a.len reads the same whether a is a fixed array whose length is a constant or a slice whose length is a word it carries.

Iterating

var xs = [1, 2, 3]

for x in xs
    x = x * 10

for i in 0..<xs.len
    xs[i] *= 10

print(xs[0], xs[1], xs[2])
10 20 30

for x in seq over an array or a slice binds a copy of each element, which is what value semantics mean — the first loop above multiplies three copies and throws them away. Changing the sequence goes through the index form. The loop evaluates its sequence once, so a slice written directly in the header lives for the whole loop rather than being rebuilt each step.

Storage is walked by index, and that is why a container is not an iterator. A for also accepts a cursor — a value implementing Iterate — but no built-in sequence implements it, and Buf deliberately does not: its elements are already sitting in memory, and reading them costs an index where a cursor would cost a call apiece. The protocol is for sequences whose elements have to be computed, which a container’s never are. What Buf implements instead is Walk, whose walk() answers the view, so for x in b asks for it once and then walks the slice by index.

What a view keeps alive

Taking a slice retains the owner; dropping one releases it. That is what makes “a slice never dangles” a fact rather than a hope, and it holds across a reallocation:

import sysl.buf.{Buf, buf}

var b: &Buf[int] = buf()

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

var v = b.view()

for i in 0..<100 do b.push(i)

print(v.len, v[0], b.len())
2 1 102

The pushes moved the elements to fresh storage. The view made before the move keeps the old storage alive and goes on showing what it was made from — it does not become invalid, which is the half of Go’s behaviour sysl could not have reproduced even if it had wanted to. What it 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.

Two consequences for how this is implemented:

  • The owner word is null whenever there is nothing to keep alive — a view of a string literal, of static storage, or of a *T region — so retain and release on a slice must tolerate null. The &T path does not pay for that check: a reference is non-null by construction, so nullability is the slice’s problem alone and gets its own pair of runtime helpers.
  • Release cannot be per payload type. A []u8 may view a 64-byte buffer today and a 4096-byte one tomorrow, so the static type of the slice does not name the type of the object its owner points at. Giving back a count is therefore type-erased, through the deallocation hook every ARC object carries — which is why releasing a slice’s owner is the same instruction sequence as releasing any other reference, with no static type in sight.

A view that would outlive the array it was made from does not fail: the array is promoted to a buffer instead, so a program that means to return one writes the ordinary var buf: [64]u8 and says nothing. Memory is where that is settled, along with what is still refused — storage the body did not declare.

The one view with nothing to keep alive and something that can die is a view of memory a C library owns, made over a *T by an object that frees it in its destructor. Its owner word is null, so the view cannot hold the object; instead the member that hands it out is marked @views(self), and the compiler refuses every use of the view that could come after the object it was called on is freed:

extern "free" c_free(p: *u8)

struct Rows
    p: *u8
    n: usize

    @views(self)
    view(&self) -> []const u8 = self.p[0..<self.n]

impl Drop for Rows
    drop(self) = c_free(self.p)

newest() -> []const u8
    val r: &Rows = Rows(null, 0)

    r.view()
this returns a view borrowed from 'r', and nothing keeps 'r' alive once this function returns

The view is still a plain []const u8 at no run-time cost. Memory has the four places it is refused.

Growing one

Buf[T] is the growable array, and it is ordinary sysl in the library rather than a type the compiler knows: a []T field for the storage, a count of how much of it is live, and the methods over them — with the one fact below, which only the compiler can pass on to the optimizer. That it can be written at all is what the section on storage sized while running bought — a container does not need a destructor if its storage is a value that already has one, which the []T field is.

It lives in sysl.buf, which is where the surface is documented. Nothing in the language reaches it: an array literal makes an array or a slice, and a for walks whatever implements Iterate.

How a push is seen by another name for the same buffer is not a question this language has to answer, because it is already answered by how the buffer is held:

var p: &Buf[int] = buf()
var q = p                   // one buffer, two names: q sees every push through p
var c = *p                  // a copy, because copying a struct is what that means

Go’s confusion — two slices that agree until one of them grows — comes from having one representation and therefore one behaviour. Held by reference, a Buf behaves as a &T behaves; held by value it is a value. Neither is a rule about growable arrays.

A buffer that is done growing can be frozen. b.freeze() takes its elements and answers a &sync Frozen[T] — one atomic count on the whole buffer, indexed, walked and viewed as a []const T, and never written — which every thread may read at once without a lock; b is left empty. What it may hold, and why, is in A frozen buffer crosses:

import sysl.buf.{Buf, buf}

var b: Buf[int] = buf()

b.push(3)
b.push(4)

val f = b.freeze()
val v = f.view()

print(f[1], f.len(), v, b.len())
4 2 [3, 4] 0

Its elements are never its header

A Buf‘s header — the handle in its box, and the record behind it holding the storage view and the count — and its elements are two different allocations: the storage is made by the buffer for itself, and a box is never made out of a buffer’s storage. So a write to an element cannot change the count or move the storage, and a write to the header cannot change an element. The compiler tells the optimizer exactly that, and nothing more: every access the buffer’s own members make to its header is marked as one side, every access they make to an element as the other, and the optimizer may keep the header in registers across a loop that writes elements. A row operation over a matrix held in a &Buf[real] reads the length and the storage pointer once, not once per cell.

It is a fact about where things are, not a rule a program has to keep. Everything else stays as it was: a view handed out by view() or elems, a reinterpreting view over the elements, a ptr_cast of an element’s address, and C given the data pointer all read and write the elements with no mark at all, which the optimizer must assume can touch anything. A member that grows the buffer writes the header, so an element read after it reads through the new storage:

import sysl.buf.{Buf, buf}

var b: &Buf[int] = buf()

b.push(1)

for i in 0..<20 do b.push(b.at(usize(i)) + 1)

print(b.len(), b.at(0), b.at(20), b.cap())
21 1 21 32

What is still refused

A &sync array cannot be sliced.

var boxed: &sync [4]int = [1, 2, 3, 4]
var v = boxed[..]
a slice does not record whether its owner's count is atomic

Nor can an array that lives inside a &sync box, whether it is a field, a field of a field, or handed to a parameter that takes a view. A view of it would hold its share of the box, and take and give back that share the ordinary way while another domain changes the same count atomically:

struct Ring
    samples: [8]i16

val r: &sync Ring = Ring([1, 2, 3, 4, 5, 6, 7, 8])
val recent = r.samples[2..<5]
print(recent.len)
a slice does not record whether its owner's count is atomic, so storage inside a '&sync Ring' cannot be sliced — a view of it would take its share of the box with a count update that is not atomic. Read it by index, or walk it with 'for'

Neither of those takes a share, so both work through the same box:

struct Ring
    samples: [8]i16

val r: &sync Ring = Ring([1, 2, 3, 4, 5, 6, 7, 8])
r.samples[3] = 40

var sum = 0
for x in r.samples
    sum += int(x)

print(r.samples[3], sum)
40 72

This is the one place where the read-only bit’s existence does not help, and the reason is the asymmetry between the two properties. A []const T can be made out of a writable view by giving something up, which is why widening is safe and why the bit costs nothing at run time. An atomic count cannot be made out of a non-atomic one at all: it is fixed when the object is allocated, and a view claiming it would be reporting on somebody else’s storage rather than describing itself. Rust reaches the same three answers with three mechanisms and splits them the same way — &[T] carries writability in the reference, Rc<[T]> against Arc<[T]> carries count discipline in the owner’s type.

There is no unchecked-index escape hatch for a hot loop. A *T is the way to write one today.

There is no rectangular multi-dimensional type. [3][3]f64 is an array of arrays and works; a distinct type with one bounds check for two indices is not planned.


Next: strings — the same three words, with a guarantee added and an operation taken away.

Search

Esc
↑↓ to navigate ↵ to open Esc to close