Guides · 10
Collections and strings
Vyi's owned containers — String, Vec(T), and HashMap(K, V) — live in the
@core package. They are not built into the language: there is no array-literal
sugar for a Vec, no map-literal sugar for a HashMap, and you import them like
anything else. Each one is allocator-backed — you hand it an allocator at
construction and every byte it owns comes from there. Nothing allocates behind
your back, and where the memory lives is always visible at the call site (guide
09 — Pointers and memory).
The examples below follow one setup pattern: create an ArenaAllocator over the
system allocator, pass &arena to the container, and do the work in a small
function called from main. If a container can't get memory, it panics with
"out of memory" — you don't handle an error union on every push.
Strings
String literals are byte views
A "" literal is a baked array of UTF-8 bytes, stored in the program's data
section at compile time. When it flows into a [*]u8 slot it decays to a
pointer-plus-length view of those bytes — that's the type most string-shaped APIs
(like stdout.write) take:
import { stdout } from "@io"
fn main() !u8 {
con greeting: [*]u8 = "hello"
stdout.write(greeting)
stdout.write("\n")
con first = greeting[0] // a u8: 104, the byte 'h'
con count = greeting.len // 5
con c: char = 'é' // a char: one 32-bit codepoint, U+00E9
if first == u8('h') && count == 5 && u32(c) == 233 {
stdout.write("ok\n")
}
return Ok(0)
}Two levels are in play: indexing a [*]u8 gives you bytes (u8), while a ''
literal is a char — a whole 32-bit Unicode codepoint. For ASCII they
coincide; the moment your text leaves ASCII, one codepoint can be several bytes,
and the String type below exists to manage exactly that gap.
A literal is immutable borrowed data. To own text — grow it, keep it beyond the
current expression, concatenate — you copy it into allocator memory as a String.
The String type
String stores UTF-8 bytes plus the allocator that owns them, and presents the
text as a sequence of codepoints. String.fromUtf8(alloc, bytes) copies a
[*]u8 (a literal, usually) into a fresh allocation:
import { String } from "@core"
import { ArenaAllocator, systemAllocator } from "@mem"
import { stdout } from "@io"
fn run() {
mut arena = ArenaAllocator.new(systemAllocator)
con cafe = String.fromUtf8(&arena, "café")
con bytes = cafe.byteLen() // 5 — 'é' is two bytes of UTF-8
con chars = cafe.length() // 4 — but one codepoint
con last = cafe[-1] // 'é' — s[i] indexes codepoints; negative = from the end
con hello = String.fromUtf8(&arena, "hello")
con sub = hello[1..3] // "el" — codepoint slice, zero-copy view
con both = hello + cafe // "hellocafé" — fresh allocation
stdout.write(both.asBytes())
stdout.write("\n")
if bytes == 5 && chars == 4 && last == 'é' && sub.byteLen() == 2 {
stdout.write("ok\n")
}
if hello == String.fromUtf8(&arena, "hello") { stdout.write("eq\n") }
}
fn main() !u8 {
run()
return Ok(0)
}The operators are ordinary methods on String (guide
13 — Operators and overloading):
s[i]returns the i-th codepoint as achar. Negative indices count from the end. Decoding walks from the start, so this is O(i) — it's honest about what UTF-8 costs.s[lo..hi]returns a zero-copyStringview of a codepoint range. It shares storage with the source.s == tis byte equality (no Unicode normalisation — two different byte encodings of the same glyph compare unequal).s + tconcatenates into a fresh buffer froms's allocator — which is why aStringcarries its allocator: chains likea + b + cnever need one passed in.
For byte-level work, drop down a level: s.byteLen() (O(1), vs s.length()
which walks the text), s.byteAt(i), s.indexOfByte(b), and s.asBytes() for
the raw [*]u8 view. There are also isEmpty(), startsWith(prefix), and
endsWith(suffix).
Building strings
String.new(alloc) makes an empty string to accumulate into, and repeat(n)
tiles one:
import { String } from "@core"
import { ArenaAllocator, systemAllocator } from "@mem"
import { stdout } from "@io"
fn run() {
mut arena = ArenaAllocator.new(systemAllocator)
con word = String.fromUtf8(&arena, "ha")
mut line = String.new(&arena)
line = line + word
line += word // compound assignment works too
stdout.write(line.asBytes()) // haha
stdout.write("\n")
stdout.write(word.repeat(3).asBytes()) // hahaha
stdout.write("\n")
}
fn main() !u8 {
run()
return Ok(0)
}Each + allocates a fresh buffer, so building in a loop is O(n²) copying — fine
with an arena and small strings; the cost is visible right where it's paid.
Vec(T)
Vec(T) is the growable array: a contiguous [*]T heap buffer, a len for the
in-use count, and a cap for the allocated slots. Vec(T).new(alloc) reserves
16 slots by default (pass a second argument to choose); push appends, doubling
the buffer when it's full — amortised O(1).
import { Vec } from "@core"
import { ArenaAllocator, systemAllocator } from "@mem"
import { stdout } from "@io"
fn run() {
mut arena = ArenaAllocator.new(systemAllocator)
mut primes = Vec(i32).new(&arena)
primes.push(2)
primes.push(3)
primes.push(5)
con n = primes.len // 3 — a field, not a method
con last = primes[-1] // 5 — negative indices count from the end
primes[0] = 7 // assignment through the same index syntax
mut sum = 0
for p in primes { sum = sum + p } // 7 + 3 + 5 = 15
con view = primes[0..2] // [*]i32 — zero-copy view of two elements
if sum == 15 && view.len == 2 && n + last == 8 {
stdout.write("ok\n")
}
}
fn main() !u8 {
run()
return Ok(0)
}The details:
- Indexing is bounds-checked against
len, notcap. Only pushed elements are addressable;v[i]on anything else panics. Negative indices address from the end (v[-1]is the last element). - Slicing is zero-copy.
v[lo..hi]returns a plain[*]Tover the in-use region. It's a view of the current buffer — if a laterpushgrows the Vec, the Vec moves to a new buffer and the view keeps pointing at the old bytes. - Elements are stored inline, contiguously. A
Vec(Star)of structs is an array of structs, not an array of pointers — there's no boxing anywhere. - The fields are public (
items,len,cap,alloc) —v.lenis how you ask the length, andv.itemsis there when you need the raw buffer.
Any type works as the element, structs included:
import { Vec } from "@core"
import { ArenaAllocator, systemAllocator } from "@mem"
import { stdout } from "@io"
struct Star { name: [*]u8, magnitude: i32 }
fn run() {
mut arena = ArenaAllocator.new(systemAllocator)
mut stars = Vec(Star).new(&arena)
stars.push(Star{ name: "Sirius", magnitude: -1 })
stars.push(Star{ name: "Vega", magnitude: 0 })
for s in stars {
stdout.write(s.name)
stdout.write("\n")
}
}
fn main() !u8 {
run()
return Ok(0)
}HashMap(K, V)
HashMap(K, V) is an open-addressing hash table in the Swiss-table style: keyed
lookups scan a compact array of one-byte hash tags, so most probes never touch a
full key. It grows automatically past a 7/8 load factor. From the outside it's
four operations:
import { HashMap, String } from "@core"
import { ArenaAllocator, systemAllocator } from "@mem"
import { stdout } from "@io"
fn run() {
mut arena = ArenaAllocator.new(systemAllocator)
mut ages = HashMap(String, i32).new(&arena)
ages.put(String.fromUtf8(&arena, "ada"), 36) // insert or update
ages.put(String.fromUtf8(&arena, "alan"), 41)
switch ages.get(String.fromUtf8(&arena, "ada")) { // get returns ?i32
Some(age): { if age == 36 { stdout.write("found\n") } },
None: { stdout.write("missing\n") },
}
if ages.contains(String.fromUtf8(&arena, "alan")) { stdout.write("has alan\n") }
ages.remove(String.fromUtf8(&arena, "alan"))
if ages.len() == 1 { stdout.write("one left\n") }
}
fn main() !u8 {
run()
return Ok(0)
}get returns ?V — there's no null to hand back for a missing key, so absence
is an Optional like everywhere else (guide
07 — Optionals and errors). switch on it, or
catch a fallback when a default value is all you need:
import { HashMap } from "@core"
import { ArenaAllocator, systemAllocator } from "@mem"
import { stdout } from "@io"
fn run() {
mut arena = ArenaAllocator.new(systemAllocator)
mut squares = HashMap(i32, i32).new(&arena)
squares.put(3, 9)
con hit = squares.get(3) catch 0 // 9
con miss = squares.get(5) catch 0 // 0
if hit == 9 && miss == 0 { stdout.write("ok\n") }
}
fn main() !u8 {
run()
return Ok(0)
}What can be a key
The map hashes keys by calling the key type's @hash() method and compares them
with ==. Both resolve through ordinary method lookup, so any type with a
@hash() u32 method (and equality) is a valid key — nothing implements a
special trait. @core provides @hash for every integer width, char, and
String out of the box; give your own type the two methods and it qualifies:
import { HashMap } from "@core"
import { ArenaAllocator, systemAllocator } from "@mem"
import { stdout } from "@io"
struct Point { x: i32, y: i32 }
fn (p: Point) @hash() u32 {
return u32(p.x) * 31 + u32(p.y)
}
fn (p: Point) @eq(other: Point) bool {
return p.x == other.x && p.y == other.y
}
fn run() {
mut arena = ArenaAllocator.new(systemAllocator)
mut cells = HashMap(Point, i32).new(&arena)
cells.put(Point{ x: 1, y: 2 }, 12)
con probe = Point{ x: 1, y: 2 }
switch cells.get(probe) {
Some(v): { if v == 12 { stdout.write("ok\n") } },
None: { stdout.write("missing\n") },
}
}
fn main() !u8 {
run()
return Ok(0)
}The contract is the usual one: keys that compare equal must hash equal.
Iterating a map
keys(), values(), and entries() each collect the live contents into a fresh
Vec (backed by the map's allocator) — a snapshot you iterate with for. An
entry is a small struct with .key and .value. Order is the table's internal
slot order: unspecified, and not stable across growth.
import { HashMap } from "@core"
import { ArenaAllocator, systemAllocator } from "@mem"
import { stdout } from "@io"
fn run() {
mut arena = ArenaAllocator.new(systemAllocator)
mut squares = HashMap(i32, i32).new(&arena)
squares.put(1, 1)
squares.put(2, 4)
squares.put(3, 9)
mut keySum = 0
for k in squares.keys() { keySum = keySum + k } // 6
mut pairSum = 0
for e in squares.entries() { pairSum = pairSum + e.key * e.value } // 36
if keySum == 6 && pairSum == 36 { stdout.write("ok\n") }
}
fn main() !u8 {
run()
return Ok(0)
}Iterating
for x in c isn't special-cased to @core's types. It works over anything that
speaks the iteration protocol: an @index(i: i32) method plus a len()
method. The loop reads c[0] through c[c.len() - 1]. Vec qualifies;
so does a [*]T view (which is why slices and string bytes iterate directly);
so does anything you write:
import { stdout } from "@io"
struct Evens { count: i32 }
fn (e: Evens) @index(i: i32) i32 {
return i * 2
}
fn (e: Evens) len() i32 {
return e.count
}
fn main() !u8 {
con firstFive = Evens{ count: 5 }
mut sum = 0
for n in firstFive { sum = sum + n } // 0 + 2 + 4 + 6 + 8 = 20
for i in 0..3 { sum = sum + i } // ranges iterate too: + 0 + 1 + 2
if sum == 23 { stdout.write("ok\n") }
return Ok(0)
}Two things for does not give you: an index (track one yourself, or loop
for i in 0..v.len() and index), and iteration over a String's codepoints —
String deliberately has no iteration protocol, because "length" is ambiguous for
UTF-8. Iterate its bytes with for b in s.asBytes(), or its codepoints by
indexing 0..s.length() (each index re-decodes from the front — O(n²) on
purpose, so the cost stays visible).
See also: reference/stdlib/core for the full API surface, and guide 09 — Pointers and memory for the allocators these containers are built on.