sysl

The slices module

sysl.slices — the operations over a built-in slice: searching, comparing, reversing, a copy that is right where the two slices overlap, two sorts that neither of them allocates, a binary search that answers where a missing value belongs, and the pointer a C binding hands across.

Every declaration in sysl.slices, with its signature: the generated API page. This page is the argument — what the module is for, and how its pieces fit; that one is the list.

sysl.slices is what a program does to a []T once it has one. Everything here is a free function rather than a member of anything, which is the same call sysl.math made for min: a built-in slice is indexed by the compiler walking to an address, so none of this goes through a trait, and what each function needs is written in its own bound.

Nothing here allocates and nothing here needs a capability. That is deliberate and it is load bearing — this is the module a C binding reaches into, and it is reachable from a target with no allocator and no operating system. The questions answered by calling something the caller wrote — map, filter, fold — are sysl.seq for exactly that reason: every one of them allocates, so they are a module a freestanding program can leave out.

import sysl.slices.{sort, is_sorted, binary_search}

var xs = [5, 3, 8, 1, 9, 2]

sort(xs)

print(xs)
print(is_sorted(xs))
[1, 2, 3, 5, 8, 9]
true

Looking things up

index_of and last_index_of answer an Option[usize] rather than a sentinel — a library answering -1 hands back a number that indexes when it should not, and usize has no negative to spare in any case.

import sysl.slices.{index_of, last_index_of, contains, min_index, max_index}

var xs = [1, 2, 3, 2, 5]

print(index_of(xs, 2).unwrap(), last_index_of(xs, 2).unwrap())
print(contains(xs, 5), contains(xs, 4))
print(min_index(xs).unwrap(), max_index(xs).unwrap())
1 3
true false
0 4

The extremes answer an index rather than a value, because the index answers both questions: an element is one subscript away, and a caller that wanted to modify the extreme element could not have got there from a copy. Ties go to the first, which is the choice min makes and for the same reason.

Both searches have a _from form, and it reads the same way sysl.text‘s does: from names a place to start, so the search is over xs[from..] and the answer is an index into the whole slice. Walking every occurrence is therefore index_of_from(xs, x, k + 1) from the last one, with nothing to slice and nothing to add back on — and the backward form looks at that same suffix, answering with the last occurrence in it rather than running downwards from from.

import sysl.slices.{index_of, index_of_from, last_index_of_from}

var xs = [1, 2, 3, 2, 5, 2]
var at = index_of(xs, 2)

while at.is_some()
    val k = at.unwrap()

    print(k)
    at = index_of_from(xs, 2, k + 1)

print(last_index_of_from(xs, 2, 4).unwrap())
print(index_of_from(xs, 2, 99).is_none())
1
3
5
5
true

An offset past the end answers None rather than trapping, which is what makes the loop above safe: at the last element k + 1 is the length, and beyond it is anything at all.

Comparing and rearranging

import sysl.slices.{equal, starts_with, ends_with, reverse, fill, swap}

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

print(equal(a, [1, 2, 3, 4]))
print(starts_with(a, [1, 2]), ends_with(a, [3, 4]))

reverse(a)
print(a)

swap(a, 0, 3)
print(a)

fill(a, 7)
print(a)
true
true true
[4, 3, 2, 1]
[1, 3, 2, 4]
[7, 7, 7, 7]

equal is not the only way to ask that any more. A slice is Eq when its elements are, so a == b answers the same question, and assert_eq takes two slices and prints both when they disagree.

And ordering came with it. A slice is Ord when its elements are — first differing element deciding, and a prefix ordering before what extends it — so a[..] < b[..] is a question this module never had a function for:

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

print(a[..] < b[..], b[..] < a[..])
false true

A slice still has no Hash, and that is a decision rather than a gap. A hash is a promise about a key and a view’s storage stays writable by whoever owns it, so a slice used as a key can change under the table. An array copies, so it hashes; core has the reasoning.

What equal still has is its signature and its module: it takes []const T and it lives here, under @no_alloc, reachable from a freestanding target — so a caller holding two slices and wanting a bool loses nothing by keeping it, and a binding that must not acquire an allocator has it whatever the core module gains. starts_with and ends_with are the same story one step along: both are equal over a sub-slice, and both stay for the same reason.

Moving elements from one slice to another

copy writes as much of the source as fits in the destination and answers how much that was. copy_exact writes the whole of the source or nothing at all, and answers whether it fitted.

import sysl.slices.{copy, copy_exact}

var dst = [0, 0, 0, 0]
var tight = [0, 0]
var room = [0, 0, 0, 0]
var small = [0, 0]
var src = [1, 2, 3]

print(copy(dst, src), dst)
print(copy(tight, src), tight)

print(copy_exact(room, src), room)
print(copy_exact(small, src), small)
3 [1, 2, 3, 0]
2 [1, 2]
true [1, 2, 3, 0]
false [0, 0]

The short answer is the point rather than a failure. A caller streaming into a buffer writes what fits and asks again; one that has already sized the destination wants copy_exact, where a mismatch is a mistake in the program rather than a state to handle. A destination larger than the source is a fit — a buffer sized once and written into in runs is the ordinary caller.

The two slices may be views of one array, and the copy is right in both directions. That is memmove‘s guarantee rather than memcpy‘s, and it is the one worth making: two views into one array is an ordinary thing to have.

import sysl.slices.copy

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

copy(a[0..<3], a[2..<5])
copy(b[2..<5], b[0..<3])

print(a)
print(b)
[3, 4, 5, 4, 5]
[1, 2, 1, 2, 3]

Why there are two declarations of each

copy is overloaded: a generic declaration over any []T, and one taking []u8. A call over bytes reaches the second, which hands the move to the platform’s memmove; every other element type reaches the first, which assigns element by element.

That is not an optimization detail — it is what makes the generic one correct. An element carrying a reference count needs the retain and the release an assignment does. A bitwise move over a []string leaks what the destination held and hands the source’s boxes out twice, and it prints the right answer while doing so: the fault is a use-after-free that arrives later, if it arrives at all where anybody is looking. So the fast path is exactly as wide as the type where it is sound.

The byte declaration earns its place because the loop is genuinely a byte at a time. The destination’s bounds check survives inside the loop body, which stops the optimizer recognising the idiom, so what the generic form compiles to is one load and one store per element — the same reason sysl.io‘s line reader reaches memchr rather than scanning bytes itself.

On a freestanding target it walks the elements instead, because there is nothing to call. A bare board is linked with no C library, so a memmove reached from here would be an undefined symbol — and this is a module a board reaches, sysl.harness being written for exactly that. The declaration and what it promises are the same on every target; only what it costs differs, which is what keeps this module usable where the operating system is not.

Two sorts, and neither of them allocates

sort is unstable, works in the slice it was given, and uses no extra storage. It is an introsort: insertion sort below sixteen elements, a median-of-three quicksort above that, and heapsort once the recursion has gone deeper than a well-behaved input ever would — which is what holds the worst case at O(n log n) rather than letting a chosen input make it quadratic.

sort_stable keeps equal elements in the order they arrived, and merges through scratch the caller supplies. That is the honest shape: a stable sort needs somewhere to merge into, and a library that allocated on your behalf would be unusable on the targets this module exists to stay available on. It answers false if the scratch is shorter than the slice.

import sysl.slices.sort_stable

var xs = [(2, 1), (1, 1), (2, 2), (1, 2)]
var scratch = [(0, 0); 4]

print(sort_stable(xs, scratch))
print(xs)
true
[(1, 1), (1, 2), (2, 1), (2, 2)]

Both come in a _by form taking a comparison, which is where the work actually is — the Ord forms are one line handing it <. The comparison is a bare arrow, so it is monomorphized and inlined rather than boxed, and the pair costs nothing over one function.

import sysl.slices.sort_by

var xs = [1, 5, 3, 2]

sort_by(xs, (a, b) -> b < a)

print(xs)
[5, 3, 2, 1]

The pair is sort_by and sort_stable_by, and is_sorted_by asks the same question of a slice somebody else ordered — which is what an assertion in a test wants, and what a caller checks before paying for a sort it may not need:

import sysl.slices.{is_sorted, is_sorted_by}

var xs = [5, 3, 2, 1]

print(is_sorted(xs), is_sorted_by(xs, (a, b) -> b < a))
false true

Which one you want is a real question. sort is faster and says nothing about equal elements; sort_stable promises their order survives. That only matters for a type whose equality does not mean identity — a record ordered on one field — and when it matters, it matters a great deal.

Why these are written in sysl rather than calling qsort

It is the first question anybody arriving from C asks, and the answer is not that the C library is slow.

The binding is perfectly writable, in a dozen lines, and everything it needs is in the language: the address of a per-instantiation comparison, the address of a slice’s storage, and the size of an element. The foreign interface writes exactly this one out.

What decides it is what this module promises. sysl.slices requires no capability, which is a promise made to every machine sysl builds for — and several of those are freestanding, where there is no C library to call at all and qsort is an undefined symbol at the end of somebody’s link. On a hosted machine the same promise fails more quietly: glibc’s qsort sorts by merging into a temporary buffer and calls malloc to obtain one, while Darwin’s sorts in place. One source text therefore allocates on one platform and not on the other.

Neither fact is visible to the compiler, because what is behind an extern is behind it. A module whose whole selling point is that it needs nothing cannot offer a function whose needs are unknowable.

Two smaller reasons stand behind that one. There is no portable stable sort in a C library — mergesort and heapsort are BSD extensions, present on macOS and absent from glibc, and ISO C has only the unstable qsort — so a binding could have replaced sort and never sort_stable. And a bound comparison is an indirect call per step, where a monomorphized [T: Ord] body inlines the same comparison.

Searching a sorted slice

binary_search answers a pair: whether the value was found, and the index it is at or would be inserted at.

import sysl.slices.binary_search

var xs = [1, 3, 5, 7, 9]

val (found, at) = binary_search(xs, 5)
val (missing, where) = binary_search(xs, 4)

print(found, at)
print(missing, where)
true 2
false 2

The insertion point is wanted on a miss and is the expensive half of the answer, so throwing it away would mean computing it twice. An Option[usize] does exactly that; a Result[usize, usize] — Rust’s spelling — keeps it but calls a miss a failure, which it is not.

Where several elements compare equal, the answer is the index of the first. That makes it a function of the values rather than of how the search happened to land, which is what lets two searches of one slice be compared.

The slice must already be sorted by the same order you are searching with. Nothing checks it — that would cost a linear scan on every search and defeat the point — and the answer for an unsorted slice is unspecified rather than wrong in some particular way.

binary_search_by takes the comparison, exactly as the sorts’ _by forms do, and it is the one to reach for on a slice ordered by anything but < — searching with an order the slice was not sorted by is the mistake the paragraph above describes, and it is easiest to make when the sort took a comparison and the search did not.

Handing a slice to C

Every binding to a C library needs a pointer to a slice’s first element, because C’s convention is a pointer beside a length. as_ptr and as_mut_ptr are that, and they exist here because every binding was otherwise writing them.

import sysl.slices.as_ptr

var xs = [10, 20, 30]

print(*as_ptr(xs))
print(as_ptr(xs[0..<0]) == null)
10
true

An empty slice answers null. An empty slice is a real input — a length of zero is a loop that does not run — but &xs[0] on one is an out-of-bounds index and traps rather than yielding the pointer C would have accepted.

The cost, said plainly: ISO C leaves passing a null pointer to memcpy undefined even at a length of zero, and a small number of libraries assert non-null on entry. Every real interface takes the pair (null, 0), and the length is what says not to look — but a binding whose C asserts otherwise must pass storage it owns rather than an empty slice.

Placing a C struct in storage you supplied

A binding that hands C a slice to keep its state in has a second problem after the pointer: a []u8 promises nothing about its own address, and the struct C is about to read wants its natural alignment. align_up answers the first address at or after a pointer that a given alignment divides, and is_aligned asks whether one already is. Neither needs C — an address read as a usize is an ordinary conversion, and ptr_cast reads it back.

import sysl.slices.align_up
import sysl.slices.is_aligned
import sysl.slices.as_mut_ptr

var storage: [sizeof(u64) + alignof(u64) - 1]u8 = [0; sizeof(u64) + alignof(u64) - 1]

val state: *u64 = ptr_cast(align_up(as_mut_ptr(storage[..]), alignof(u64)))

*state = 42

print(is_aligned(state, alignof(u64)))
print(*state)
true
42

The storage is over-sized by alignment - 1, which is the most the rounding can ever cost, so the value fits wherever the slice happens to start. alignof(T) is a compile-time constant, so the call asks for what the type wants rather than repeating a number.

A pointer that is already aligned is answered unchanged. On a 64-bit machine storage usually is aligned already, which is what makes a missing call so easy to ship: it works until the day the storage does not start on a boundary.

alignment must be a power of two and nothing checks it. The arithmetic is a mask, and a mask means something only for a power of two — any other value quietly answers an address that is aligned to nothing. It is the caller’s to get right, exactly as it is in C.

Search

Esc
↑↓ to navigate ↵ to open Esc to close