Docs · Language

Memory model

Vyi is a by-value language with no garbage collector, no null, and no hidden heap: values copy, pointers are always valid by construction, and memory beyond the current stack frame comes only from an allocator you name. This page states the precise rules — copy semantics, pointer validity, the [*]T representation contract, stability and escape, and the allocator contract. For the tutorial treatment read guide 09 — Pointers and memory.

Value semantics

Assignment, argument passing, and return are by value:

SiteRule
mut b = a / b = acopies the whole value — for aggregates, all fields/elements; the copy is independent
call f(a)the parameter binds the argument as an immutable con; the callee gets its own data only by copying into a mut local
return vthe caller receives a value of its own
shape-compatible initialisationa wider struct copies into a narrower shape-compatible one, dropping extra fields — a copy, never an alias
vyi
import { stdout } from "@io"

struct Point { x: i32, y: i32 }

fn shifted(p: Point) Point {
    mut q = p               // explicit copy: q is the callee's own value
    q.x = q.x + 1
    return q                // returned by value
}

fn main() !u8 {
    con a = Point{ x: 1, y: 2 }
    mut b = a               // copies both fields
    b.y = 50                // a.y is untouched
    con c = shifted(a)      // a is unchanged: parameters bind con
    stdout.writeInt(i64(a.y + b.y + c.x))  // prints 2 + 50 + 2 = 54
    stdout.write("\n")
    return Ok(0)
}

A parameter binding is con — reassigning it is rejected (cannot reassign immutable binding); mutation starts with your own mut copy. Closures follow the same rule: captures are by value, so creating or calling a closure allocates nothing (guide 04).

Sharing — one storage location seen from several places — is always spelled with a pointer type: *mut T, *con T, or the view type [*]T.

Pointer validity

TypePoints atMay be absent?May write through?
*mut Tone Tneveryes
*con Tone Tneverno
?*mut T / ?*con Tone T, or nothingyes — Optional, must be unwrappedafter unwrap, per the pointer type
[*]Tzero or more Ts, with lengthnever (zero-length is a value, not absence)yes, bounds-checked
c_ptr TC memoryyes — nullable, FFI onlyyes (guide 15)

The invariants:

  • Always valid by construction. A *mut T / *con T value cannot be null

and cannot be created dangling — every producing form (&x, allocator calls, parameters) yields a pointer to live storage, and escape checking keeps it from outliving that storage. Absence is ?*T, checked in the type system like any Optional.

  • Write permission rides in the type. There is no bare *T; every pointer

type states mut or con. A *mut T widens to *con T, never the reverse. Writing through a *con is a compile error at any pointer depth.

  • No pointer arithmetic. Strict pointers support == and != only. Walking

memory by offset is what [*]T is for.

  • Validity is not liveness. The compiler rules out null and frame escape; it

does not track allocator lifetimes. Use-after-free, or reading an arena pointer after reset(), is the programmer's contract with the allocator, unchecked today.

vyi-error
fn read(p: *i32) i32 {   // error: write `*mut T` or `*con T` — no bare `*T`
    return *p
}

[*]T — the array-pointer representation contract

A [*]T is a pointer and a length carried together in one value (a pointer-plus-length view over someone else's storage). The contract:

PropertyRule
representation{ ptr, len } — one value; the length travels with the pointer, not in the type
nullnever — a [*]T always views valid storage
zero lengtha legal, ordinary value (s[..0] works; .len is 0; any index panics)
indexingbounds-checked; s[i] panics outside -len .. len-1; negative indices count from the end
slicings[lo..hi] produces a [*]T over the same storage — a view, not a copy
.lenthe element count, i32
.ptrthe raw data pointer, for FFI and allocator authors
element layoutelements are contiguous, packed at a fixed per-type stride

String literals are the everyday producer — a "" literal is a [*]u8 viewing its baked UTF-8 bytes — and allocators are the other (allocation contract).

vyi
import { stdout } from "@io"

fn main() !u8 {
    con s = "hello"
    con empty = s[..0]       // zero-length view: fine
    con tail = s[1..]        // view of the same bytes
    con p = s.ptr            // the raw pointer half
    stdout.writeInt(i64(i32(empty.len) + tail.len + i32(*p) - 108))  // prints 0 + 4 + 104 - 108 = 0
    stdout.write("\n")
    return Ok(0)
}

Fixed arrays: [N]T

[N]T is N elements stored inline, and N is part of the type. A fixed array is a value — assignment copies all elements. Conversions between the fixed and view worlds are explicit casts:

CastProducesCopies?
[*]T(&arr)a view over the array's storage; length from the typeno — writes through the view land in the array
[N]T(view)a fresh fixed array snapshotting N elementsyes
*[N]T(view)a pointer-to-fixed-array over the same storageno
[*]T(p) (p: *[N]T)the view again, length re-attached from the typeno
vyi
import { stdout } from "@io"

fn main() !u8 {
    mut a = [4]u8[1, 2, 3, 4]
    con view = [*]u8(&a)      // same storage
    view[1] = 20              // a[1] is now 20
    con snap = [4]u8(view)    // independent copy
    view[1] = 99              // snap doesn't see this
    stdout.writeInt(i64(i32(a[1]) + i32(snap[1]) - 99))  // prints 99 + 20 - 99 = 20
    stdout.write("\n")
    return Ok(0)
}

Stability and escape

A pointer may leave a function only if its storage outlives the function's frame. The compiler classifies every pointer:

  • Frame-bound — points into the current stack frame (&local). Returning

it, or storing it anywhere that outlives the frame, is rejected: ``frame-bound pointer *mut i32 escapes via return; obtain the pointer from an allocator's alloc method, or use markStablePtr if you're authoring an allocator``.

  • Stable — storage outlives every frame. Stable pointers may be returned,

stored in long-lived structures, and captured by closures.

The stable sources:

SourceWhy stable
allocator results (alloc, new)heap storage lives until freed
file-scope con and mut bindingsglobals live for the whole program
known valuesa compile-time-computed value you point at is reified — baked into the program's data section (string-literal bytes are the everyday case)
parametersfrom the body's perspective, the caller's storage outlives the call
aggregates of stable pointersstability propagates through what you build
vyi
import { stdout } from "@io"

con limit: i32 = 100    // file-scope known value — reified into program data

fn limitPtr() *con i32 {
    return &limit       // stable
}

mut counter = 0         // global: also stable

fn counterPtr() *mut i32 {
    return &counter
}

fn main() !u8 {
    *counterPtr() = 41
    stdout.writeInt(i64(*limitPtr() - 59 + counter - 41))  // prints 41
    stdout.write("\n")
    return Ok(0)
}

A local con is still frame-bound — its address dies with the frame, known initialiser or not:

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

Allocator authors — the one place raw memory enters the language — vouch for their storage with @intrinsics.markStablePtr; ordinary code never calls it.

The allocator contract

All heap memory flows through the Allocator interface from @mem — allocation is a value you pass, not ambient machinery. The API:

MethodSignatureContract
allocfn alloc(size: i32, align: i32) OutOfMemory![*]u8exactly size bytes, aligned to at least align; on failure Err(OutOfMemory) — never null, never a valid-looking empty view
freefn free(buf: [*]u8)accepts the same [*]u8 alloc returned; arena-style allocators may no-op
newfn (a: Allocator) new(known T: type) OutOfMemory!*mut Ttyped single value: sizeof T bytes at alignof T
newfn (a: Allocator) new(known T: type, count: i32) OutOfMemory![*]Ttyped buffer of count elements
deletefn (a: Allocator) delete(known T: type, ptr: [*]T)typed counterpart to array new; T is inferred — write a.delete(buf)

Failure is in the type — OutOfMemory — consumed with try, catch, or switch like any error union (error model). Only alloc and free are dynamic; new and delete are methods on the interface value, so implementing those two gives an allocator the whole API.

The standard library ships three allocators:

AllocatorBackingfreeReclaim model
systemAllocatorthe platform heapindividualgeneral-purpose
FixedBufferAllocatora buffer you provide (often a stack array)individual (bump-style)full when the buffer is full; reset()
ArenaAllocatorchunks from a parent allocatorno-opreset() reclaims everything at once
vyi
import { Allocator, ArenaAllocator, OutOfMemory, systemAllocator } from "@mem"
import { stdout } from "@io"

fn fill(alloc: Allocator, n: i32) OutOfMemory![*]i32 {
    con xs = try alloc.new(i32, n)   // [*]i32 of n elements
    xs[0] = 42
    return Ok(xs)
}

fn main() !u8 {
    mut arena = ArenaAllocator.new(systemAllocator)
    con xs = try fill(&arena, 4)
    con first = xs[0]
    arena.reset()                    // xs is dead from here — the arena's contract
    stdout.writeInt(i64(first))
    stdout.write("\n")
    return Ok(0)
}

What lives where

StorageWhat goes thereLifetime
stack frameslocals, parameters, temporaries — including whole aggregates, inlinethe enclosing call
baked program datareified known values: string-literal bytes, file-scope con datathe whole program
global slotsfile-scope mut bindingsthe whole program
allocator storageonly what you alloc/newuntil free/delete/reset, per the allocator

There is no garbage collector and no implicit allocation: no language construct — closures, interfaces, error returns, enum payloads — reaches for the heap behind your back. Aggregates store their fields and elements inline; () is zero-size. Concrete sizes and alignments are target-dependent — query them with sizeof T and alignof T, which are ordinary prefix operators producing known values.

See also: guide 09 — Pointers and memory · known values · stdlib/mem.