Memory & Regions
SafeC uses a region-based memory model that enforces lifetime safety at compile time. Every reference carries a region qualifier that tells the compiler where the data lives and how long it will be valid. Regions are compile-time only -- they produce no runtime metadata and erase to raw pointers in the generated code.
The Four Regions
| Region | Lifetime | Allocation | Deallocation |
|---|---|---|---|
stack |
Lexical scope | Automatic (stack frame) | Automatic (scope exit) |
static |
Program lifetime | Static storage | Never |
heap |
Dynamic | Explicit (malloc) |
Explicit (free) |
arena<R> |
User-defined | Region allocator | Bulk reset |
Region-Qualified References
Every reference in SafeC must be region-qualified. This tells the compiler exactly where the pointed-to data lives:
&stack int x_ref; // points to stack-allocated int
&heap float buf_ref; // points to heap-allocated float
&static Config cfg_ref; // points to static storage
&arena<AudioPool> Frame f; // points to arena-allocated Frame
All references are non-null by default. Use the optional prefix for nullable references:
?&stack Node next; // may be null
?&heap Buffer cache; // may be null
Stack Region
The stack region is the default for local variables. Stack references cannot escape the scope in which they were created.
void example() {
int x = 42;
&stack int ref = &x; // OK: ref lives in same scope as x
// return ref; // ERROR: stack ref would escape scope
}
The compiler performs escape analysis to ensure stack references never outlive their referent:
&stack int bad() {
int local = 10;
return &local; // ERROR: returning reference to local
}
Static Region
The static region is for data that lives for the entire program duration:
static int counter = 0;
&static int get_counter() {
return &counter; // OK: counter lives forever
}
Static references are the most permissive -- they can be stored anywhere because they never become invalid.
Heap Region
The heap region is for dynamically allocated data. Heap references require explicit allocation and deallocation:
void example() {
&heap int p = new int;
*p = 42;
std::dealloc(p);
}
The compiler tracks heap references but does not automatically free them. Use defer for deterministic cleanup:
void process() {
&heap char buf = new char[4096];
defer std::dealloc(buf);
// ... use buf ...
} // buf freed here via defer
Cast to the plain pointer type, not (heap T*)
(heap int*)malloc(...) doesn't parse — a region qualifier isn't valid
inside a cast's parentheses. Cast to the ordinary pointer type ((int*));
the result converts implicitly to the &heap reference on assignment, as
shown above. Also note the UL suffix on malloc(4096UL) — malloc takes
an unsigned long (size_t), and there's no implicit int →
unsigned long conversion (see Types).
<std/mem.h>'s malloc_defer(n) sugars the alloc-then-defer dealloc pattern
above into a single call. malloc_defer isn't a real function — it's
parser-level sugar recognized only as a var-decl initializer, and it
desugars into std::alloc(n) (the generic allocator, which takes T from
the declared type) for the declaration plus a synthesized
defer std::dealloc(buf); right after it. Declare the variable as a
nullable heap reference to get a typed, null-checked view (with auto there
is no type to take T from, so it is the type-erased
std::alloc_raw_(n) / std::dealloc_raw_ pair and the variable is a byte
pointer, unsigned char*):
#include <std/mem.h>
void process2() {
?&heap int buf = malloc_defer(4096UL); // freed automatically at scope exit
match (buf) {
case some(p): { *p = 1; } // ... use p ...
case null: { } // out of memory
}
} // std::dealloc(buf) runs here via the synthesized defer
A deferred free runs when the scope exits, so it doesn't count as freeing
the reference for the statements after it — only an explicit dealloc
that has already run does (see below).
Compile-Time Use-After-Free and Double-Free Checking
A &heap T reference freed with std::dealloc(p) (or an allocator's
.dealloc(p) method) is tracked by the same dataflow analysis as arena
references (see Arena References Die on Reset):
reading it after that point is a compile error, and freeing it again is a
double free, also caught at compile time:
#include <std/mem.h>
void f() {
&heap int p = new int;
*p = 42;
std::dealloc(p);
*p = 43; // ERROR: use of 'p' (&heap reference) after
// std::dealloc() freed it
std::dealloc(p); // ERROR: same check -- this is the double-free
}
Rebinding recovers p, same as arena references:
&heap int p = new int;
std::dealloc(p);
p = new int; // OK: fresh allocation, fresh binding
*p = 42; // OK
std::dealloc(p);
The analysis tracks each pointer, not each variable name. A free reaches every copy of the pointer (through any number of assignments), every struct field and array element it was stored in, and, through per-function summaries, the caller's copies when a callee frees its parameter or a field of it. When the callee itself is fine but what the caller passes it was already freed, the error is reported at the call:
#include <std/mem.h>
struct Box { &heap int v; };
void release(&heap int p) { std::dealloc(p); }
void release_box(&Box b) { std::dealloc(b.v); }
int peek(&Box b) { return *b.v; }
void g() {
&heap int p = new int;
&heap int q = p; // any number of copies...
struct Box b;
b.v = q; // ...and fields or array elements
release(b.v); // release() frees its parameter
*p = 1; // ERROR: use of 'p' (&heap reference) after
// std::dealloc() freed it
int n = peek(&b); // ERROR: call to 'peek' reads a &heap reference
// after std::dealloc() freed it
release_box(&b); // ERROR: call to 'release_box' frees memory
// after std::dealloc() freed it
}
Trusted, not checked
Within its model the check is sound; the full statement is under Temporal Safety. Outside it:
- A raw libc
free(p), any other function with no body (externdeclarations, C imports), calls through function pointers orfnvalues, and the function aspawnstarts are assumed to free nothing and keep no pointer to their arguments. A pointer freed that way isn't caught at compile time; it relies on the runtime double-free/ invalid-free protection described in Safety. - Memory reached through a raw pointer, or pointer indexing in
unsafe {}(the buffers ofstd::Vecand the other std containers), isn't tracked; a pointer read back from there counts as live. - Distinct parameters (and globals) of one function are assumed not to point to the same allocation.
Two shapes that are actually fine are still reported: a free and a use
guarded by complementary conditions (if (owned) / if (!owned)), and a
loop that frees a linked structure node by node through safe references
(write it recursively, or walk it with raw pointers in unsafe {}).
unsafe {} suppresses the reports inside it, same as every other region
rule; its frees still count for the code after it.
Leak Checking: Owning Types
The checks above cover references: they catch a use or a free after a free. They don't require the memory to be freed at all — a bare &heap T that is never deallocated is not reported. Leaks are caught for owning types instead: a struct with a drop() method (like std::Vec, std::String or a Buf that frees its buffer in drop()) must be dropped or moved on every path, so the allocation it owns can't be forgotten:
struct Buf { &heap char data; void drop(); };
void Buf::drop() { std::dealloc(self.data); }
void f(int c) {
struct Buf b = Buf::new(4096UL);
if (c) return; // ERROR: value of Drop type 'Buf' leaks when the function returns
b.drop();
}
Wrap allocations that outlive a scope in such a type. See Ownership & Drop.
Arena Regions
Arenas provide bulk allocation and deallocation. You declare a region, allocate into it with new<R>, and reset all allocations at once with arena_reset<R>().
Declaring a Region
region AudioPool {
capacity: 4096
}
Allocating in an Arena
&arena<AudioPool> Sample s = new<AudioPool> Sample;
The new<R> T expression performs a bump-pointer allocation from the arena. It is fast (just an offset increment plus a capacity check). An allocation that doesn't fit in the region's remaining capacity aborts the program, the same way an out-of-bounds index does — it never hands out memory past the end of the arena.
Resetting an Arena
arena_reset<AudioPool>();
This resets the arena's offset to zero, effectively freeing all allocations at once. No destructors are called -- arena-allocated objects must not hold external resources.
Destroying an Arena
arena_destroy<R>() goes a step further than arena_reset<R>(): it frees
the region's malloc'd backing buffer entirely rather than just rewinding the
bump offset.
arena_destroy<AudioPool>();
A second call is a safe no-op (the buffer pointer is nulled after the first
call), and a subsequent new<AudioPool> T transparently re-mallocs a
fresh backing buffer. Use arena_destroy<R>() when the arena won't be
reused for a while and you want to release its memory back to the system;
use arena_reset<R>() when you'll keep allocating from it again soon and
want to avoid the re-malloc cost.
Partially Freeing an Arena
arena_reset<R>() always rewinds all the way to zero. arena_mark<R>() /
arena_free_to<R>() are a checkpoint/rewind pair for freeing only the
tail of an arena's allocations — a scratch-space pattern where you take a
checkpoint, allocate some short-lived working data, then rewind past just
that data:
region AudioPool { capacity: 4096 }
void process() {
for (int i = 0; i < 10; i = i + 1) {
unsigned long mark = arena_mark<AudioPool>(); // checkpoint
&arena<AudioPool> struct Sample scratch = new<AudioPool> struct Sample;
// ... use scratch for this iteration only ...
arena_free_to<AudioPool>(mark); // free just 'scratch'
}
}
arena_mark<R>() returns the region's current byte offset (an
unsigned long) as an opaque checkpoint value. arena_free_to<R>(mark)
rewinds the offset back to it at runtime, clamped so a stale or otherwise-
too-large mark can only shrink used, never grow it — verified: an
allocation made before the checkpoint keeps its original value after a
later allocation is freed back to that checkpoint and something else
reuses the space.
arena_free_to<R>() only invalidates references allocated after the
checkpoint it's rewinding to — a reference bound before any
arena_mark<R>() call was ever made for R survives a arena_free_to<R>()
call untouched, since the memory it points into is outside the scratch
range being freed:
&arena<AudioPool> struct Sample keep = new<AudioPool> struct Sample;
unsigned long mark = arena_mark<AudioPool>();
&arena<AudioPool> struct Sample scratch = new<AudioPool> struct Sample;
arena_free_to<AudioPool>(mark);
keep->v = 5; // OK: 'keep' was bound before any mark() — never staled by free_to()
scratch->v = 5; // ERROR: 'scratch' was bound after the mark — correctly caught
Every arena_mark<R>() call is tracked as its own checkpoint, so nested
marks are precise: rewinding to an inner mark leaves references
allocated between the outer and inner marks valid.
region Pool { capacity: 4096 }
void nested() {
unsigned long outer = arena_mark<Pool>();
&arena<Pool> struct Sample a = new<Pool> struct Sample;
unsigned long inner = arena_mark<Pool>();
&arena<Pool> struct Sample b = new<Pool> struct Sample;
arena_free_to<Pool>(inner);
a->v = 1; // OK: 'a' was allocated before 'inner'
b->v = 1; // ERROR: use of 'b' (&arena<Pool> reference) after arena_reset<Pool>(),
// arena_destroy<Pool>(), or arena_free_to<Pool>() invalidated it
arena_free_to<Pool>(outer);
a->v = 2; // ERROR: same message, for 'a'
}
Only a mark the checker can't identify (one passed in by the caller that
it can't resolve, or a value that may be one of several marks) makes
arena_free_to<R>() invalidate more than that, conservatively. A callee
that rewinds to a mark it was passed is applied at the call site.
Inside a loop, a mark/free_to pair is fine (each iteration's free_to
rewinds only that iteration's allocations, as in the scratch example
above), and an arena_free_to<R>() that invalidates a reference used
earlier in the same body is caught on the next iteration:
region Pool { capacity: 4096 }
void process() {
unsigned long mark = arena_mark<Pool>();
&arena<Pool> struct Sample keep = new<Pool> struct Sample;
for (int i = 0; i < 3; i = i + 1) {
keep->v = 1; // ERROR: could be invalidated by the
// free_to below on a prior iteration
arena_free_to<Pool>(mark); // unpaired -- no mark() in this loop body
}
}
unsafe {} bypasses the check, same as every other region rule.
Arena Runtime Representation
At the LLVM level, each arena is a global struct {ptr, i64 used, i64 cap}:
ptr-- base pointer to the arena's backing memoryused-- current bump offsetcap-- total capacity in bytes
new<R> T (or new<R> T[n]) checks that sizeof(T) (times n) fits in cap - used, aborting if it doesn't, then increments used and returns ptr + old_used. arena_reset<R>() sets used back to 0.
Arrays by Region
How big an array can be decided depends on where it lives:
| Region | Declaration | Size |
|---|---|---|
| stack | int buf[8]; |
compile-time constant (a runtime size is only allowed inside unsafe) |
| static | static int table[16]; |
compile-time constant |
| heap | &heap int h = new int[n]; |
any runtime n; free with std::dealloc(h) |
| arena | &arena<R> int a = new<R> int[n]; |
any runtime n, but it must fit in R's remaining capacity — otherwise the program aborts |
new returns a reference to the first element. A reference carries no length and can't be indexed, so pass and walk arrays as a bounds-checked Slice<T> (std::Slice::of(first, n)) or a []T slice. Arrays of references work the same way — &T items[N] on the stack, Slice<&T> across function boundaries — which is what replaces C's T** "array of pointers":
#include <std/collections/slice.h>
struct Sample { int v; };
region Frame { capacity: 4096 }
static int g_table[16]; // static: size fixed at compile time
void fill(struct Slice<int> s, int v) {
for (unsigned long i = 0UL; i < s.length(); i = i + 1UL) s.set(i, v);
}
int arrays(unsigned long n) {
int local[8]; // stack: size fixed at compile time
// int bad[n]; // error: 'n' is not a compile-time constant
&heap int h = new int[n]; // heap: runtime size
&arena<Frame> int a = new<Frame> int[n]; // arena: runtime size, within Frame's 4096 bytes
fill(std::Slice::of(&local[0], 8UL), 1); // view each one through a bounds-checked Slice
fill(std::Slice::of(h, n), 2);
fill(std::Slice::of(a, n), 3);
int t = local[7] + *std::Slice::of(h, n).at(n - 1UL) + *std::Slice::of(a, n).at(0UL) + g_table[0];
std::dealloc(h);
arena_reset<Frame>();
return t; // 1 + 2 + 3 + 0
}
// Arrays of references follow the same rules.
int count_live(struct Slice<&Sample> items) { return (int)items.length(); }
int main() {
struct Sample a; a.v = 1;
struct Sample b; b.v = 2;
&Sample fixed[2] = { &a, &b }; // stack array of references
int c = count_live(std::Slice::of(&fixed[0], 2UL));
return arrays(4UL) == 6 && c == 2 ? 0 : 1;
}
Run in PlaygroundReferences to References
A reference can refer to another reference — the replacement for C's T**. Pick the form by what each level may hold:
| Type | Outer | Inner | Typical use |
|---|---|---|---|
&&T |
always present | always present | out-parameter that always receives a reference |
&?&T |
always present | may be null | out-parameter whose result may be "none" (a tokenizer's position, a lookup) |
?&&T |
optional | always present | optional out-parameter (strtol's end) |
?&?&T |
optional | may be null | optional out-parameter whose result may be "none" |
static int g_value = 7;
void always(&&int out) { *out = &g_value; } // writes a non-null reference
void maybe(&?&int out) { *out = null; } // may write null
void optional(?&&int out) {
match (out) {
case some(o): { *o = &g_value; }
case null: { } // caller didn't ask
}
}
&&T and & &T mean the same thing. An array of references is not a reference to a reference — wrap it in an array or Slice<&T> as above.
Region Rules
The compiler enforces several rules to guarantee memory safety:
1. References Cannot Outlive Their Region
&stack int ref;
{
int x = 10;
ref = &x; // ERROR: x's scope is shorter than ref's
}
// ref would be dangling here
2. References Cannot Escape to a Longer-Lived Region
A reference to a short-lived region cannot be stored in a longer-lived location:
static &stack int global_ref; // ERROR: stack ref stored in static
void bad() {
int local = 5;
global_ref = &local; // ERROR: stack ref escaping to static
}
3. No Implicit Cross-Region Conversion
References from different regions are not interchangeable:
void takes_heap(&heap int p);
void example() {
int x = 42;
takes_heap(&x); // ERROR: &stack int is not &heap int
}
4. Arena References Die on Reset
arena_reset<R>() and arena_destroy<R>() invalidate every outstanding
reference into region R, since the memory they point into may be handed
out again by a later new<R>. After a reset or destroy, every reference
into R (and every copy of one, in a variable, field or array element)
may be dangling, and reading it is a compile error.
arena_free_to<R>() is narrower — see
Partially Freeing an Arena — and only
invalidates references allocated after the checkpoint being freed to.
region Pool { capacity: 1024 }
int main() {
&arena<Pool> int p = new<Pool> int;
arena_reset<Pool>();
*p = 42; // ERROR: use of 'p' (&arena<Pool> reference) after
// arena_reset<Pool>(), arena_destroy<Pool>(), or
// arena_free_to<Pool>() invalidated it
return 0;
}
Run in PlaygroundThis is a dataflow analysis that follows the function's control flow, not its text order — two cases worth knowing:
- if/else branches don't bleed into each other. A reset inside one
branch affects the rest of that branch and the code after the
if-statement (where that branch may have run) — not the other
branch:
c
if (cond) { arena_reset<Pool>(); } else { *p = 2; // OK: this branch never resets Pool } *p = 3; // ERROR: 'then' might have run, so this is possibly stale - A reset anywhere in a loop body invalidates the whole body, not just
code after it. Since a later iteration runs the top of the body
again after an earlier iteration's reset, a use that's textually
before the reset call is still flagged:
cEach loop is analyzed until its state stops changing, so a reset reaches the code before it in the body on the next iteration wherever in the body (or inside whatever expression) it appears, and reaches the code after the loop only through the exits that can follow it.
while (cond) { *p = 1; // ERROR: a *previous* iteration may have reset Pool here arena_reset<Pool>(); }
The same holds for switch fallthrough, match, ?:, &&/||,
break/continue/goto/return/try, and defer/errdefer (which
run at each return), and for heap frees as well as arena resets. A
function that resets the region, or rewinds to a mark it was passed,
invalidates the caller's references at the call. The check is sound —
a real use-after-reset or use-after-free_to is never missed — up to the
trust boundaries listed under
Temporal Safety.
Rebinding a stale reference to a fresh allocation recovers it — the check is about reading a specific stale binding, not about the variable name:
&arena<Pool> int p = new<Pool> int;
arena_reset<Pool>();
p = new<Pool> int; // OK: fresh allocation, made after the reset
*p = 42; // OK
Unsafe Escape Hatch
When the region rules are too restrictive, unsafe {} blocks allow bypassing them:
unsafe {
int *raw = (int*)malloc(sizeof(int));
*raw = 42;
free(raw);
}
See Safety for the full unsafe model.
Outliving References: &T / ?&T With No Region
Leaving the region qualifier off entirely — &T (non-nullable) or ?&T
(nullable) — gives a reference with no declared or tracked region at
all. It isn't a fifth entry in the region-escape/lifetime analysis above;
it's the escape hatch from needing one, for two overlapping cases:
- Crossing the
externboundary. A pointer that a C function might retain past the call returning (a registered callback's context, an opaque handle) can't honestly be&stack(dies with the call) or&heap/&static(claims a lifetime guarantee SafeC can't verify across the ABI boundary). - Not committing to a region for its own sake. Most functions that
just read or write through a reference for the call's duration don't
care whether the caller's value is
&stack,&heap,&static, or&arena<R>— picking one specific region as a parameter type only narrows who can call it, for no safety benefit. Reserve a concrete region for when the parameter's own semantics genuinely pin it to one lifetime (e.g. a callee that stores the reference past its own return needs&heap/&static, not&stack).
struct Point { int x; int y; }
// No region committed -- caller may pass &stack, &heap, &static, or &arena<R>.
int sumPoint(&Point p) { return p.x + p.y; }
struct Point local;
local.x = 3; local.y = 4;
&stack Point ref = &local;
sumPoint(ref); // &stack Point -> &Point: implicit, no unsafe, no region check
A reference of any region converts to &T/?&T implicitly (no
unsafe, no cast), and &T/?&T converts back to a raw pointer
implicitly too — because there's no region to check in the first place.
Nullable vs. non-nullable is the ordinary, orthogonal choice here: use
?&T only where the value can genuinely be absent, &T otherwise. See
Safety for the nullable-reference read grammar
(match/is_null()/.default()/!), which applies identically to
?&T.
FFI and Regions
When calling C functions, region information is erased:
&static Tconverts toT*automatically (safe, nounsafeneeded)&T/?&T(no region) converts to/fromT*/void*automatically in both directions (safe, nounsafeneeded) — see "Outliving References" above- Other region refs (
&stack,&heap,&arena<R>) requireunsafe {}to pass to C
extern int printf(const char *fmt, ...);
void example() {
static const char *msg = "hello\n";
printf(msg); // OK: &static → raw pointer is safe
}
static const char* initializers must be local, not global
A file-scope static const char *msg = "hello\n"; fails with "global
initializer for 'msg' is not a compile-time constant expression" — even
though the value genuinely is a string-literal constant. The same
declaration works fine as a local (static-storage) variable inside a
function, as shown above.
See C Interop for details.