Skip to content
CryoCryo home
LanguageProgram structure

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();