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:
| Site | Rule |
|---|---|
mut b = a / b = a | copies 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 v | the caller receives a value of its own |
| shape-compatible initialisation | a wider struct copies into a narrower shape-compatible one, dropping extra fields — a copy, never an alias |
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
| Type | Points at | May be absent? | May write through? |
|---|---|---|---|
*mut T | one T | never | yes |
*con T | one T | never | no |
?*mut T / ?*con T | one T, or nothing | yes — Optional, must be unwrapped | after unwrap, per the pointer type |
[*]T | zero or more Ts, with length | never (zero-length is a value, not absence) | yes, bounds-checked |
c_ptr T | C memory | yes — nullable, FFI only | yes (guide 15) |
The invariants:
- Always valid by construction. A
*mut T/*con Tvalue 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.
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:
| Property | Rule |
|---|---|
| representation | { ptr, len } — one value; the length travels with the pointer, not in the type |
| null | never — a [*]T always views valid storage |
| zero length | a legal, ordinary value (s[..0] works; .len is 0; any index panics) |
| indexing | bounds-checked; s[i] panics outside -len .. len-1; negative indices count from the end |
| slicing | s[lo..hi] produces a [*]T over the same storage — a view, not a copy |
.len | the element count, i32 |
.ptr | the raw data pointer, for FFI and allocator authors |
| element layout | elements 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).
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:
| Cast | Produces | Copies? |
|---|---|---|
[*]T(&arr) | a view over the array's storage; length from the type | no — writes through the view land in the array |
[N]T(view) | a fresh fixed array snapshotting N elements | yes |
*[N]T(view) | a pointer-to-fixed-array over the same storage | no |
[*]T(p) (p: *[N]T) | the view again, length re-attached from the type | no |
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:
| Source | Why stable |
|---|---|
allocator results (alloc, new) | heap storage lives until freed |
file-scope con and mut bindings | globals live for the whole program |
| known values | a compile-time-computed value you point at is reified — baked into the program's data section (string-literal bytes are the everyday case) |
| parameters | from the body's perspective, the caller's storage outlives the call |
| aggregates of stable pointers | stability propagates through what you build |
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:
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:
| Method | Signature | Contract |
|---|---|---|
alloc | fn alloc(size: i32, align: i32) OutOfMemory![*]u8 | exactly size bytes, aligned to at least align; on failure Err(OutOfMemory) — never null, never a valid-looking empty view |
free | fn free(buf: [*]u8) | accepts the same [*]u8 alloc returned; arena-style allocators may no-op |
new | fn (a: Allocator) new(known T: type) OutOfMemory!*mut T | typed single value: sizeof T bytes at alignof T |
new | fn (a: Allocator) new(known T: type, count: i32) OutOfMemory![*]T | typed buffer of count elements |
delete | fn (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:
| Allocator | Backing | free | Reclaim model |
|---|---|---|---|
systemAllocator | the platform heap | individual | general-purpose |
FixedBufferAllocator | a buffer you provide (often a stack array) | individual (bump-style) | full when the buffer is full; reset() |
ArenaAllocator | chunks from a parent allocator | no-op | reset() reclaims everything at once |
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
| Storage | What goes there | Lifetime |
|---|---|---|
| stack frames | locals, parameters, temporaries — including whole aggregates, inline | the enclosing call |
| baked program data | reified known values: string-literal bytes, file-scope con data | the whole program |
| global slots | file-scope mut bindings | the whole program |
| allocator storage | only what you alloc/new | until 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.