Skip to content

Sequences

Growable lists, fixed arrays, and borrowed slices — how each is built, read, iterated, and converted.

Updated View as Markdown

Atoll has three sequence forms. They are not spellings of one another — each makes a different promise about growth and ownership.

Form Nominal type Length Owns storage Has methods
[]T List[T] runtime, growable yes the full list API
[N]T fixed array compile-time N inline value none — indexing only
[..]T Slice[T] runtime, fixed no, borrows a list a small view API

All three are homogeneous: every element has type T.

fn shapes(): int {
    growable: []int = [1, 2, 3]
    fixed: [3]int = [1, 2, 3]
    view := growable.slice(0..2)
    return growable.len() + (view?.len() ?? 0) + (fixed[0] ?? 0)
}

Lists

List[T] is the nominal type and []T is the sugar you should write. A square-bracket literal builds one whenever the expected type is growable.

fn names(): []string {
    return ["Ada", "Grace", "Katherine"]
}

Both spellings name the same type, so they interchange freely in signatures:

fn a(xs: []int): int => xs.len()
fn b(xs: List[int]): int => a(xs)

fn f(): int {
    return b([1, 2, 3])
}

An empty literal has nothing to infer T from, so it needs either an annotation or a later use that pins the element type:

fn annotated(): int {
    empty: []int = []
    return empty.len()
}

fn inferred_from_use(): int {
    mut values := []
    values.add(42)
    return values.len()
}

Constructors

[]T() builds an empty list, and an integer argument is a capacity hint. It desugars to List.new[T](capacity), which you can also call directly.

fn constructors(): int {
    empty := []int()
    buffer := []byte(4096)
    same := List.new[int]()
    reserved := List.new[byte](4096)
    return empty.len() + buffer.len() + same.len() + reserved.len()
}

Capacity reserves storage; it does not change the logical length. All four lists above have len() == 0. Reserve up front when you know the final size, and use reserve to extend the hint later:

fn build(count: int): []int {
    mut out := []int(count)
    out.reserve(count)
    mut i := 0
    for i < count {
        out.add(i * i)
        i += 1
    }
    return out
}

len() and size() are aliases; capacity() reports the reserved slot count, which is always at least len().

Reading elements

Indexing yields an Option. xs[0] has type T?, and so does xs.get(0). There is no bounds-check abort in Atoll, so the optional is the only honest result type.

fn reads(): int {
    xs := [10, 20, 30]
    a := xs[0] ?? -1              // Option fallback operator
    b := xs.get(5).unwrap_or(-1)  // method form
    c := match xs[1] {
        Some(v) => v
        None => 0
    }
    return a + b + c
}

Forgetting the unwrap is a type error, not a silent coercion:

fn oops(): int {
    xs := [1, 2, 3]
    return xs[0] + 1
}

first() and last() are the endpoint shortcuts, and they are optional for the same reason:

fn endpoints(xs: []int): string {
    lo := xs.first() ?? 0
    hi := xs.last() ?? 0
    return "${lo}..${hi}"
}

get_unchecked(i) returns T directly and skips the bounds test. It exists for code that has already proved the index is in range — reach for it only behind such a proof, never as a convenience to dodge the Option.

Growing and updating

add appends, set overwrites an existing position. Out-of-range set is a no-op rather than an error.

fn mutate(): string {
    mut xs := [1, 2]
    xs.add(3)
    xs.set(0, 10)
    xs.set(99, 0)   // out of range: ignored
    return "${xs.len()} ${xs[0] ?? 0}"
}

Declare the binding mut when you intend to change the list. It documents intent at the definition site and keeps whole-value reassignment available:

fn replace(): int {
    mut xs := [1, 2, 3]
    xs = [4, 5]
    return xs.len()
}

Reassigning a non-mut binding is rejected:

fn frozen(): int {
    xs := [1, 2, 3]
    xs = [4, 5]
    return xs.len()
}

Transforming

The list API is built from non-mutating combinators — map, filter, sorted_by, take, distinct, and friends return a new list and leave the receiver alone.

fn squares_of_odds(xs: []int): int {
    return xs.filter(v => v % 2 == 1).map(v => v * v).sum()
}

fn top_two(xs: []int): []int {
    return xs.distinct().sorted().take(2)
}

Closures take either form — v => ... as an argument, or a trailing brace block:

fn predicates(xs: []int): bool {
    return xs.any(v => v > 2) && xs.all { v => v > 0 }
}

partition returns a tuple of two lists, accessed positionally:

fn split(xs: []int): string {
    parts := xs.partition(v => v % 2 == 0)
    return "${parts.0.len()} even, ${parts.1.len()} odd"
}

Iterating

for walks a list element by element. for mut binds each element by a write-back binding, so assigning to the loop variable updates the list.

fn sum(xs: []int): int {
    mut total := 0
    for v in xs {
        total += v
    }
    return total
}

fn double_in_place(): int? {
    mut xs := [1, 2, 3]
    for mut v in xs {
        v = v * 2
    }
    return xs[0]
}

enumerate() pairs each element with its index; the pair is a tuple, so use .0 and .1:

fn numbered(xs: []string): []string {
    mut out := []string()
    for pair in xs.enumerate() {
        out.add("${pair.0}: ${pair.1}")
    }
    return out
}

Nested lists iterate the same way — [][]T is just a list whose element type is a list:

fn grid_total(): int {
    grid: [][]int = [[1, 2], [3, 4]]
    mut total := 0
    for row in grid {
        for v in row {
            total += v
        }
    }
    return total
}

Adding to or removing from a list while a traversal of it is live can invalidate the traversal’s logical assumptions even though memory stays safe. Build a new list with map/filter, or finish iterating before you mutate.

Fixed arrays

[N]T has its length in the type. The literal must contain exactly N elements, and each is checked against T.

fn loopback(): [4]byte {
    return [127, 0, 0, 1]
}

The length may be a named integer constant in a local annotation:

const WIDTH = 4

fn blank_row(): int {
    row: [WIDTH]byte = [0, 0, 0, 0]
    return row[0]?.to_int() ?? 0
}

Today that only works inside a function body. A named constant in a parameter or return type is not const-evaluated yet, so signatures need a literal length:

const WIDTH = 4

fn blank_row(): [WIDTH]byte {
    return [0, 0, 0, 0]
}

Both conditions are compile-time. A wrong count and a mixed element type are each rejected outright:

fn short(): void {
    a: [3]int = [1, 2]
}
fn mixed(): void {
    xs := [1, "two"]
}

Arrays carry no methods

This is the sharpest practical difference from a list. A fixed array supports indexing — still optional-valued — and indexed assignment. That is all.

fn array_ops(): int {
    mut a: [4]int = [1, 2, 3, 4]
    a[1] = 20
    mut total := 0
    mut i := 0
    for i < 4 {
        total += a[i] ?? 0
        i += 1
    }
    return total
}

Note the loop: it counts to the literal N rather than calling len(), since there is no len() — N is already visible in the type.

fn no_len(): int {
    a: [4]byte = [127, 0, 0, 1]
    return a.len()
}

The same is true of get, size, first, map, slice, and the rest of the list API. When you want them, copy the array into a list:

fn to_list(a: [4]byte): []byte {
    mut out := []byte()
    mut i := 0
    for i < 4 {
        out.add(a[i] ?? 0u8)
        i += 1
    }
    return out
}

for value in array type-checks, but the loop it lowers to does not currently terminate. Until that is fixed, walk an array with the counted for condition form shown above, or convert it to a []T and iterate that.

Array types are exact

Arrays of the same element type but different lengths are different types, and an array never implicitly becomes a list:

fn coerce(): void {
    a: [3]int = [1, 2, 3]
    xs: []int = a
}

Use [N]T for protocol fields, fixed vectors, and layouts where the count is part of the contract. Use []T whenever the length is decided at runtime.

Assigning an array to a second binding does not currently give you an independent copy — writing through the new binding is observable through the original. Treat an array as data you build once and read, and go through a []T when two bindings must diverge.

Arrays in struct fields

An array can be declared and constructed as a struct field, but it cannot be indexed through the field path:

struct Ipv4 { octets: [4]byte }

fn first_octet(): byte? {
    ip := Ipv4 { octets: [10, 0, 0, 1] }
    return ip.octets[0]
}

Copying the field into an annotated local passes the checker but does not yet survive lowering either, so today an array field is effectively write-only. Declare the field as []byte when you need to read it back:

struct Ipv4 { octets: []byte }

fn first_octet(): byte? {
    ip := Ipv4 { octets: [10, 0, 0, 1] }
    return ip.octets[0]
}

Fixed arrays are at their best as locals and as parameters, where the checker enforces the count and the code reads them by index.

Slices

Slice[T], written [..]T, is a non-growing view over a range of a list’s elements. It borrows: no separate buffer is allocated, and the view keeps the backing storage valid for as long as it lives.

Take one with slice(range). The range is half-open — 1..3 selects indices 1 and 2 — and an out-of-range request returns None:

fn middle_len(xs: []int): int {
    return xs.slice(1..3)?.len() ?? 0
}

A match is the usual shape when the body is more than one expression:

fn window_sum(xs: []int): int {
    match xs.slice(1..3) {
        Some(view) => {
            mut total := 0
            for v in view {
                total += v
            }
            return total
        }
        None => return 0
    }
}

Slices in signatures

Take [..]T when a function reads a contiguous run of elements and must not retain or grow them. The nominal spelling Slice[T] is accepted everywhere the sugar is, and it is the one to use when you need to attach ?:

fn checksum(data: [..]byte): int {
    mut acc := 0
    for b in data {
        acc = (acc + b.to_int()) % 255
    }
    return acc
}

fn head(xs: []int): Slice[int]? {
    return xs.slice(0..1)
}

[..]int? parses as a slice of int?, not an optional slice. Write Slice[int]? for “a slice or nothing”.

The view API

A slice has len, size, is_empty, get, first, last, set, slice, and to_list. It has no add, reserve, or any other growth operation:

fn view_api(xs: []int): string {
    match xs.slice(0..3) {
        Some(v) => {
            sub := v.slice(1..2)
            return "${v.len()} ${v.first() ?? 0} ${sub?.len() ?? 0} ${v.to_list().len()}"
        }
        None => return "none"
    }
}
fn cannot_grow(xs: []int): void {
    match xs.slice(0..1) {
        Some(v) => v.add(9)
        None => {}
    }
}

Re-slicing produces another view over the same backing storage; to_list() is the operation that copies the viewed elements into an independent []T.

Because a match arm binds the view immutably, rebind it when you want to write through the view:

fn write_through(): int? {
    mut xs := [1, 2, 3]
    match xs.slice(0..2) {
        Some(v) => {
            mut view := v
            view.set(0, 99)
        }
        None => {}
    }
    return xs[0]
}

Growth versus views

Appending to a list may move its backing storage. An existing slice keeps the storage and range it was created from, so it stays valid and keeps its own length — it does not follow the owner’s new length:

fn growth(): string {
    mut xs := [1, 2, 3]
    view := xs.slice(0..3)
    xs.add(4)
    return "view=${view?.len() ?? 0} list=${xs.len()}"
}

This is exactly why a slice is a distinct type rather than another spelling for a list: []T promises you can grow it, [..]T promises you cannot.

Choosing a form

Requirement Type
Append, remove, or reserve []T
The exact count is part of the contract [N]T
Read a contiguous range without owning a buffer [..]T
Independently owned copy of viewed elements slice(...) then .to_list()
Growable binary buffer []byte
Borrowed binary input [..]byte

Do not accept a slice when the callee must retain and mutate the data after the owner is gone — take a []T and let it own the elements. Do not accept a list when the count is a protocol invariant — take a [N]T so the checker enforces it.

A composed example

Everything above in one unit: a fixed-size protocol header, a growable buffer of samples, and a borrowed window handed to a pure function.

struct Reading {
    sensor: string
    value: float
}

const WINDOW = 3

fn moving_average(samples: [..]float): float {
    if samples.is_empty() {
        return 0.0
    }
    mut total := 0.0
    for v in samples {
        total = total + v
    }
    return total / samples.len().to_float()
}

fn latest_window(samples: []float): Slice[float]? {
    if samples.len() < WINDOW {
        return samples.slice(0..samples.len())
    }
    return samples.slice(samples.len() - WINDOW..samples.len())
}

fn format(readings: []Reading): []string {
    mut lines := []string()
    for r in readings {
        lines.add("${r.sensor}=${r.value}")
    }
    return lines
}

fn main(): void {
    mut samples := []float(8)
    samples.add(1.0)
    samples.add(2.0)
    samples.add(6.0)
    samples.add(3.0)

    avg := match latest_window(samples) {
        Some(w) => moving_average(w)
        None => 0.0
    }

    for line in format([Reading { sensor: "t0", value: avg }]) {
        println(line)
    }

    tag: [4]byte = [65, 84, 79, 76]
    mut header := []byte(4)
    mut i := 0
    for i < 4 {
        header.add(tag[i] ?? 0u8)
        i += 1
    }
    println("header bytes: ${header.len()}")
}

The library chapter documents the full method surface in Lists; the ownership rules that make a slice safe to hold are in References and Values.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close