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 isc_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 Tor*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).
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:
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:
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:
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):
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 *:
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):
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:
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:
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:
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.
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:
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:
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:
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:
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:
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:
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):
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. Individualfree, 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;freeis a no-op;reset()reclaims everything at once. The right default for phase-shaped work: build, use, throw away wholesale.
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:
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.