sysl

Module

sysl.container

Five containers, each with a shape a slice and a Buf cannot give you.

Import import sysl.container

Map and Set over one flat probe table, Deque for a queue cheap at both ends, Heap for a priority queue, and List — an immutable singly linked list that shares its tail. Every one of them owns its elements and grows, so every one of them needs an allocator; the fixed-capacity complement is sysl.container.ring, which needs none.

A module rather than five, because what a program wants from here is a data structure and the choice between them is the interesting part. Each file’s own header says what its structure costs and when to reach for it instead of its neighbour.

Index

deque deque_with_capacity difference heap heap_of heap_with_capacity intersection is_subset list list_of map map_with_capacity set set_of set_with_capacity union Cursor Deque DequeCursor Heap List ListCursor Map Set SetCursor Slot Index for Deque[T] IndexSet for Deque[T] Iterate for Cursor[K, V] Iterate for DequeCursor[T] Iterate for ListCursor[T] Iterate for SetCursor[K] Walk for Deque[T] Walk for List[T] Walk for Map[K, V] Walk for Set[K]

Functions

deque

deque[T]() -> Deque[T]

deque_with_capacity

deque_with_capacity[T](n: usize, fill: T) -> Deque[T]

A deque with room for n elements already under it. Every slot starts as None, so fill is not stored anywhere; the parameter stays because callers write it, and because it is what lets a call say the element type without brackets.

The storage is rounded up to a power of two, because the mask every index goes through is only correct for those lengths.

difference

const difference[K: Hash + Eq](a: Set[K], b: Set[K]) -> Set[K]

The keys in the first set and not the second. Not symmetric, so it walks a whatever the sizes.

heap

heap[T: Ord]() -> Heap[T]

heap_of

heap_of[T: Ord](xs: []const T) -> Heap[T]

Every element of a slice heaped at once.

This is linear where pushing them one at a time is not, and the difference is the reason it is worth having rather than a convenience: sifting down from the last parent to the root touches each element once at a depth that halves as it rises, which comes to O(n), while n pushes come to O(n log n). The elements go in unordered and the heap property is established afterwards.

heap_with_capacity

heap_with_capacity[T: Ord](n: usize, fill: T) -> Heap[T]

A heap whose storage is already big enough for n elements. The fill is written for the reason buf_with_capacity‘s is: a generic T has no zero, and none of these slots is read.

intersection

const intersection[K: Hash + Eq](a: Set[K], b: Set[K]) -> Set[K]

The keys in both sets. The smaller set is the one walked, because every key walked costs a lookup in the other and the result cannot be bigger than the smaller input – so walking the larger would ask more questions to reach the same answer.

is_subset

const is_subset[K: Hash + Eq](a: Set[K], b: Set[K]) -> bool

Whether every key of the first set is in the second. A set cannot be a subset of a smaller one, so the count settles most calls before a single lookup is made.

list

list[T]() -> List[T]

The empty list.

list_of

list_of[T](xs: []const T) -> List[T]

A list of a slice’s elements, in the same order.

The slice is walked backwards, because a list is built by putting things on the front: walking forwards would produce the reverse and need a second pass to correct.

map

const map[K: Hash + Eq, V]() -> Map[K, V]

An empty map, which holds no table at all until something is put in it.

An empty map allocates one small cell and no table, which matters far more than it looks: a map built inside a loop, held in a struct that is usually empty, or made and dropped on a branch that turns out not to be taken would otherwise pay for a table of slots every time, and no caller can see that it is paying. The cell is the MapCore every name for the map shares – three words, whatever the key and value. The first put makes the list_room slots a small map lives in, and the ninth distinct key turns them into a table.

Reach for map_with_capacity where entries are known to be coming: a caller who names a size is saying so, and gets its storage immediately.

The annotation on core is required and is not noise. MapCore‘s parameters would have to come from the constructor’s arguments while the empty table waits to hear what it is a table of, and neither can go first.

map_with_capacity

const map_with_capacity[K: Hash + Eq, V](n: usize) -> Map[K, V]

A map whose storage is already big enough for n entries without rehashing, for a caller that knows roughly how many are coming. Up to list_room entries that is the list’s slots, since so few would be a list anyway. Past it the map starts as a table, sized to twice n because it rehashes at three quarters full, so a table of exactly n slots would grow before the nth entry arrived.

set

const set[K: Hash + Eq]() -> Set[K]

set_of

const set_of[K: Hash + Eq](xs: []const K) -> Set[K]

Every key of a slice, with duplicates collapsed. The shape that makes a set worth reaching for in one line rather than three.

set_with_capacity

const set_with_capacity[K: Hash + Eq](n: usize) -> Set[K]

union

const union[K: Hash + Eq](a: Set[K], b: Set[K]) -> Set[K]

The keys in either set. Sized for the larger of the two, since that is the least it can come to.

Types

Cursor

struct Cursor[K, V]
    cell: []Slot[K, V]
    at: usize

A position in a table: which slot to look at next.

It takes no bounds, which is the point of writing them on the declarations that need them: walking a table hashes nothing and compares nothing, so a cursor asks nothing of a key.

Deque

struct Deque[T]
    private core: &DequeCore[T]

A sequence that grows and that is cheap to take from at either end.

The reason this exists beside Buf is one line of cost. A queue written over a growable sequence takes from the front with remove(0), which shifts every remaining element down one – so a loop that pushes n things and pops them all is quadratic, and reads as though it were linear. That is the shape of every breadth-first walk, every work list and every scheduler run queue, and it is the one place the library was quietly teaching an accident.

The elements live in a ring: head says where the first one is, count says how many follow, and both ends wrap around the end of the storage rather than moving anything. Taking from the front advances head and touches nothing else; adding to the front steps it backwards into the space that leaves. Neither end shifts, which is the whole point.

A slot the ring is not using holds None, which holds nothing. That is what lets the storage be made before there is anything to put in it, and what makes taking an element out let go of it there and then: the slot it leaves is written None, so a destructor behind the element runs when the deque gives it up rather than when the storage goes. Buf keeps its spare slots empty a different way – a buffer’s live slots are a prefix, which slot_storage and slot_vacate describe – and a ring’s are not, since push_front writes the far end of the storage first.

The storage length is always a power of two, because it starts at eight and only ever doubles, so wrapping is a mask rather than a division. That invariant is worth stating because it is what every index calculation here assumes – there is no % anywhere below, and a growth path that produced a non-power-of-two length would break all of them at once rather than visibly.

A Deque is a reference to its ring, not the ring. Assigning one, passing it, capturing it in a closure or reading it out of another container gives a second name for the same deque: a push_front through either is seen by both, a growth included. A destructor behind an element runs when the element is taken out, or when the last name for the deque goes.

The bounds-checked members panic rather than answering an Option, which is the bargain Buf makes for the same reason: an index past the end is a mistake in the program. pop_front and pop_back answer an Option, because taking from an empty sequence is a question a caller asks on purpose.

MemberSignatureDescription
headhead -> usizeWhere the first element sits in the storage.
countcount -> usizeHow many elements there are – len() under the name the field had.
lenlen(self) -> usize
capcap(self) -> usize
is_emptyis_empty(self) -> bool
atat(self, i: usize) -> T
setset(self, i: usize, v: T)
firstfirst(self) -> Option[T]
lastlast(self) -> Option[T]
push_backpush_back(self, v: T)
push_frontpush_front(self, v: T)The end Buf has no answer for at all.
pop_frontpop_front(self) -> Option[T]
pop_backpop_back(self) -> Option[T]
clearclear(self)The elements dropped and the head put back at the start.
copycopy(self) -> Deque[T]A second deque holding the same elements in the same order, the front put back at the start of its storage – the one way to get a deque that is not this one, every other way of handing a Deque on handing on this one.
releaserelease(*self)This name’s share of the ring given back, and the handle left naming nothing – the state zeroed storage starts in, where every reader answers as an empty deque does.

DequeCursor

struct DequeCursor[T]
    private elems: []Option[T]
    head: usize
    count: usize
    at: usize

A walk from the front to the back. It holds the storage rather than the deque, so pushing onto the deque while a cursor is live leaves the cursor reading the ring it started on; an element the deque gives up meanwhile is a slot that reads None, and the walk ends there.

Heap

struct Heap[T: Ord]
    elems: Buf[T]

A priority queue: the smallest element is always the next one out.

It is a MIN-heap, and that is a decision rather than a coin toss. The three things a priority queue is actually reached for – a scheduler ordered by deadline, a shortest-path search ordered by distance, an event loop ordered by time – all want the smallest first, and all three would otherwise have to negate their keys to get it. A max-heap is a min-heap over a reversed comparison, and reversing Ord needs a wrapper type the caller would have to declare, so whichever way round this goes one set of callers pays. It goes the way the common cases want.

The shape is a binary heap laid out in a Buf, with no tree and no pointers: the children of the element at i are at 2i + 1 and 2i + 2, and its parent is at (i - 1) / 2. So the whole structure is arithmetic over a growable sequence the library already has, which is why this module is short – Buf does the growing and this does the ordering.

The heap property is that no element is smaller than its parent. That is weaker than sorted, which is exactly why push and pop are logarithmic rather than linear: only one path from a leaf to the root is ever touched.

Walking a heap is deliberately not offered. The storage order is not the priority order – only the first element is guaranteed to be anything in particular – so a cursor over it would hand back elements in an order that looks meaningful and is not. Draining with pop is how a heap is read in order, and it is the only way that answers truthfully.

MemberSignatureDescription
lenlen(self) -> usize
is_emptyis_empty(self) -> bool
peekpeek(self) -> Option[T]The smallest element, left where it is.
pushpush(self, v: T)An element added, then carried up past every parent it is smaller than.
poppop(self) -> Option[T]The smallest element removed and returned.
clearclear(self)
releaserelease(*self)The buffer underneath given back without allocating, as Buf.release does – for a heap in storage the program zeroed itself, which answers as empty until it is assigned.

List

struct List[T]
    private cell: Option[&Cell[T]]
    private count: usize

A list, which is a first cell or nothing, and the number of cells after it.

The length is carried rather than counted, and immutability is what makes that free. A cell’s tail never changes, so a length worked out when the list was built stays true forever – there is no operation that could invalidate it. A mutable list would have to walk to answer len, or maintain the count against every mutation; this pays one addition at prepend and answers in constant time for the rest of the list’s life.

MemberSignatureDescription
lenlen(self) -> usize
is_emptyis_empty(self) -> bool
firstfirst(self) -> Option[T]The first element, or None.
restrest(self) -> Option[List[T]]Everything after the first element, or None where there is no first element.
unconsuncons(self) -> Option[(T, List[T])]The first element and the rest, in one question.
prependprepend(self, v: T) -> List[T]A new list with v on the front, sharing the whole of this one.
reversereverse(self) -> List[T]The same elements in the opposite order, which costs a cell each because nothing of the original can be shared: a reversed list’s tails are not tails of the original.

ListCursor

struct ListCursor[T]
    private at: Option[&Cell[T]]

A position in a list, which is just the cell to look at next – there is nothing else to remember, since a cell knows the whole of the rest of the list.

It holds the cell rather than the list, and holding it is what keeps it alive: a cursor part-way through a list that nothing else refers to still owns the cells it has not reached.

Map

struct Map[K: Hash + Eq, V]
    private core: &MapCore[K, V]

A mapping from keys to values, with a key found in constant time on average.

A Map is a reference to one table, like Scala’s mutable.HashMap. Assigning it, passing it, capturing it in a closure or reading it out of another container gives a second name for the same map, so a put through either name is seen through both:

var m: Map[int, int] = map()
var n = m

n.put(1, 10)
print(m.len(), m.get(1).unwrap_or(0))      // 1 10

It has no zero value, the table it names having to exist, so a declaration says where its map comes from: var m: Map[string, int] = map().

What a Map holds is one counted reference to a MapCore, which carries the table and its two counts. Holding the counts beside the table is what makes the sharing whole: a map whose copies each carried their own counts over one shared table would let a put through one copy write an entry the other’s count never learned of.

MemberSignatureDescription
cellcell -> []Slot[K, V]The table itself, every slot of it, for a reader that wants to look rather than ask.
countcount -> usizeHow many entries are live.
deaddead -> usizeHow many tombstones the table is carrying.
lenconst len(self) -> usize
is_emptyconst is_empty(self) -> bool
capacityconst capacity(self) -> usizeHow many slots the map’s storage has.
getconst get(self, k: K) -> Option[V]
get_orconst get_or(self, k: K, default: V) -> VThe value a key names, or the one the caller supplies where it names none.
hasconst has(self, k: K) -> bool
putconst put(self, k: K, v: V)A key given a value: the entry it already had takes the new one, or a fresh entry is placed.
removeconst remove(self, k: K) -> boolA key taken out, answering whether it was there.
clearconst clear(self)Every entry dropped and the table let go entirely; see MapCore.clear.
copyconst copy(self) -> Map[K, V]A second map holding the same entries, its table sized to them – the one way to get a map that is not this one, every other way of handing a Map on handing on this one.
releaserelease(*self)This name’s share of the table given back, and the handle left naming nothing – the state zeroed storage starts in, where every reader answers as an empty map does.

Set

struct Set[K: Hash + Eq]
    entries: Map[K, bool]

A set of keys, with membership answered in constant time on average.

It is a Map with nothing in the value column, and that is a deliberate choice about where the duplication goes rather than laziness. A bespoke table would save the value slot – for a Set[int] that is eight bytes of padding per slot against twenty-four – and would cost a second copy of the probing, the tombstone rule and the rehash sizing, which is the part of a hash table that is actually hard to get right. Two copies of that is two places for it to be wrong, and only one of them would have the tests.

The representation is behind the API, which is what makes the choice reversible: add, has and remove say nothing about how the table is laid out, so a bespoke table can replace this one later without moving anything a program can name.

The value stored is true throughout and is never read. What carries the information is whether the key is in the map at all.

MemberSignatureDescription
lenconst len(self) -> usize
is_emptyconst is_empty(self) -> bool
capacityconst capacity(self) -> usize
hasconst has(self, k: K) -> bool
addconst add(self, k: K) -> boolA key put in, answering whether it was new.
removeconst remove(self, k: K) -> boolA key taken out, answering whether it was there.
clearconst clear(self)
releaserelease(*self)The map underneath given back without allocating, as Map.release does – for a set in storage the program zeroed itself, which answers as empty until it is assigned.

SetCursor

struct SetCursor[K]
    inner: Cursor[K, bool]

A position in a set, which is a position in the map underneath it with the unread value dropped on the way out.

Slot

enum Slot[K, V]
    Empty
    Dead
    Live(hash: u64, key: K, value: V)

One slot of the table, which is one of three things: never used, used and vacated, or holding an entry.

Empty and Dead are different and the difference is the whole probing scheme. A probe stops at Empty, because a key that hashed here would have been placed at the first free slot it found and so cannot be further along. It does not stop at Dead: something was here when the key it is looking for was inserted, so the chain it belongs to may continue past it.

Live carries the hash beside the key so that a probe can reject a slot on an integer comparison and rehash can re-place an entry without asking the key to mix itself down again. While the map is still a list nothing has been hashed, and the field holds zero.

MemberSignatureDescription
valuevalue(self) -> Option[V]What a slot holds, for a caller that has an index and wants the value at it.
is_liveis_live(self) -> bool

Implementations

Index for Deque[T]

impl[T] Index[usize, T] for Deque[T]

Subscripting reaches the bounds-checked members rather than the storage, so d[i] counts from the front of the deque and cannot read a slot the ring is not currently using.

IndexSet for Deque[T]

impl[T] IndexSet[usize, T] for Deque[T]

Iterate for Cursor[K, V]

impl[K, V] Iterate for Cursor[K, V]

A pair per entry rather than a reference to one, because there is no entry to refer to – an entry lives inside the table, and a reference into storage that rehash replaces is the one thing this design exists to make impossible.

Iterate for DequeCursor[T]

impl[T] Iterate for DequeCursor[T]

Iterate for ListCursor[T]

impl[T] Iterate for ListCursor[T]

Iterate for SetCursor[K]

impl[K] Iterate for SetCursor[K]

Walk for Deque[T]

impl[T] Walk for Deque[T]

for x in d walks a deque from the front to the back, through the cursor walk answers.

Walk for List[T]

impl[T] Walk for List[T]

for x in l walks a list from its head, through the cursor walk answers.

Walk for Map[K, V]

impl[K: Hash + Eq, V] Walk for Map[K, V]

for (k, v) in m walks the entries, a pair apiece, through the cursor walk answers.

Walk for Set[K]

impl[K: Hash + Eq] Walk for Set[K]

for k in s walks the keys, in the map’s order, through the cursor walk answers.

Search

Esc
↑↓ to navigate ↵ to open Esc to close