sysl

The slices module

sysl.slices — the operations over a built-in slice: searching, comparing, reversing, 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.

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.

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.

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]

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]

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. guide/qsort in the compiler’s repository writes it 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.

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.

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.

Search

Esc
to navigate to open Esc to close