container design
Design goal
Generic code often needs to move data between matrix and vector
representations, or to look at individual entries, without caring about
storage: dense arrays, persistent vectors, views, lazy functions, foreign
buffers. container describes those structural capabilities separately from
the mathematical ones of algebra, so that a type can take part
in data movement without claiming algebraic laws, and the other way round.
Mathematical background
A container is a representation of a function
Abstractly, a vector of length with elements in is a function
, where , and an matrix is a
function . A concrete container type V represents
such functions. Two capabilities connect the representation with the function
it denotes:
- read gives the denotation: for ;
- build goes the other way: is some representation of restricted to .
The basic law tying them together is that building and then reading returns the function you started from:
Reading and then building gives a container that is observationally equal to
the original: indistinguishable through get, though perhaps a different
value in memory.
Generic algorithms as compositions
With these two maps, the generic algorithms are compositions of functions on the denotations:
Two laws follow directly and are what the tests check:
where is observational equality. The first pair are the functor laws:
on denotations, map is post-composition, and post-composition preserves
identities and composition.
Editing as a lens
A persistent edit and a read form a lens on position , and a correct implementation satisfies the three lens laws for every valid index:
and, being persistent, it leaves itself unchanged. A mutable edit satisfies the same equations with “the state of after the call” in place of the returned value.
Design decisions
Operation dictionaries instead of traits
Problem. A capability such as “read elements of type T from container
V” relates two types. MoonBit traits have one Self parameter and no
associated types.
Options. (a) A trait on V that fixes the element type, for example
through a generic method. (b) A trait on V per element type. (c) A record of
functions parametrized by both types.
Decision. (c): VectorReadOps[V, T] and its siblings are plain structs of
closures, built with new and passed explicitly.
Why. A record expresses the two-parameter relation directly. It also lets one container type publish several dictionaries, for example a checked and a clamping read, or dictionaries for different element types of a polymorphic foreign handle, which a trait instance (unique per type) could not. The cost is explicit passing; the algorithms take the dictionaries as arguments.
Read and build are separate
A view can be read but not built; a write-only sink can be built but not read; a foreign handle may allow only reads. Combining read and build into one capability would force every such type either to fake the missing half or to stay out. The algorithms state exactly which half they need on each side: source read, target build.
Two editing models
Persistent editing returns a new value; mutable editing changes the argument
and returns Unit. They are different contracts: generic code written for the
persistent form may keep the old value and expect it unchanged, which a mutable
implementation would violate. So they are separate records, and a type
provides the one matching its ownership model. Neither implies resizing,
insertion or deletion.
Read everything, then build
The algorithms read the whole source into a temporary array before calling
tabulate. This costs memory, but it makes failure atomic: if any read
fails, the algorithm returns that error before the target exists, so callers
never see a half-built container. It also calls the user’s mapping function
exactly once per element, in row-major order, which matters when the function
has effects or is expensive.
Checked everywhere
Every dictionary function returns a Result. A generic algorithm cannot know
the bounds behaviour of an arbitrary container, so the contract requires each
implementation to report bad indices and shapes as values instead of aborting.
The repository adapters validate before they touch storage.
Correctness and invariants
- Shape preservation.
mapandconvertpreserve exactly, including and ;transposeproduces . The algorithms never callgeton an empty dimension. - Index mapping of the transpose. The source is buffered in row-major order, so source entry sits at offset . The target initializer at reads that offset, which is exactly .
- Error precedence. A negative reported shape gives
NegativeDimensionbefore any read; otherwise the first failing read (in row-major order) is returned; otherwise the result oftabulateis returned unchanged. - Complexity. reads, one
tabulatethat evaluates the initializer times, and calls of the mapping function, for .
Alternatives rejected
- Generic
insert,delete,createorremove. Removing a sparse entry, setting it to zero, deleting a row and resizing a matrix are different operations; one name for all of them would have no clear law. - Optimized kernel operations in the read dictionary (row swaps, scaled row
additions). Requiring them would exclude simple containers. A future
MatrixKernelOps-style dictionary may be added beside read and build as an optional capability. - Streaming algorithms without the buffer. They would save memory but give up failure atomicity for targets that are built incrementally.
Boundaries
container defines no storage, no arithmetic and no algebraic laws. It
provides no sparse or lazy containers itself, no resizing or structural
editing, and no high-performance kernels; the algorithms are copies
through closures and are meant for interchange, not inner loops. Concrete
dictionaries for this repository’s types live in
container/adapters.