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.