Guides · 09

Pointers and memory

Vyi is a by-value language: assignment copies, arguments copy, returning copies. Pointers are how you opt into sharing — and the type system makes two promises about them that most systems languages don't:

  • No null. A pointer always points at something. "Maybe a pointer" is an Optional (?*mut T), stated in the type, forced to be checked. (The one exception is c_ptr T, the nullable C-interop pointer — it exists for FFI and nothing else. Guide 15.)
  • Write permission rides in the type. Every pointer type says whether you may write through it: *mut T or *con T.

There is also no garbage collector and no hidden heap: nothing allocates unless you call an allocator (even closures capture by value). Memory you want beyond the current stack frame, you ask for explicitly — that's the second half of this guide.

Pointers: *mut T and *con T

&x takes the address of x; *p dereferences. The mutability of the pointer follows the binding it came from: &x on a mut gives a *mut T (writable), on a con gives a *con T (read-only).

vyi
import { stdout } from "@io"

fn bump(p: *mut i32) {
    *p = *p + 1
}

fn readVia(p: *con i32) i32 {
    return *p
}

fn main() !u8 {
    mut x = 10
    bump(&x)               // x = 11
    con r = readVia(&x)    // a *mut widens to *con — always safe
    stdout.writeInt(i64(r))
    stdout.write("
")
    return Ok(0)
}

A *mut flows freely into a *con slot (treating a writable address as read-only loses nothing), but never the other way. There is no bare *T — every pointer type states its write permission:

vyi-error
fn readVia(p: *i32) i32 {   // error: write `*mut T` (writable) or `*con T` (read-only)
    return *p
}

Writes through a *con are compile errors, and the check is carried in the type, so it holds at any pointer depth and across function boundaries:

vyi-error
con fixed: i32 = 42

fn main() !u8 {
    con p = &fixed
    *p = 99        // error: cannot assign through a pointer to an immutable `con`
    return Ok(0)
}

The two mutabilities are distinct: con on a binding restricts the name (no reassignment), while *con in a pointer type restricts the pointee. A con binding holding a *mut pointer writes through it just fine — you only couldn't re-point it:

vyi
import { stdout } from "@io"

fn main() !u8 {
    mut x = 5
    con p: *mut i32 = &x
    *p = 6             // fine: the pointer type is *mut
    stdout.writeInt(i64(x))
    stdout.write("
")
    return Ok(0)   // 6  (but `p = &y` would be rejected — p is con)
}

Maybe-a-pointer: ?*mut T

Since pointers can't be null, "no pointer yet" is spelled with an Optional, like any other absence (guide 07):

vyi
import { stdout } from "@io"

fn main() !u8 {
    mut x = 5
    mut slot: ?*mut i32 = None
    slot = Some(&x)
    switch slot {
        Some(p: _): { *p = 7 },
        None:       { },
    }
    stdout.writeInt(i64(x))
    stdout.write("
")
    return Ok(0)   // 7
}

There is no sentinel to forget to check — the switch (or catch/try on the optional) is the only way at the value.

Deref, fields, depth

Field access through a pointer auto-derefs — p.y, not (*p).y. Methods use the same write permission: a *mut method only on a writable place; a *con method on any instance of the type without copying; a value method through a pointer loads a copy. Details: methods & dispatch.

You can take interior addresses (&pt.x, &arr[i]) and stack indirection as deep as you like; each extra level needs an explicit *:

vyi
import { stdout } from "@io"

struct Point { x: i32, y: i32 }

fn main() !u8 {
    mut pt = Point{ x: 3, y: 4 }
    con p = &pt
    p.y = 9             // auto-deref: writes pt.y
    con px = &pt.x      // pointer to one field
    *px = 10

    mut n = 10
    con q = &n
    con qq = &q         // *con *mut i32
    **qq = 20           // write two levels deep: n = 20

    stdout.writeInt(i64(p.x + pt.y + *q))
    stdout.write("
")
    return Ok(0)   // 10 + 9 + 20 = 39
}

Pointers support == and != (same address?) and nothing else — no arithmetic. Walking memory by offset is what [*]T below is for (and, at the FFI boundary, c_ptr):

vyi-error
fn main() !u8 {
    mut x = 1
    con p = &x
    con q = p + 1      // error: strict pointers support only `==`/`!=`
    return Ok(0)
}

Fixed arrays: [N]T

[N]T is a fixed-size array of N elements, and the size is part of the type — [3]i32 and [4]i32 are different types. Literals put the type first, then the elements; an empty bracket zero-initialises:

vyi
import { stdout } from "@io"

fn main() !u8 {
    con a = [4]i32[1, 2, 3, 4]    // explicit length
    mut z = [4]u8[]               // all four bytes zero
    z[0] = 9
    stdout.writeInt(i64(a[0] + a[3] + i32(z[0])))
    stdout.write("
")
    return Ok(0)   // 1 + 4 + 9 = 14
}

Arrays are values. Assigning one copies all the elements, and the copy is independent:

vyi
import { stdout } from "@io"

fn main() !u8 {
    mut a = [3]i32[1, 2, 3]
    mut b = a                    // copies the whole array
    b[0] = 50
    stdout.writeInt(i64(a[0] + b[0]))
    stdout.write("
")
    return Ok(0)   // 1 + 50 = 51: a is untouched
}

The same holds for structs, and for passing either to a function: a parameter is an immutable (con) view of the caller's value — to modify it, copy it into a mut local, which is yours:

vyi
import { stdout } from "@io"

struct Point { x: i32, y: i32 }

fn clobber(p: Point) i32 {
    mut q = p            // your own copy
    q.x = 100
    return q.x + q.y     // 108
}

fn main() !u8 {
    con pt = Point{ x: 7, y: 8 }
    con r = clobber(pt)
    stdout.writeInt(i64(pt.x + pt.y))
    stdout.write("
")
    stdout.writeInt(i64(r))
    stdout.write("
")
    return Ok(0)   // pt unchanged: 7 + 8; clobber returned 108
}

When copies are what you don't want — a buffer several functions fill in — pass a pointer or a [*]T view instead.

Array pointers: [*]T

[*]T is a pointer plus a length, carried together in one value (elsewhere called a slice or fat pointer). It views zero or more Ts in someone else's storage; it cannot be null, and every index is bounds-checked. It's the type you'll touch most: string literals are [*]u8, and allocators hand out [*]Ts.

vyi
import { stdout } from "@io"

fn main() !u8 {
    con s = "hello"    // a [*]u8 over the literal's baked bytes
    con n = s.len      // 5 — the length lives in the value, not the type
    con h = s[0]       // 104 ('h')
    con o = s[-1]      // negative indices count from the end: 111 ('o')
    stdout.writeInt(i64(n))
    stdout.write("
")
    stdout.writeInt(i64(h))
    stdout.write("
")
    stdout.writeInt(i64(o))
    stdout.write("
")
    return Ok(0)
}

Indexing past the end (or before -len) panics at runtime — "array index out of bounds" (guide 08). There is no unchecked indexing; .ptr exposes the raw pointer when FFI genuinely needs it.

Slicing

Range-indexing a [*]T produces another [*]T over a subrange of the same storage — a view, not a copy:

vyi
import { stdout } from "@io"

fn main() !u8 {
    con s = "hello world"
    con head = s[..5]     // "hello"
    con tail = s[6..]     // "world"
    con mid  = s[3..8]    // "lo wo" — lo inclusive, hi exclusive
    con all  = s[..]      // the whole thing
    stdout.writeInt(i64(head.len))
    stdout.write("
")
    stdout.writeInt(i64(tail.len))
    stdout.write("
")
    stdout.writeInt(i64(mid.len))
    stdout.write("
")
    stdout.writeInt(i64(all.len))
    stdout.write("
")
    return Ok(0)
}

lo..hi is half-open, like everywhere else in the language; lo...hi is inclusive (s[...4]s[..5]).

Converting between [N]T and [*]T

The cast syntax is the target type applied like a function. The conversion you'll reach for most takes a pointer to a fixed array and produces a [*]T view over the same storage — the length comes from the [N] in the type:

vyi
import { stdout } from "@io"

fn main() !u8 {
    mut a = [4]u8[1, 2, 3, 4]
    con view = [*]u8(&a)     // a view: same storage, now with a runtime length
    view[1] = 20             // writes through the view land in a
    stdout.writeInt(i64(a[1]))
    stdout.write("
")
    return Ok(0)          // 20
}

This is how a stack array flows into anything that takes a [*]T — see the FixedBufferAllocator example below.

Casting to a value array goes the other way and copies: [4]u8(bytes) snapshots four bytes out of a [*]u8 into a fresh [4]u8 of your own:

vyi
import { systemAllocator } from "@mem"
import { stdout } from "@io"

fn main() !u8 {
    con bytes = systemAllocator.alloc(4, 1) catch { panic "out of memory" }
    bytes[0] = 7
    con copy = [4]u8(bytes)    // copies the bytes; independent from here on
    bytes[0] = 100             // the copy doesn't see this
    stdout.writeInt(i64(copy[0]))
    stdout.write("
")
    return Ok(0)         // 7
}

Finally, *[4]u8(bytes) reshapes a [*]u8 into a pointer-to-fixed-array (dropping the runtime length in favour of the static one), and [*]u8(p) reshapes it back:

vyi
import { systemAllocator } from "@mem"
import { stdout } from "@io"

fn main() !u8 {
    con bytes = systemAllocator.alloc(4, 1) catch { panic "out of memory" }
    con p = *[4]u8(bytes)      // drop .len: the length is now in the type
    con back = [*]u8(p)        // re-attach it from the type
    stdout.writeInt(i64(back.len))
    stdout.write("
")
    return Ok(0)    // 4
}

Rule of thumb: casts between pointer shapes re-describe storage; a cast to a value array ([4]u8(bytes)) copies into a fresh one.

Allocators

Everything above lived in stack frames or baked constants. Longer-lived or runtime-sized memory comes from an allocator, and Vyi's design here is deliberate: allocation is a value you pass around, not ambient machinery. A function that allocates takes an allocator; one that doesn't, can't.

The @mem package defines the interface — two methods, and failure is in the type:

vyi
import { Allocator, OutOfMemory } from "@mem"

fn needsMemory(alloc: Allocator) OutOfMemory![*]u8 {
    return alloc.alloc(64, 1)
}

alloc(size, align) returns OutOfMemory![*]u8 — a slice of exactly size bytes, or an error. Never a null, never a valid-looking empty slice: you try, catch, or switch like any other error union (guide 07).

The simplest allocator is systemAllocator, the platform heap:

vyi
import { systemAllocator } from "@mem"
import { stdout } from "@io"

fn main() !u8 {
    con buf = try systemAllocator.alloc(64, 1)   // 64 bytes, 1-byte aligned
    defer systemAllocator.free(buf)              // release on every exit
    buf[0] = 42
    stdout.writeInt(i64(buf.len))
    stdout.write("
")
    return Ok(0)                  // .len is 64 — exactly what was asked for
}

Typed allocation: new

Raw bytes are rarely what you want. new allocates typed storage, computing size and alignment from the type (sizeof T, alignof T — both are ordinary prefix operators you can use too):

vyi
import { Allocator, FixedBufferAllocator } from "@mem"
import { stdout } from "@io"

fn main() !u8 {
    mut buf = [64]u8[]                          // 64 bytes on the stack
    mut fba = FixedBufferAllocator.new([*]u8(&buf))
    con alloc: Allocator = &fba                 // boxed as the interface

    con nums = alloc.new(i32, 4) catch { panic "out of memory" }
    nums[0] = 42                                // nums is a [*]i32 of 4 elements
    stdout.writeInt(i64(nums[0]))
    stdout.write("
")
    return Ok(0)
}

new(T, count) returns a [*]T of count elements; the single-element form new(T) returns a *mut T. Both propagate OutOfMemory. When OOM is genuinely unrecoverable, say so at the call site — catch { panic "out of memory" } — rather than pretending it can't happen. delete(buf) is the typed counterpart that returns a new(T, count) buffer to its allocator.

That example also shows the pattern that makes allocators composable: Allocator is an ordinary interface (guide 12). Code written against it works with every allocator — including ones you build yourself: implement alloc and free, and new / delete come for free.

Choosing an allocator

  • systemAllocator — the platform heap. Individual free, general-purpose.
  • FixedBufferAllocator — an allocator over a buffer you provide, as above — often a stack array. Zero heap involvement; when the buffer is full, it's full.
  • ArenaAllocator — a bump allocator carved out of a parent. Allocation is a pointer increment; free is a no-op; reset() reclaims everything at once. The right default for phase-shaped work: build, use, throw away wholesale.
vyi
import { ArenaAllocator, systemAllocator } from "@mem"
import { stdout } from "@io"

fn main() !u8 {
    mut arena = ArenaAllocator.new(systemAllocator)
    con a = arena.new(u8, 32) catch { panic "out of memory" }
    arena.reset()                    // everything from the arena, gone at once
    con b = arena.new(u8, 32) catch { panic "out of memory" }
    stdout.writeInt(i64(i32(b.ptr) - i32(a.ptr)))
    stdout.write("
")
    return Ok(0)   // 0 — the chunk is reused from the start
}

After a reset(), pointers handed out before it are dead — the compiler doesn't track that for you; it's the arena's contract with its user.

Stability and escape

The compiler checks that no pointer outlives what it points at. A pointer into the current stack frame is frame-bound; returning it would dangle, and that's a compile error:

vyi-error
fn dangling() *mut i32 {
    mut local = 42
    return &local     // error: frame-bound pointer escapes via return
}

What may escape a function is a stable pointer — one whose storage outlives the frame:

  • pointers obtained from an allocator (alloc / new),
  • pointers to globals and to known values (a compile-time-computed value you point at is baked into the program's data — a string literal's bytes are the everyday example),
  • pointers you received as parameters — from the body's perspective the caller's storage outlives the call.

Stability propagates through what you build: a struct whose pointer fields are all stable is itself stable and may be returned. (Authors of allocators — the one place raw memory genuinely enters the language — vouch for their storage with @intrinsics.markStablePtr; ordinary code never needs it.)

The promise, precisely: no null and no escaping frame pointers — and liveness is still yours to manage. Use-after-free, or using an arena pointer after reset(), is not caught today; the allocator's contract tells you when storage dies.

See also: reference/memory-model, reference/stdlib/mem.