Documentation / Collections and maps

Collections and maps

Collections store one element type in order. Maps associate one value type with scalar keys and preserve insertion order.

Both use braces because square brackets build Moth templates. Unlike many languages, indexing doesn't form part of the source surface. Fallible methods such as get, set and remove make bounds and lookup failure explicit.

Choose the explanation level: Collection literals

Collection literals

Collections use curly braces and keep their values in order.

scores = {10, 20, 30}
names = {"Priya", "Rob"}

Every item has the same element type.

An empty collection needs an explicit type:

scores ~{Int} = {}

Collections start at index 0.

Use collection methods such as get, set, push and remove instead of square-bracket indexing.

Collections form ordered, zero-indexed, homogeneous groups.

Collection types and literals use braces.

values ~= {1, 2, 3}
names {String} = {"Priya", "Rob"}

Literal inference

  • A non-empty literal infers one element type from its items.
  • Every item must be compatible with that element type.
  • A typed collection receiving site may apply normal contextual coercion to each item.
  • A mixed literal such as {1, "bad"} is invalid.
  • The element type is fixed when the literal is created.

An empty literal needs an explicit collection type at its declaration or field default site.

values ~{Int} = {}

The compiler does not infer an empty collection from:

  • a later push
  • later assignment
  • a loop
  • a function argument
  • HIR use
  • borrow analysis

Collection versus map literals

A top-level key/value entry with = makes a non-empty brace literal a map literal.

Every entry in that literal must then be a key/value pair.

Collection items and map entries cannot be mixed.

Syntax boundaries

  • Collections do not use square-bracket indexing.
  • Square brackets belong to templates.
  • Element reads and writes use compiler-owned collection members.
  • ~ is binding or access mode. It is not part of collection type identity.
Choose the explanation level: Growable collections

Growable collections

A growable collection can gain and lose items.

names ~{String} = {}

~names.push("Priya")

The binding is mutable because the collection changes.

push on a growable collection cannot fail, so it needs no ! or catch. A fixed-size collection is the one that can be full.

An immutable collection can still be read:

names = {"Priya", "Rob"}

first = names.get(0) catch:
    then "guest"
;

{T} is a growable collection of T.

names ~{String} = {}
values {Int} = {1, 2, 3}

Contract

  • The element type is part of semantic type identity.
  • A growable {Int} is not compatible with {String}.
  • Growable and fixed collections are different types.
  • A mutable binding is required for operations that change the collection.
  • An immutable growable collection remains readable but cannot be mutated through that binding.
  • length() returns the current logical number of items.
  • The element type is not widened by later mutation.

Growable push is infallible in the accepted source contract and returns no value. Allocation exhaustion is unrecoverable and traps rather than entering Error!, so growable push takes neither postfix ! nor catch.

names ~{String} = {}

~names.push("Priya")

Handling a growable push is a compile-time error:

-- INVALID: growable push takes neither handling form
~names.push("Priya") catch:
;
~names.push("Rob")!

Fixed {N T} push is the fallible one, because a fixed collection can be full. See Collection operations for both contracts.

Use Mutable bindings for the distinction between mutable storage and one exclusive operation.

Choose the explanation level: Fixed collections

Fixed collections

A fixed collection has a maximum number of items in its type.

values {3 Int} = {10, 20}

This value has length 2 and capacity 3.

A compile-time constant can name the capacity:

capacity #Int = 4
names ~{capacity String} = {}

Different capacities create different types.

A {4 Int} does not silently become {8 Int} or growable {Int}.

{N T} is a fixed collection of T with maximum length N.

values {3 Int} = {10, 20}

capacity #Int = 4
scratch ~{capacity String} = {}

Type identity

Fixed capacity is semantic type identity.

These are distinct incompatible types:

{Int}
{4 Int}
{8 Int}

Capacity is not a growable allocation hint.

There is no implicit conversion:

  • from growable to fixed
  • from fixed to growable
  • between different fixed capacities

Capacity syntax

Capacity in type position must be:

  • a positive Int literal
  • a bare visible #Int constant name

Capacity position is not general expression position.

Arithmetic, calls, field access, const-record projection, conditionals and nested expressions are invalid there.

Name the calculation first:

base #Int = 4
larger_capacity #Int = base + 2
values ~{larger_capacity String} = {}

Capacity-only shorthand

A binding declaration with an immediate non-empty literal may infer the element type:

capacity #Int = 4
labels {capacity} = {"alpha", "beta"}

{capacity} shorthand is invalid for:

  • empty literals
  • non-literal initializers
  • function signatures
  • aliases
  • fields
  • return slots

Empty fixed values

An immutable binding cannot be initialised with an empty fixed literal because there is no later mutable owner path to fill it.

These are valid:

  • a mutable empty fixed binding
  • a fixed collection field default with an explicit field type

A fixed literal may contain fewer items than its capacity.

Its logical length is the number of stored items, not the maximum capacity.

Push and capacity

Fixed push appends after the last stored item. When the collection has a free slot, the push succeeds and returns no value. When the collection is full, the push fails, so a fixed push needs catch or postfix !:

values ~{3 Int} = {10, 20}

~values.push(30) catch:
    io.error("no free slot")
;

The failure goes through the builtin Error! collection error path, and a failed push leaves the stored items unchanged. The ~ before the call is the explicit mutable receiver access that every mutating collection operation requires.

remove frees one slot. Removing an item lets later pushes succeed again, and it never changes the collection's capacity or type.

Choose the explanation level: Collection operations

Collection operations

Read an item with get:

first = items.get(0) catch:
    then 0
;

Change a stored item with set:

~items.set(0, 99) catch:
;

Add and remove items with push and remove.

Whether push can fail depends on the collection. A growable collection gains room as needed, so its push cannot fail:

growable ~{Int} = {10, 20}

~growable.push(30)

A fixed-size collection can be full, so its push can fail and must be handled:

fixed ~{3 Int} = {10, 20}

~fixed.push(30) catch:
;

remove can fail on either one, because the index may not exist:

removed = ~growable.remove(1) catch:
    then 0
;

Get the current number of items with length():

count = items.length()

Operations that can fail must use ! or catch.

Collections use five compiler-owned member operations, with clear accepted as a sixth and not yet implemented.

get

value = items.get(index) catch:
    then fallback
;
  • argument: index Int
  • success: shared access to the stored element value
  • failure: builtin Error!
  • receiver access: shared

set

~items.set(index, value) catch:
;
  • arguments: index Int, then one element value
  • success: no value
  • failure: builtin Error!
  • receiver access: mutable
  • replaces an existing element only
  • does not fill unused fixed capacity

push

One source spelling resolves statically to two contracts, chosen by the receiver's collection shape.

Growable push

~items.push(value)
  • receiver: {T}
  • receiver access: mutable
  • argument: one element value
  • success: no value
  • infallible in Moth source semantics
  • appends after the current last element
  • allocation exhaustion traps rather than entering Error!
  • takes no postfix ! and no catch

Fixed push

~items.push(value) catch:
;
  • receiver: {N T}
  • receiver access: mutable
  • argument: one element value
  • success: no value
  • failure: builtin Error!
  • fallible only because a fixed collection can be full
  • appends after the current last element
  • requires postfix ! or catch

remove

removed = ~items.remove(index) catch:
    then fallback
;
  • argument: index Int
  • success: the removed element
  • failure: builtin Error!
  • receiver access: mutable
  • shifts later elements down
  • frees one slot in a fixed collection

length

count = items.length()
  • no arguments
  • success: Int
  • infallible
  • receiver access: shared
  • returns logical length, not fixed capacity

clear

Accepted design with implementation deferred. Maps already expose clear(); fixed and growable collections do not yet.

~items.clear()
  • no arguments
  • success: no value
  • infallible
  • receiver access: mutable
  • removes every element and sets the logical length to zero
  • keeps the collection usable, and does not shrink fixed capacity

Compiler-known retention effects

Builtin fixed and growable collections are Moth's trusted dynamic-storage substrate. The compiler knows the retained-edge summary contributed by each stored value. An Int can contribute zero obligations, a direct heap-backed value can contribute one, and an inline aggregate that physically stores several handles can contribute several direct obligations.

A summary may also describe nested retention, so the compiler knows what a stored value structurally contains. That is a description, not a count: obligations are the direct edges between final allocation families. A separately allocated child owns its own outgoing edges, and an allocation reachable only through such a child is never counted again by the container. If layout refinement instead places a child inline in the collection's storage, its handles become direct collection edges and do count.

Memory analysis uses those value summaries rather than assuming that one element means one edge.

The table describes each operation's successful path.

| Operation | Retained-edge effect | |---|---| | get | creates a statically bounded temporary alias. It adds no persistent obligation | | push | on a committed insertion, adds the inserted value's direct retained-edge summary | | set | removes the replaced element's obligations and adds the new element's obligations | | remove | removes the stored element's obligations and returns the existing value as a detached stored result | | clear | removes the obligations contributed by every stored element | | collection destruction | removes all element obligations and destroys the backing-storage domain | | growth or reallocation | replaces backing storage while preserving logical element summaries |

For the initial direct-handle implementation, an element commonly contributes zero or one direct obligation. The general summary supports aggregate and nested retention without changing the collection contract.

Successful commits and failed operations

A builtin collection mutation commits its retained-edge effects atomically on the successful path. A failed operation preserves the original storage topology and all cleanup obligations. Invalid-index remove destroys no obligation, a failed fixed-capacity push adds none and a failed set leaves the previous entry intact. Growable push has no recoverable error path, so it has no failed-operation retention case. Count changes happen after the operation reaches its semantic commit point.

Public summaries must preserve exit-specific effects:

```text success: remove old obligations add new obligations

error: no retention change ```

clear kills the collection's complete retention domain. If no other live alias or retention domain can retain a target allocation family, the call becomes that family's final cleanup frontier. The collection itself may continue to live. Declared-region-owned allocation storage still remains until declared-region exit.

Individual remove or set does not establish a final cleanup frontier by itself.

The compiler does not recognise user methods by name. A user abstraction earns the same strong summary only by composing these builtin operations in a way analysis can prove kills every relevant reference on every path.

Future collection APIs must preserve narrow, analysable destruction effects. An operation whose effect on stored references cannot be stated precisely weakens memory analysis for every program that uses it.

For the accessible explanation, see Automatic cleanup and retained edges. The compiler implementation details live in Retained Edge Counting.

Shared rules

  • get, set, remove and fixed-collection push must use postfix ! or catch.
  • set, push, remove and clear require ~receiver.
  • length, clear and growable push are infallible and take no error handling.
  • Builtin member arguments are positional-only.
  • Assignment through get is removed.
  • Square-bracket indexing is not supported.
  • Invalid indices use the typed error path rather than a silent no-op.
  • A live result from get prevents conflicting mutation of the same collection until that shared access reaches its last use.

Error propagation and recovery are specified by Errors, options and assertions.

Choose the explanation level: Hash maps

Hash maps

A hash map stores values under keys.

scores ~= {
    "Priya" = 10,
    "Rob" = 12,
}

Use get to read a value:

score = scores.get("Priya") catch:
    then 0
;

Use set and remove through a mutable receiver:

~scores.set("Emmy", 7) catch:
;

removed = ~scores.remove("Rob") catch:
    then 0
;

Maps remember insertion order.

length is a property:

count = scores.length

Builtin keys are limited to String, Int, Bool and Char.

Hash maps form insertion-ordered key/value groups.

Types and literals

scores ~= {"Priya" = 10, "Rob" = 12}
empty_scores ~{String = Int} = {}
  • A map type is {Key = Value}.
  • A map literal contains key = value entries.
  • Any top-level = entry makes a non-empty brace literal a map literal.
  • Every entry must then be a key/value pair.
  • Collection items and map entries cannot be mixed.
  • An empty map literal needs an explicit or immediate contextual map type.
  • A bare identifier in key position is a variable reference, not string shorthand.
  • Map values follow the same runtime-storable rules as collection elements.

Key contract

Builtin map keys are permanently limited to:

  • String
  • Int
  • Bool
  • Char

Invalid key families include:

  • Float
  • structs
  • choices
  • collections
  • maps
  • traits
  • functions
  • external opaque types
  • generic parameters

The builtin map surface does not expose user-defined HASHABLE, custom hashers or custom comparers.

Every String key uses content equality and content hashing. Quoted and template-produced strings follow the same key rule because construction origin does not create a second string category.

Storage and order

Maps own their entry structure. Existing keys and values stored in entries follow the ordinary shared-reference, explicit-copy and inferred-transfer rules.

Insertion order follows these rules:

  1. First insertion chooses the entry position.
  2. Replacing an existing key updates the value without moving the entry.
  3. Replacement keeps the existing stored key.
  4. Removing a key removes its entry from the order.
  5. Re-inserting that key appends a new entry.

Operations

get(key):

  • shared receiver
  • fallible
  • returns shared access to the stored value

contains(key):

  • shared receiver
  • infallible
  • returns Bool

set(key, value):

  • mutable receiver
  • fallible
  • inserts or replaces
  • returns no old value

remove(key):

  • mutable receiver
  • fallible
  • removes the entry and returns the removed value under the normal lifetime and ownership rules

clear():

  • mutable receiver
  • infallible
  • removes every entry

length:

  • shared read-only property
  • no parentheses
  • returns Int
  • cannot be assigned
score = scores.get("Priya") catch:
    then 0
;

found = scores.contains("Priya")

~scores.set("Emmy", 7) catch:
;

removed = ~scores.remove("Rob") catch:
    then 0
;

count = scores.length
~scores.clear()

get, contains and remove borrow the lookup key.

A live shared value returned by get prevents mutation of the same map until that shared access is no longer used.

Compiler-known retention effects

Builtin maps are part of Moth's trusted dynamic-storage substrate. The compiler knows the retained-edge summary contributed by every stored key and value. A scalar can contribute zero obligations, a direct heap-backed value can contribute one, and an inline aggregate that physically stores several handles can contribute several direct obligations.

A summary may also describe nested retention, so the compiler knows what a stored value structurally contains. That is a description, not a count. Obligations are the direct edges between final allocation families: a separately allocated child owns its own outgoing edges, and an allocation reachable only through such a child is never counted again by the map. If layout refinement instead packs a child inline into the map's storage, its handles become direct map edges and do count.

The table describes each operation's successful path. Maps retain both stored keys and stored values. Replacing an existing key keeps the stored key and changes only the stored value. The lookup key remains a temporary borrowed alias unless the operation inserts it as a new stored key.

| Operation | Retained-edge effect | |---|---| | get or contains | creates a temporary lookup alias and adds no persistent obligation | | new-key set | adds the stored key's obligations and the new value's obligations | | existing-key set | keeps the existing stored key, removes the old value's obligations and adds the new value's obligations. The incoming lookup key is not retained | | remove | removes the stored key's and stored value's obligations. The value becomes a detached stored result, but the stored key does not | | clear | removes all stored key and value obligations | | map destruction | removes all stored key and value obligations and destroys the backing-storage domain |

For the initial direct-handle implementation, a stored scalar commonly contributes zero obligations and a direct heap-backed value commonly contributes one. The general summary supports aggregates and nested retention without a new map vocabulary.

Successful commits and failed operations

A builtin map mutation commits its retained-edge effects atomically on the successful path. A failed operation preserves the original entry structure and all cleanup obligations. A failed lookup or remove destroys no obligation. A failed insertion retains neither the incoming key nor the incoming value. A failed replacement leaves the stored key and old value obligations intact. Count changes happen after the operation reaches its semantic commit point.

Public summaries preserve the exit-specific effects:

```text success: remove old value obligations when present add new key and value obligations when inserting

error: no retention change ```

Consider a map whose key and value point to the same allocation family:

text = [: large value]
values ~{String = String} = {}

~values.set(text, text)! -- final use of text
removed = ~values.remove("large value")!

The new-key insertion creates two direct persistent obligations to the same allocation family, one from the stored key and one from the stored value. Because this was the final use of text, its existing cleanup obligation can be reused for exactly one of those two stored references. The second is a genuinely new obligation.

Existing-key replacement keeps the stored key, so an equal-content lookup key does not add another obligation. The lookup string passed to remove is a temporary borrowed alias, not an obligation to the stored family.

Removal drops the stored key obligation and reclassifies the stored value obligation into the result when affine responsibility can move there. One removed reference can become the result's obligation; the other simply disappears.

clear kills the map's complete retention domain. If no other live alias or retention domain can retain a target allocation family, the call becomes that family's final cleanup frontier. The map itself may continue to live. Declared-region-owned allocation storage still remains until declared-region exit.

Future map APIs must preserve narrow, analysable destruction effects. The full contract is explained for users in Automatic cleanup and retained edges. Compiler implementation details live in Retained Edge Counting.

Outside the builtin map surface

  • hashset syntax
  • map equality
  • indexing syntax
  • mutable entry APIs
  • const maps
  • fixed-capacity maps
  • specialised map variants
  • user-defined key types

More sophisticated maps belong in ordinary package structs.

Inline nesting limit

Map types nested more than two levels deep inline are rejected by the parser. Use a named type alias for deeper nesting.

The current builtin map runtime path is HTML-JS. Targets without map lowering reject reachable map use before backend lowering. Use the progress matrix for current target coverage.