sysl

Module

sysl.slices

Operations over a built-in slice, and the pointer a C binding needs to hand one across.

Import import sysl.slices
Requires
no alloc

These are free functions over []T rather than members of anything, which is the call math/compare.sysl already made for min and integer.sysl made for gcd. A slice is indexed by the compiler walking to an address, so none of this goes through Index or Iterate – those say what indexing and walking mean for a type someone wrote, and a built-in has neither to offer. A bound written on each function is also the specification: contains asks for Eq because it compares, max_index asks for Ord because it orders, and a program with a type of its own satisfying the bound gets these for nothing.

Nothing here allocates and nothing here needs a capability. The module is reachable from a freestanding target, which is deliberate and is what keeps it usable by a C binding: as_ptr is the first thing a binding reaches for, and acquiring an allocator by asking for it would be a poor trade. Sorting is in sort.sysl beside this file and holds to the same rule.

The byte copy reaches libc, and it is gated on the target rather than reaching it everywhere. A bare board is linked -nostdlib and has no memmove to call, so a hosted target hands the move to libc and a freestanding one walks the elements. Nothing about what copy promises differs between the two; only what it costs does.

Index

INSERTION_LIMIT align_up as_mut_ptr as_ptr binary_search binary_search_by contains copy copy copy_exact copy_exact ends_with equal fill index_of index_of_from is_aligned is_sorted is_sorted_by last_index_of last_index_of_from max_index min_index reverse sort sort_by sort_stable sort_stable_by starts_with swap

Constants

INSERTION_LIMIT

const INSERTION_LIMIT: usize = 16

Below this many elements, insertion sort. It is where the recursion bottoms out and where a simple quadratic sort genuinely wins – the constant factor is small and the data is in cache.

Functions

align_up

align_up[T](p: *T, alignment: usize) -> *T

The first address at or after p that alignment divides.

This is what a binding needs to place a C struct in storage the caller supplied. A []u8 promises nothing about its own address, while the struct C is about to read wants its natural alignment – so a caller over-sizes its storage by alignment - 1 and puts the state at the first aligned address inside it. miniz is the package that does this, and it wrote the arithmetic in C because a comment there says sysl has no pointer-to-integer cast. It has one: an address read as a usize is an ordinary conversion (reference/memory.md), and reading it back is ptr_cast, so this needs no C at all.

The alignment a type wants is alignof(T), which is a compile-time constant, so the usual call spells it rather than repeating a number the C header already knows.

A pointer that is already aligned is answered unchanged, which is what makes it safe to call on storage that happens to be aligned already – the common case on a 64-bit machine, and the one that hides a missing call until the day it does not.

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

as_mut_ptr

as_mut_ptr[T](xs: []T) -> *T

The same, where the caller may write through it.

Separate from as_ptr because []const T and []T are different types and the const one must not yield a pointer something can write through. The empty case answers null for the same reason.

as_ptr

as_ptr[T](xs: []const T) -> *T

A pointer to the first element, for handing a slice to C as the pointer-and-length pair every C interface takes.

This exists because every binding was writing it. qcbor and monocypher each carried a private copy and the QOI survey needed the same one, and each copy had to get the empty case right on its own – which is the case that is not obvious, and the reason this is a library function. Both copies are gone: the two packages import this one instead, and their two hand-rolled versions had different bugs, which is the argument for a library function in one line.

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

The two answers this was chosen over are both worse. A pointer to a function-local scratch, which is what the hand-rolled copies return, points into a dead stack frame: safe exactly as long as nobody reads it, and silently wrong if anything ever does. A module-level scratch fixes the lifetime but cannot be generic, so there would have to be one per element type and nowhere to put them.

The cost, said here so a caller can see it: 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.

binary_search[T: Ord](xs: []const T, x: T) -> (bool, usize)

Where a value is in a sorted slice, and where it would go if it is not there.

Answers a pair rather than an Option, because the index is wanted either way. A caller that found the value wants its position; a caller that did not wants the place to insert it, which is the expensive half of the answer and is already computed. Option[usize] throws that away and Result[usize, usize] – Rust’s spelling – calls a miss a failure, which it is not.

The index of the FIRST equal element, where the slice holds several. That makes the answer depend on the values rather than on how the search happened to land, which is what lets two searches of the same slice be compared.

The slice must be sorted by the same order being searched with. Nothing checks it – that would cost a linear scan on every search, defeating the point – and the answer for an unsorted slice is unspecified rather than wrong in some particular way.

binary_search_by

binary_search_by[T](xs: []const T, x: T, lt: (T, T) -> bool) -> (bool, usize)

contains

contains[T: Eq](xs: []const T, x: T) -> bool

Whether the value appears at all – index_of where the position is not wanted.

copy

copy[T](dst: []T, src: []const T) -> usize

As much of src as fits in dst, copied element by element, answering how much moved.

This exists because every caller was writing the loop, Buf.extend included – and a loop at a call site is both noise and slower than the platform’s own move. C has memcpy, Rust has copy_from_slice and Go has copy for the same reason.

The two slices may be views of one array, and the direction is chosen from their addresses so that an overlap does not overwrite what has not been read yet. usize(p) reads an address as a number, which is an ordinary conversion (reference/memory.md), so this needs nothing raw. Overlapping is memmove‘s guarantee rather than memcpy‘s, and it is the right one to make: two views into one array is an ordinary thing to have, and a silently wrong answer is not worth the nanoseconds.

The short answer is the point rather than a failure. A streaming caller writes what fits and asks again; copy_exact is for a caller who has already sized the destination and for whom a mismatch is a mistake.

The declaration beside this one takes byte slices and is what a call over []u8 reaches, since reference/declarations.md § Overloading prefers a declaration that named its parameters. That one hands the move to memmove; this one cannot, because an element carrying a reference count needs the retain and the release an assignment does, and a bitwise move would leak what the destination held and hand the source’s boxes out twice.

copy

copy(dst: []u8, src: []const u8) -> usize

The same over bytes, which libc moves a word at a time where a sysl loop moves one byte.

Measured rather than assumed: the loop above compiles to a ldrb/strb pair per element at the default optimization, because the destination’s bounds check survives inside the body and stops the optimizer recognising the idiom. sysl.io.find_byte reaches memchr for exactly this reason and says so.

A byte carries no count, so the bitwise move the generic form must not make is the right one here. memmove rather than memcpy, for the overlap the other declaration promises.

A FREESTANDING TARGET WALKS THE ELEMENTS INSTEAD, because there is nothing to call. A bare board is linked -nostdlib, so a memmove reached from here is an undefined symbol at the link – and this module is one a board reaches, sysl.harness being written for exactly that. The declaration stays either way, so a caller writes one thing and a program’s own targets decide what it costs.

The gate is freestanding rather than anything finer, and that is deliberately conservative: a board with an SDK usually does have a libc – the Pico’s newlib defines __aeabi_memmove – but whether one is on the link line is the application’s to decide and not something the library can read. Being wrong in this direction costs a loop; being wrong in the other costs a build.

copy_exact

copy_exact[T](dst: []T, src: []const T) -> bool

The whole of src copied, or nothing at all – true when it fitted.

The question copy does not answer. A caller who sized the destination once and is filling it has a bug rather than a state when the source is too long, and reading a returned count back against src.len at every call is the check this is instead.

A destination LARGER than the source is a fit, where Rust’s copy_from_slice asks for equal lengths. A buffer sized once and written into in runs is the ordinary caller, and refusing that would send them back to copy for a question they were not asking.

copy_exact

copy_exact(dst: []u8, src: []const u8) -> bool

The same over bytes, reaching memmove through the byte copy beside it.

ends_with

ends_with[T: Eq](xs: []const T, suffix: []const T) -> bool

Whether the slice ends with the given elements.

equal

equal[T: Eq](a: []const T, b: []const T) -> bool

Whether two slices have the same length and the same elements in the same order.

The length test comes first and is what makes the common refusal cost nothing.

fill

fill[T](xs: []T, v: T)

Every element set to one value.

index_of

index_of[T: Eq](xs: []const T, x: T) -> Option[usize]

Where a value first appears, or None.

Option[usize] rather than a sentinel index. A library answering -1 for absence hands back a number that indexes when it should not, and usize has no negative to spare in any case. The absence is in the type, where a caller has to look at it.

index_of_from

index_of_from[T: Eq](xs: []const T, x: T, from: usize) -> Option[usize]

The same search begun part-way along, which is what walks every occurrence without cutting the slice up: index_of_from(xs, x, k + 1) is the next one after k.

from names a place to START, and the answer is an index into the WHOLE slice – so it is index_of(xs[from..], x) with from added back on. sysl.text‘s Search reads its own _from members the same way, and this is the pair that made the rule worth stating in both places.

A from past the end answers None rather than trapping, which is what makes the k + 1 above safe to write at the last element without a bound test of its own.

is_aligned

is_aligned[T](p: *T, alignment: usize) -> bool

Whether an address is already a multiple of alignment.

The question align_up answers by construction, asked on its own – for an assertion in a binding’s raw layer, or to find out whether over-sizing the storage was needed at all. A power of two is assumed here too, though this one is a remainder rather than a mask, so a value that is not one answers a real question about divisibility rather than nonsense.

is_sorted

is_sorted[T: Ord](xs: []const T) -> bool

Whether the slice is already in order. Worth having on its own, and it is also the sorts’ own postcondition, which is what the tests assert against.

is_sorted_by

is_sorted_by[T](xs: []const T, lt: (T, T) -> bool) -> bool

last_index_of

last_index_of[T: Eq](xs: []const T, x: T) -> Option[usize]

Where a value last appears, or None. Walks down from the end, so it stops at the first match rather than scanning the whole slice to keep the latest one.

last_index_of_from

last_index_of_from[T: Eq](xs: []const T, x: T, from: usize) -> Option[usize]

The last occurrence at or after from – the backward search over the same suffix index_of_from takes, rather than a search running downwards from from. So the two agree on which elements are being looked at and disagree only on which end they answer from, and a from past the end answers None from either.

max_index

max_index[T: Ord](xs: []const T) -> Option[usize]

Where the largest element is, or None. Ties go to the first, as above – written xs[best] < xs[i] rather than xs[i] > xs[best] so that an equal element does not displace the one already held.

min_index

min_index[T: Ord](xs: []const T) -> Option[usize]

Where the smallest element is, or None for an empty slice.

The index rather than the 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 same choice min makes and for the same reason: for a type whose equality does not mean identity, which of two indistinguishable values comes back is something a caller can observe.

reverse

reverse[T](xs: []T)

The elements in the opposite order, in place.

Walks from both ends toward the middle, so each element is written once and an odd middle element is left alone rather than swapped with itself.

sort

sort[T: Ord](xs: []T)

The slice in order, in place, using no extra storage.

Introsort: quicksort for the general case, insertion sort for the small one, and heapsort once the recursion has gone deeper than a well-behaved input ever would. The last of those is what holds the worst case at O(n log n) – a plain quicksort degrades to O(n²) on input chosen to defeat its pivot, and a library sort must not have an input that makes it quadratic.

Equal elements may come back in any order. That is what “unstable” means and it is only observable for a type whose equality does not mean identity – a record ordered on one field. A caller who cares wants sort_stable, which says so in its name.

sort_by

sort_by[T](xs: []T, lt: (T, T) -> bool)

sort_stable

sort_stable[T: Ord](xs: []T, scratch: []T) -> bool

The slice in order, with equal elements left in the order they were given, merged through storage the caller supplies.

Answers false and changes nothing when the scratch is shorter than the slice, which is the one way it can refuse. scratch needs xs.len elements; anything beyond that is ignored.

Bottom-up rather than recursive. The passes double in width – 1, 2, 4, 8 – merging adjacent runs, so there is no recursion and the stack does not grow at all. A top-down merge sort would add O(log n) frames for the same result.

sort_stable_by

sort_stable_by[T](xs: []T, scratch: []T, lt: (T, T) -> bool) -> bool

starts_with

starts_with[T: Eq](xs: []const T, prefix: []const T) -> bool

Whether the slice begins with the given elements. An empty prefix is a prefix of everything, which falls out of the loop rather than being decided here.

swap

swap[T](xs: []T, i: usize, j: usize)

Two elements exchanged, in place.

Out-of-range indices trap, as any other subscript does: this is an operation on storage the caller named, and inventing an answer for an index that is not there would hide the caller’s own mistake.

Search

Esc
↑↓ to navigate ↵ to open Esc to close