14Pointers and Memory
Cryo is a systems language. There is no garbage collector. Memory is managed manually through explicit allocation and deallocation, or, preferably, through RAII patterns: a type that owns memory implements Drop, and its destructor releases what its constructor acquired.
14.1 Address-of and Dereference
The & operator takes the address of a value. The * operator follows a pointer to its target.
mut x: int = 42;
const ptr: int* = &x; // ptr holds the address of x
const val: int = *ptr; // val is 42
Pointer indexing is supported: ptr[0] is *ptr, and ptr[n] accesses the n-th element from the pointer's base. This holds whatever the pointer points at: p[i] with p: [T]* is the i-th array from p, not an element of the array p points at; write (*p)[i] for that.
14.2 Heap Allocation
Low level (malloc / free) for raw byte buffers and FFI-shaped allocations:
import std::core::intrinsics::{ malloc, free };
const buf: u8* = malloc(1024) as u8*;
buf[0] = 0xFF;
free(buf as void*);
A bare malloc, free or realloc means a declaration in scope, as any bare name does: the module's own intrinsic function, an extern "C" it declares, or an import. With no such declaration in the writing module the bare name is an error, and the fix is to say which one is meant - libc::free for the C allocator, heap::free for the Cryo one. There is no freestanding exception: under no_std the name is declared (extern or intrinsic), so the rule holds there as everywhere.
Heap values (new / delete):
const p: Person* = new Person { name: "Alice", age: 30 };
const o: Option<i64>* = new Option<i64>::Some(41);
delete p;
delete o;
new T { .. } places a struct literal on the heap and yields a T*; new E::V(args) does the same for an enum variant. Parentheses after the name build only a variant: new Point(1, 2) is refused (E0117).
Array allocation (new T[n]):
const arr: int* = new int[100];
new T[n] allocates room for n contiguous T and yields a T*. The memory is uninitialized - it is the typed equivalent of malloc(n * sizeof(T)). Free it with free(arr as void*). For constructed, growable, bounds-checked storage prefer Array<T>.
Where new gets its memory (Allocate). new and delete are sugar over the trait std::core::ops::Allocate, as a[i] is over Index and *p over Deref:
type trait Allocate {
static allocate(size: u64, align: u64) -> void*;
static release(ptr: void*, align: u64) -> void { }
}
new T { ... } of a struct that implements Allocate takes its storage from T::allocate(size, align), with T's size and alignment 16; the literal is then stored into it as for any new. new T[n] of such an element type takes the block from the same allocate. A type with no implementation uses the global allocator, unchanged.
delete p releases *p as a scope exit releases a value of p's pointee type: that type's Drop, if it has one, then the values its fields own, each released the same way. Then delete p hands p to the same implementation's release with the pointee type's alignment, instead of freeing it. release does nothing unless an implementation writes it, which is what an arena wants: its storage is reclaimed whole, never one object at a time. An allocator that does free single objects writes both methods.
mut g_pool: [u8; 4096] = [0; 4096];
mut g_used: u64 = 0;
type struct Node {
tag: i32;
}
implement trait Allocate for struct Node {
static allocate(size: u64, align: u64) -> void* {
g_used = (g_used + align - 1) & ~(align - 1);
const p: void* = (&g_pool[g_used as i64]) as void*;
g_used = g_used + size;
return p;
}
}
const n: Node* = new Node { tag: 2 }; // from g_pool, through Node's implementation
delete n; // Node's release: nothing
The compiler's own AST nodes are allocated this way: each node struct implements Allocate over the arena it reclaims at each target boundary. A generic type's implementation (implement<T> trait Allocate for struct Cell<T>) serves each instance's new and delete. new Cell<i32> { .. } allocates the instance Cell<i32>, and new Cell { .. } written without arguments allocates the instance the literal Cell { .. } would be: the one the expected pointer's pointee names, or else the one its field values bind. An implementation for any other type, an enum or a primitive, is refused (E0312), since new of such a type never calls one. An implementation is checked against the trait as any is (section 10.2): an allocate written with other parameters, another return type or a receiver is refused (E0312), and release may be left out, since its default does nothing.
Higher level (Box<T> and the collections). Prefer Box<T> over raw malloc for owning a single heap value, and prefer Array<T> / String / HashMap<K, V> over manual allocation for collections. They handle reservation, growth, and cleanup, and they implement Drop.
14.3 Null
null is the null pointer literal, valid in any pointer context. The primitive string is pointer-shaped (a NUL-terminated u8*), so it counts as a pointer context: null assigns to, initializes, returns as, and compares with a string without a cast.
import std::fmt::{ println };
const p: int* = null;
if (p == null) { println("null"); }
const s: string = null; // string is pointer-shaped
if (s == null) { println("no name"); }
A function-pointer type (A) -> R is a pointer context too, so an optional
callback slot takes a bare null wherever the type is stated - a binding's
annotation, a return, a struct-literal field (section 17.4):
type struct Vtable {
drop_fn: (void*) -> void;
data: void*;
}
const vt: Vtable = Vtable { drop_fn: null, data: null };
This is the null literal taking the type its context states, not a conversion
between void* and a function pointer. That conversion stays explicit in both
directions, so an arbitrary void* still cannot become callable without an
as.
Dereferencing a null pointer is undefined behaviour. For "may be absent" semantics on a value, use Option<T> rather than a nullable pointer; the compiler then forces you to handle the absent case at every use.
14.4 NonNull
core::ptr::NonNull<T> is a thin wrapper around T* that statically guarantees non-nullness. Containers and smart pointers in the standard library use NonNull<T> internally so they never have to defensively null-check.
import core::ptr::NonNull;
const non_null: NonNull<u8> = NonNull::new(buf).unwrap();
mut name: Type = value;A mutable binding, reassignable after declaration - mutability is opt-in, const is the default. At module level it declares mutable global state. Before & it marks an exclusive reference or receiver instead.
mut x: intconst NAME: Type = value;At module level, a true compile-time constant, conventionally SCREAMING_SNAKE_CASE. (mut at module level declares mutable global state instead.)
const ptr: int*mut x: intconst val: intconst ptr: int*import a::b;
import a::b::{ X, Y };
import a::b as c;
import a::b::*;Brings a module, or selected items from it, into scope. One path per declaration: use the brace form for several items, as to alias, and * for everything public (prefer the brace form; wildcards invite name collisions).
intrinsic function malloc(size: u64) -> void*intrinsic function free(ptr: void*) -> voidconst buf: u8*value as TypeExplicit conversion: between numeric types, or reinterpreting one pointer type as another. Cryo never converts implicitly, and as inserts no range checks - a narrowing cast is the programmer's responsibility.
const buf: u8*const p: Person*new T { field: value }
new E::Variant(args)
new T[n]Heap allocation. new T { .. } places a struct literal on the heap and yields a T*, and new E::V(args) does the same for an enum variant; new T[n] reserves n uninitialised T - the typed malloc. The storage comes from T's Allocate implementation, or else the global allocator. Parentheses after a struct's name are refused (E0117): there are no constructors, and a struct's static new(..) is an ordinary method.
const o: Option<i64>*Option::Some(T)delete p;Releases what new T { .. } allocated: the pointee's Drop and the values its fields own, then its storage, through T's Allocate::release. Memory from new T[n] or malloc is released with free instead.
const p: Person*const o: Option<i64>*const arr: int*type struct Name { ... }
type enum Name { ... }
type trait Name { ... }
type union Name { ... }Begins a type declaration; the keyword that follows says which kind. On its own, type Name = Existing; declares an alias.
type trait Name : Base {
method(&this, other: &This) -> Ret;
}Names a set of methods a type may implement. Inside the body This is the implementing type; methods may carry default bodies, and a trait may require a base trait (type trait Ord : Eq). Generic code asks for a trait with where T: Name.
type trait Allocatestatic name(param: Type) -> Ret { ... }A method that belongs to the type rather than an instance, called as Type::name(..). For structs, static new(..) returning a struct literal is the idiomatic constructor.
static Allocate::allocate(size: u64, align: u64) -> void*(parameter) size: u64(parameter) align: u64static Allocate::release(ptr: void*, align: u64) -> void(parameter) ptr: void*mut g_pool: [u8; 4096]mut g_used: u64type struct Name {
field: Type;
method(&this) -> Ret { ... }
}A value type with named fields and optional methods. Structs live on the stack and are passed by value. Fields are public by default and may carry = default values; methods take their receiver as &this, mut &this, or this.
type struct NodeNode.tag: i32implement struct Name { ... }
implement trait Trait for Type { ... }
implement u64 { ... }Adds methods to a type outside its declaration. implement struct T (or enum, or a bare primitive) opens an inherent block; implement trait Tr for T supplies a trait's methods for one type. Resolution is at compile time - no dynamic dispatch.
implement trait Trait for Type { ... }Introduces a trait implementation: the block supplies, for one concrete type, the method bodies the trait requires.
language referencetype trait Allocateimplement trait Trait for Type { ... }Names the type a trait implementation is for. The block that follows supplies the bodies the trait requires.
language referencetype struct Nodestatic Node::allocate(size: u64, align: u64) -> void*mut g_used: u64(parameter) align: u64const name: Type = value;An immutable binding: it cannot be reassigned after initialisation. The annotation may be omitted when an initialiser is present - the binding takes the initialiser's concrete type, inferred locally. Mutability is opt-in through mut.
const p: void*mut g_pool: [u8; 4096](parameter) size: u64return value;Leaves the current function with the given value. A function whose return type is not void must return one; a bare return; is for void. In an async function it completes the future with the value.
const p: void*const n: Node*const n: Node*const p: int*nullThe null pointer literal, valid in any pointer context - including the primitive string, which is a NUL-terminated u8*. Compare with == null; it is not an Option.
if (condition) { ... } else if (condition) { ... } else { ... }Branches on a parenthesised boolean condition. Bodies are always braced; there is no single-statement form. Used as an expression, both arms are required and must produce the same type: const sign = if (n < 0) { -1 } else { 1 };.
const p: int*const s: stringprimitive stringNUL-terminated raw string (u8*), matching the C ABI. Length-typed text is Str / String.
const s: stringtype struct VtableVtable.drop_fn: (void*) -> voidVtable.data: void*const vt: Vtabletype struct VtableVtable.drop_fn: (void*) -> voidVtable.data: void*namespace core;namespace core::ptr;type struct NonNull<T> {
ptr: T*;
}const non_null: NonNull<u8>static NonNull::new(raw: T*) -> NonNull<T>Construct from a raw pointer, aborting if it's null. Check once at the ownership boundary so downstream code can rely on the invariant.
std::core::ptrconst buf: u8*const ptr: int*const n: Node*const p: int*intrinsic function realloc(ptr: void*, new_size: u64) -> void*intrinsic function name(param: Type) -> Ret;Declares a function the compiler lowers directly to LLVM IR rather than calling. Intrinsics are the contract between the standard library and the compiler (malloc, memcpy, printf, ...); user code does not normally declare its own.
function name<T>(param: Type) -> Ret { ... }Declares a function. Parameters are written name: Type and are never inferred; the return type follows -> and defaults to void. Declaration order does not matter: every signature is collected before any body is checked, so functions may call each other freely and recurse.
extern function exit(code: i32) -> void;
extern "C" {
function puts(s: string) -> i32;
}Declares a function whose body the linker supplies - typically a C library symbol. The compiler trusts the Cryo signature; keeping it in step with the real C signature is on you.
language referencestart..endA half-open range - start up to but excluding end - and sugar for Range::new(start, end): an iterator usable in for, and an ordinary value anywhere else. It binds looser than arithmetic, so a..b + 1 is a..(b + 1).
function args() -> Array<String>Program arguments as UTF-8 strings. The first element is the program name. Returns an empty array if the runtime hasn't published argv yet. Panics on allocation failure.
std::envsizeof(T)The size of a type in bytes, as a compile-time constant - usable in static_assert. alignof(T) gives the alignment.
const arr: int*type struct Array<T, A = GlobalAlloc> where A: Allocator {
ptr: T*;
length: i64;
capacity: i64;
alloc: A;
}Array<T, A>: growable, heap-backed, contiguous sequence.
std::collections::arraytype trait Allocate {
static allocate(size: u64, align: u64) -> void*;
static release(ptr: void*, align: u64) -> void;
}Allocate is where new gets its memory and delete returns it. new T { .. } of a struct that implements it calls T::allocate(size, align) for the storage, then stores the literal as for any other new; delete p runs the pointee's Drop, then hands the storage to release(p, align).
type trait Index<Idx, Output> {
index(&this, idx: Idx) -> Output*;
}Index<Idx, Output> is the contract for container[idx]-style access, returning a pointer to the element. The compiler rewrites a[i] into *(a.index(i)); because the deref of the returned Output* is a place, the one impl serves reads (v = a[i]), writes (a[i] = v), and compound assignment (a[i] += v).
type trait Deref<Target> {
deref(&this) -> Target*;
}Deref<Target> exposes the value a smart pointer points at. The returned Target* is mutable (Cryo pointers are not const-qualified), so this one trait covers both shared and mutable access - there is no separate DerefMut.
function name(count: i32, args...)A variadic parameter: the trailing bucket receives any number of further arguments with C's calling convention, so the function stays ABI-compatible with the printf family. Wrap the bucket in VaArgs to read typed values from it.
type parameter Ttype struct Box<T, A = GlobalAlloc> where A: Allocator {
ptr: T*;
alloc: A;
}Box<T, A>: single-owner heap allocation.
std::alloc::boxtype struct String<A = GlobalAlloc> {
buffer: RawBuffer<u8, A>;
length: u64;
}String: owned, heap-allocated, length-typed UTF-8.
std::collections::stringtype struct HashMap<K, V, A = GlobalAlloc> where A: Allocator {
buckets: Entry<K, V>**;
bucket_count: u64;
length: u64;
alloc: A;
seed: u64;
}HashMap<K, V, A>: key-value map with amortized O(1) operations.
std::collections::hashmap