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:

vyi
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:

vyi
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 a char. 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-copy String view of a codepoint range. It shares storage with the source.
  • s == t is byte equality (no Unicode normalisation — two different byte encodings of the same glyph compare unequal).
  • s + t concatenates into a fresh buffer from s's allocator — which is why a String carries its allocator: chains like a + b + c never 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:

vyi
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).

vyi
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, not cap. 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 [*]T over the in-use region. It's a view of the current buffer — if a later push grows 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.len is how you ask the length, and v.items is there when you need the raw buffer.

Any type works as the element, structs included:

vyi
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:

vyi
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:

vyi
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:

vyi
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.

vyi
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:

vyi
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.