Memory Model Research

A comparison of memory management techniques under consideration for Conjure, with code samples and tradeoff analysis.

Conjure aims to give game and engine developers a language with (ideally) safer memory handling than C, more predictable performance than a language relying on GC or ref-counting, and less ceremony than Rust. There are several ways to get there. This page walks through each option being considered, in order of increasing automation, with code samples and tradeoffs.

A quick reminder of Conjure’s core principles:

  • Optimal and predictable performance at runtime.
  • Extremely fast compile times and great language server responsiveness.
  • Conjure encourages arena/pool/ECS style programming as convention, but understands this pattern doesn’t fit every use case.

Conjure’s target audience skill set is not necessarily systems programming experts. The language should be approachable for users with a C#/Unity background, and (ideally) shouldn’t require deep expertise in memory management to write safe code. At the same time, Conjure is not trying to be a safe-by-default language for general application development; it’s designed for game and engine developers who are often working in performance-critical code and may need to drop down to manual memory management for certain hot paths.

The same example problem is used throughout: a small game scene with a Player that owns its name (heap string) and equips a Weapon, a Weapon that points back to its wielder, an Inventory containing a dynamic array of items, and a World that holds all active players. Each technique handles the allocations, the back-pointer between Player and Weapon, the dynamic array growth, and the cascading cleanup differently.

1. Manual management with defer free()

The C, Zig, and Odin approach. Every allocation has a matching free(). The defer keyword schedules cleanup at scope exit so it isn’t forgotten across early returns. The compiler does nothing automatic; the programmer is responsible for every pointer and every cleanup call.

The differentiator of Conjure compared to C (beyond defer) is that alloc and free are compiler intrinsics, so the compiler is free to elide heap allocations when escape analysis proves a value doesn’t outlive its scope. Additionally, debug builds include runtime checks and memory tracing to help catch leaks and use-after-free, but these are designed for development purposes and are stripped out of release builds for zero overhead.

Types can declare custom cleanup logic via an overload free hook (for cascading cleanup), but the user must still call free() manually to trigger it. The defer <cleanup> pattern is also carried through mutex locks, file handles, and other resources that need deterministic cleanup.

The deeper challenge of this model becomes visible in code that touches multiple pieces of data with intertwined lifetimes. When a Player owns a Weapon that points back at it, and a World owns a list of Player pointers, the programmer is responsible for ordering teardown correctly so that nothing accesses freed memory and nothing is freed twice. The compiler offers no help; the LSP can warn about obvious patterns, but anything involving function boundaries or stored pointers is on the programmer.

Conjure conjure
struct Player {
  name    string
  health  i32
  weapon  ?*Weapon
}

struct Weapon {
  damage  i32
  wielder ?*Player
}

struct Inventory {
  items []string
}

struct World {
  players []*Player
}

overload free(p &Player) {
  free(p.name)
  if w = p.weapon {
      // weapon's wielder points back at p;
      // null it before freeing so any
      // later access via that pointer
      // is a clean null check.
      w.wielder = null
      free(w)
  }
}

overload free(inv &Inventory) {
  for item in inv.items { free(item) }
  free(inv.items)
}

func main() {
  var hero = alloc(Player{
      name = "Hero",
      health = 100,
      weapon = null,
  })!
  defer free(hero)

  var sword = alloc(Weapon{
      damage = 50,
      wielder = hero,
  })!
  hero.weapon = sword
  // ownership of sword now belongs to
  // hero; no defer for sword since
  // hero's overload free releases it.

  var inv = Inventory{ items = []string{} }
  defer free(inv)
  inv.items.append("potion")
  inv.items.append("rope")

  var world = World{ players = []*Player{} }
  defer free(world.players)
  world.players.append(hero)
  // world.players holds a non-owning
  // pointer to hero. If world outlived
  // hero, that would be a dangling
  // pointer.

  println("${hero.name}: ${hero.health}")
}
C (equivalent) c
typedef struct Weapon Weapon;
typedef struct Player Player;

struct Player {
  cjrString name;
  int32_t   health;
  Weapon*   weapon;  /* may be NULL */
};

struct Weapon {
  int32_t damage;
  Player* wielder;   /* may be NULL */
};

typedef struct {
  cjrArray(cjrString) items;
} Inventory;

typedef struct {
  cjrArray(Player*) players;
} World;

static void Player_free(Player* p) {
  cjr_str_free(&p->name);
  if (p->weapon) {
      p->weapon->wielder = NULL;
      free(p->weapon);
  }
  free(p);
}

static void Inventory_free(Inventory* inv) {
  for (size_t i = 0; i < inv->items.len; i++)
      cjr_str_free(&inv->items.ptr[i]);
  cjr_arr_free(&inv->items);
}

int main(void) {
  Player* hero = malloc(sizeof(*hero));
  hero->name   = cjr_str_lit("Hero");
  hero->health = 100;
  hero->weapon = NULL;

  Weapon* sword  = malloc(sizeof(*sword));
  sword->damage  = 50;
  sword->wielder = hero;
  hero->weapon   = sword;

  Inventory inv = {0};
  cjr_arr_append(&inv.items, cjr_str_lit("potion"));
  cjr_arr_append(&inv.items, cjr_str_lit("rope"));

  World world = {0};
  cjr_arr_append(&world.players, hero);
  /* world holds a non-owning ptr to hero */

  printf("%s: %d
",
      cjr_str_cstr(&hero->name),
      hero->health);

  /* teardown order matters; literals
     skip the actual free path via tag */
  cjr_arr_free(&world.players);
  Inventory_free(&inv);
  Player_free(hero);
  return 0;
}
  • Predictable: every allocation and free is visible in source.
  • Zero runtime overhead beyond the allocator itself.
  • Compiler stays simple and fast, LSP latency is minimal.
  • Maps directly to C; FFI is essentially free.
  • Easy to integrate AddressSanitizer, Valgrind, or other native debugging tools (they work as designed for C code).
  • Stable allocation locations are friendly to debuggers: a pointer captured at one moment is the same pointer later (no compiler-inserted refcount ops or generation bumps).
  • Use-after-free is silent undefined behavior with no detection at all.
  • Forgetting a free() causes leaks the compiler can’t catch.
  • Defer pattern is error-prone with conditional ownership transfers.
  • Cascading ownership (Player owns Weapon owns…) must be hand-coded in overload free.
  • Distinguishing owning from non-owning pointers is convention, not enforced.

2. Auto-free at scope exit

The compiler tracks which bindings hold heap-owned resources and inserts a free call at every scope exit automatically. The user never manually triggers the free/free in normal code; cleanup happens for them (similar to RAII in C++). An overload free hook lets types declare custom cleanup logic (closing file handles, releasing locks, notifying observers).

Free functions are automatically generated for types with heap-owned fields, so the user doesn’t have to write them manually. The compiler walks the type recursively and emits the right teardown for each field: nested structs get their own frees called, dynamic arrays release each element and then the backing buffer, strings free their data if they’re heap-owned, and so on. Complex nested structures clean up correctly without recursive cleanup code in user-space.

For this approach, we’ll replace alloc() with make() as it reads a bit better for constructing pointers to resources. Arena and custom-allocator paths use the longer make(T{...}, allocator) form. The result is always an owned pointer (*T), and the allocation’s lifetime is governed by the rules described in the rest of this section.

The model has two defining properties: everything owns by default, and the user never sees moves. The first means pointer-typed fields and local bindings holding owned data are freed automatically at scope exit. No annotation is needed to opt in, and back-pointers, pool views, and other non-owning references must opt out explicitly. The second means assigning, passing, returning, or storing a binding never invalidates the source from the user’s perspective. Bindings remain readable and writable after being shared. This is the inverse of section 3, where each of those operations transitions the source to a Moved state and prevents further use.

The “no visible moves” property is what makes this model approachable: the user can write var alias = sword and keep using both sword and alias afterward, mutate either, pass either to a function, the way they would in C# or Go. Internally the compiler is doing flow-sensitive bookkeeping to make sure exactly one binding releases each allocation, but that bookkeeping never surfaces as a compile error in the common case.

Owning vs borrowing in the type system

The owns-by-default rule is the right answer for the common case (tree-shaped ownership: parent owns children), but it can’t be universally true. Back-pointers, observer lists, and pool views all hold references to memory the field’s owner doesn’t actually own. Conjure handles this at the type level with the &T reference sigil, since it carries “borrowed, non-owning” semantics. Using it in struct fields or container element positions tells the compiler not to free through it.

&T in a struct field means the field points at something the field’s owner doesn’t own. The compiler won’t generate a free call for it. Nullable borrows are written ?&T. The same logic carries through containers: []&Player is an owned dynamic array whose backing buffer frees normally but whose &Player elements are not freed. A Map[K, &V] type behaves the same way: storage is freed, references are not. The compiler walks the type the way it always does, and the sigil tells it which fields and elements to recurse into and which to skip.

Function parameters and return types use the same convention, which means the sigil is the function’s ownership contract:

  • func equip(w *Weapon) — the function takes ownership; the caller’s free responsibility transfers in.
  • func inspect(w &Weapon) — the function borrows; the caller still owns.
  • func makeSword() *Weapon — the caller receives ownership.
  • func firstWeapon(p &Player) &Weapon — the caller receives a borrow tied to p’s lifetime.

No separate parameter-convention annotations are needed (no @owned / @borrowed annotations). The type sigil already in the language is the convention.

[:]T slices follow the same rule. A slice is a borrowed view into another buffer (a dynamic array, a fixed-size array, or raw memory), and it is not freed at scope exit. The source memory’s owner is responsible for keeping it alive for as long as the slice references it.

The cost of leaning on the type system this way is that the validity of borrowed memory is not tracked at compile time. If the owner of the underlying allocation frees it before the borrow does, the &T field or [:]T slice is left dangling, and accessing it is undefined behavior. This is the same gotcha that already applies to &T parameters elsewhere in the language, just extended to fields and containers. Sections 4 and 6 explore generational references as a runtime mechanism for catching these dangling-borrow cases with a clear panic rather than silent corruption.

Alias tracking and escape analysis

The “no visible moves” property is implemented by flow-sensitive analysis within each function scope. The compiler tracks, for each heap allocation introduced in the function, the set of bindings that currently alias it. When any member of an alias set is transferred to an owning slot outside the function. Assignment to a *T field, append into a []*T container, passed as a *T argument, returned as *T — the entire alias set loses free responsibility for that allocation, and no scope-exit free is emitted for any of them.

var sword = make(Weapon{...})!   // {sword} → A, this scope owns A
var alias = sword                // {sword, alias} → A
hero.weapon = sword              // A escapes; set clears free responsibility
// scope exit: neither sword nor alias frees A; hero owns it now.

The aliases remain readable and writable after the transfer. The allocation is still live; it’s just no longer this scope’s job to release. This is what lets users write the code naturally without thinking about ownership transfer; the compiler handles it.

A few edge cases worth understanding:

Reassignment splits the set. If alias = make(Weapon{...})! later in the scope, alias leaves the original set and starts a fresh one rooted at the new allocation. Free responsibility for the original allocation stays with whoever’s left in its set; free responsibility for the new one is alias’s.

Conditional escape generates conditional free. If sword escapes on one branch of an if but not the other, the compiler emits a hidden flag and a conditional free at scope exit. The user doesn’t see this; the codegen handles it.

Borrow operations don’t touch the alias set. Passing &sword to a &T parameter, or assigning to an &Weapon field, is a borrow with no ownership transfer. The alias set for sword’s allocation is unaffected; the receiving binding is a separate short-lived borrow.

Function calls update the alias graph at the boundary. When you call equip(sword) with a *Weapon parameter, the caller’s alias set clears the same way it does for a field assignment. The callee starts fresh with its parameter as the sole owner. When the callee returns, its alias graph is gone.

Alias tracking extends to types containing heap allocations

The same rule applies to any type that contains heap allocations transitively, not just *T directly. The compiler classifies each type at definition time as either:

  • Plain old data: i32, Vec3, Transform, fixed arrays of value types, and structs whose fields are all values. Copies freely on assignment, no tracking.
  • Heap-bearing: contains a *T, string, []T, Map, or any heap-bearing type transitively. Participates in the alias graph on assignment.

So var bar = foo where foo is an Inventory { items []string } doesn’t blindly bitwise-copy and create a double-free hazard on the shared backing buffer. Instead, bar and foo enter the same alias set for that buffer, and free responsibility transfers normally on escape. POD types like Vec3 copy without tracking because there’s nothing to free.

The category is visible by inspecting the type’s fields, so no annotation is needed. The classification also matches game-dev intuition: Vec3 is a value, Player is a thing-on-the-heap.

When @nofree still matters

With alias tracking handling ownership transfer automatically, @nofree shrinks to a narrow escape valve for cases the compiler genuinely can’t see:

  • FFI returns where C code owns the allocation and Conjure is just holding the pointer.
  • rawptr casts where the type system has been deliberately bypassed and the compiler can’t classify the underlying memory.
  • Hot paths where the user wants to manage free() calls explicitly, often because the allocation is arena-backed and per-binding cleanup is wrong.

In normal code (make() followed by assignment to a struct field or container, or return from a function), the user does not need @nofree. The escape analysis sees the transfer and elides the free.

Standard library smart pointers

Because this model is mechanically close to C++‘s RAII discipline, the C++ smart pointer family maps cleanly onto Conjure stdlib types without language changes. Each is a thin wrapper around *T with a hand-written overload free and a controlled set of operations:

Conjure typeC++ analogSemantics
Box[T]std::unique_ptrSingle owner, frees the value on scope exit. Equivalent to a bare *T in this model
Rc[T]std::shared_ptrRefcounted shared ownership, non-atomic (thread-local)
Arc[T]std::shared_ptrRefcounted shared ownership, atomic (cross-thread)
Weak[T]std::weak_ptrNon-owning observer to an Rc/Arc, upgrades fallibly

These could live in the standard library and compose with auto-free’s existing machinery. Each is a heap-bearing type whose overload free performs the appropriate refcount decrement. The user opts into shared ownership by reaching for the standard library type rather than the language imposing a model. This is the same pattern Swift, C++, and modern Rust use, and it keeps the language core small.

Adopting auto-free as the language model and providing these as stdlib types covers the cases where shared ownership is genuinely needed without changing the rules for the 95% of code that doesn’t need them.

overload free and explicit free()

A type’s overload free hook runs as part of its free process, before (or in place of) the compiler-generated field frees, so the user can perform cleanup that requires field data still being valid. For types where the entire cleanup needs to be hand-rolled (usually because the compiler-generated walk is wrong for the type), overload free can do all the work, with the type’s fields declared as borrows (&T) or raw pointers (rawptr) so the compiler doesn’t try to free them automatically.

Explicit free(x) consumes its argument and the entire alias set the binding belongs to. After free(sword), any sibling alias (var alias = sword earlier in the scope) is also a compile error to use. This is the model’s only user-visible appearance of “moves,” and it’s narrow: triggered only by the explicit free() call itself, not by any other use of the binding. Consuming the whole alias set keeps the no-dangling-bindings property intact; the alternative (consuming only the named binding) would leave the aliases pointing at freed memory.

Conjure Source conjure
struct Player {
  name    string
  health  i32
  weapon  ?*Weapon  // owned: frees with player
}

struct Weapon {
  damage  i32
  wielder ?&Player  // borrowed: not freed
}

struct Inventory {
  items []string    // owned: strings + buffer
}

struct World {
  players []&Player // owned buffer, borrowed elements
}

overload free(p &Player) {
  notifyDespawn(p)
  // automatic field frees run after this:
  // - name freed
  // - weapon freed if non-null
}

func main() {
  var hero = make(Player{
      name = "Hero",
      health = 100,
      weapon = null,
  })!

  var sword = make(Weapon{
      damage = 50,
      wielder = hero,  // *Player narrows to &Player
  })!
  hero.weapon = sword
  // alias tracker sees the escape;
  // sword's free responsibility transfers
  // to hero.weapon. No @nofree needed.

  var inv = Inventory{ items = []string{} }
  inv.items.append("potion")
  inv.items.append("rope")

  var world = World{ players = []&Player{} }
  world.players.append(hero)
  // hero narrows to &Player; world owns the
  // buffer but not the players inside it.

  println("${hero.name}: ${hero.health}")

  // scope exit (reverse declaration order):
  //   world: free buffer, skip elements (they're &)
  //   inv:   free each string, free buffer
  //   sword: escaped to hero.weapon, no free
  //   hero:  overload free runs, then name
  //          frees, then weapon frees
  //          (sword.wielder is &Player, no-op),
  //          then hero allocation freed
  //
  // No double-free, no leaks, no dangling.
}
C (equivalent) c
typedef struct Weapon Weapon;
typedef struct Player Player;

struct Player {
  cjrString name;
  int32_t   health;
  Weapon*   weapon;   /* owned */
};

struct Weapon {
  int32_t damage;
  Player* wielder;    /* &Player: not freed */
};

typedef struct {
  cjrArray(cjrString) items;
} Inventory;

typedef struct {
  cjrArray(Player*) players;  /* &Player elements */
} World;

/* User-defined overload free runs first,
 then compiler-generated field walks: */

static void Player_free(Player* p) {
  notifyDespawn(p);
  cjr_str_free(&p->name);
  if (p->weapon) {
      Weapon_free(p->weapon);
      free(p->weapon);
  }
}

static void Weapon_free(Weapon* w) {
  /* damage is value; wielder is &Player: skip */
}

static void Inventory_free(Inventory* inv) {
  for (size_t i = 0; i < inv->items.len; i++)
      cjr_str_free(&inv->items.ptr[i]);
  cjr_arr_free(&inv->items);
}

static void World_free(World* w) {
  /* players elements are &Player: free buffer
     only, skip each Player* element */
  cjr_arr_free(&w->players);
}

int main(void) {
  Player* hero = malloc(sizeof(*hero));
  hero->name   = cjr_str_lit("Hero");
  hero->health = 100;
  hero->weapon = NULL;

  Weapon* sword  = malloc(sizeof(*sword));
  sword->damage  = 50;
  sword->wielder = hero;
  hero->weapon   = sword;
  /* Alias analysis: 'sword' escaped to
     hero->weapon, so no free is emitted
     for the 'sword' local at scope exit. */

  Inventory inv = {0};
  cjr_arr_append(&inv.items, cjr_str_lit("potion"));
  cjr_arr_append(&inv.items, cjr_str_lit("rope"));

  World world = {0};
  cjr_arr_append(&world.players, hero);

  printf("%s: %d
",
      cjr_str_cstr(&hero->name),
      hero->health);

  /* compiler-inserted frees, reverse order: */
  World_free(&world);
  Inventory_free(&inv);
  /* sword: no free emitted (escaped) */
  Player_free(hero);
  free(hero);
  return 0;
}
  • Default behavior matches the common case (tree-shaped ownership); no annotations needed for simple code.
  • Pointer sigil (*T vs &T) carries ownership at the type level; back-references and pool views read as &T rather than needing a separate annotation.
  • Sigil-driven function parameter conventions: *T transfers, &T borrows. No separate @owned / @borrowed keywords.
  • Alias tracking eliminates almost all manual @nofree annotations; the compiler handles ownership transfer automatically.
  • No visible move semantics in normal code; bindings remain readable and writable after being shared into containers, fields, or other bindings.
  • POD vs heap-bearing classification is determined from the type’s fields; no annotation required to participate in alias tracking.
  • Cleanup logic centralized in overload free, not at every call site.
  • Compiler implementation is local: per-function flow-sensitive analysis, no inter-procedural lifetime solving.
  • Frees happen at well-defined points (scope exits in reverse declaration order); execution is predictable.
  • Stable allocation locations are friendly to debuggers: a pointer captured at one moment is the same pointer later.
  • &T fields and [:]T slices are not validity-checked; dangling references are undefined behavior, not a runtime panic. (Sections 4 and 6 layer generational checks on top to fix this.)
  • Alias tracking is more compiler complexity than per-binding analysis, and the conditional-free codegen needs hidden flags for branch-divergent escapes.
  • Container element ownership is uniform per container: []*T or []&T, but not a mix of owning and borrowing in the same array.
  • Cyclic cleanup (linked lists, observer meshes) still requires care; indirect cycles can stack-overflow.
  • Manual free() still needed for early cleanup before scope exit, and it consumes the entire alias set rather than just the named binding.
  • Refactoring a tree-shaped design into one with back-pointers means changing some *T fields to &T and reasoning about lifetimes by hand.
  • Storing &T in struct fields and containers requires loosening the reference escape rules; the compiler can no longer prove statically that a stored reference outlives its target.
  • @nofree still survives for FFI returns, rawptr round-trips, and explicit hot-path control; the model isn’t quite annotation-free at the binding level.

3. Move semantics + auto-free

Adds compile-time ownership tracking. Each binding of an owning type has a Live or Moved state. Assigning, passing by value, returning, or storing in a struct transfers ownership: the source binding becomes Moved and any subsequent use is a compile error. Only the current owner runs cleanup, preventing double-free without runtime checks.

This is the Rust model with the borrow checker removed. The compiler tracks ownership flow through the function body via a local pass with no constraint solving or lifetime annotations, just per-binding state. The result is that the most common class of memory bug (use-after-free of an owned pointer) becomes a compile error, and the cleanup is automatic.

The cost is conceptual. Users have to learn the distinction between value types (no owning fields, copy on assignment) and owning types (contain heap data, move on assignment). They have to understand that passing a World to a function might consume it, depending on the function’s signature. Critically, this model has a real weak spot: back-pointers and other cyclic references cannot be expressed with pure move semantics, because no single binding can own the cycle. The standard workarounds are either a separate non-owning pointer type (weak[T]), or restructuring the design to use indices into a pool. Both work, but both are friction relative to “just write the obvious code.”

Partial moves (extracting a single field from a struct while leaving the rest intact) are also restricted under the simplest version of this model. Code that does this naturally (linked-list operations, swap-and-extract patterns) needs either a relaxed rule or struct-destructuring syntax to compensate.

Conjure Source conjure
struct Player {
  name    string
  health  i32
  weapon  ?*Weapon  // owns
}

struct Weapon {
  damage  i32
  wielder ?weak[Player]  // non-owning
}

struct Inventory {
  items []string
}

struct World {
  players []*Player  // owns
}

func main() {
  var hero = alloc(Player{
      name = "Hero",
      health = 100,
      weapon = null,
  })!

  var sword = alloc(Weapon{
      damage = 50,
      wielder = weak(hero),
  })!
  hero.weapon = sword
  // sword moved into hero.weapon.
  // 'sword' binding is now Moved;
  // any use of 'sword' is a compile
  // error.

  var inv = Inventory{ items = []string{} }
  inv.items.append("potion")
  // string literal moved into items.

  var world = World{ players = []*Player{} }
  world.players.append(hero)
  // hero moved into world.players[0].
  // 'hero' is now Moved.
  // println(hero.name) <- COMPILE ERROR

  // To use the player further, access
  // through the world:
  println(world.players[0].name)

  // At scope exit:
  //   world frees -> cascades to each
  //     player -> cascades to weapon
  //     -> weapon.wielder is weak,
  //        does nothing.
  //   inv frees -> frees items.
  //
  // No double-free, no dangling.
}
C (equivalent) c
/* Compiler tracks per-binding state.
 After 'world.players.append(hero)',
 the 'hero' binding is marked Moved.
 Accessing 'hero.name' fails type
 checking before reaching codegen. */

typedef struct Weapon Weapon;
typedef struct Player Player;

struct Player {
  cjrString name;
  int32_t   health;
  Weapon*   weapon;
};

struct Weapon {
  int32_t damage;
  Player* wielder;  /* weak: not freed */
};

/* (Inventory and World structs same
 as section 1) */

/* Compiler-generated frees know that
 weak fields are not owning, so no
 cascade through wielder: */

static void Weapon_free(Weapon* w) {
  /* nothing to do; damage is value,
     wielder is weak (non-owning). */
}

static void Player_free(Player* p) {
  cjr_str_free(&p->name);
  if (p->weapon) {
      Weapon_free(p->weapon);
      free(p->weapon);
  }
}

int main(void) {
  /* allocations same as before */
  /* ... */

  /* frees at scope exit:
     world owns players -> cascades
     inv owns items -> cascades
     sword and hero already moved into
     world and hero.weapon, so no free
     for those bindings. */

  World_free(&world);  /* frees everything */
  Inventory_free(&inv);
  return 0;
}
  • Use-after-move and use-after-free caught at compile time with clear diagnostics.
  • No runtime overhead for ownership tracking.
  • Ownership is visible in the type system: function signatures signal whether they consume or borrow.
  • Pairs naturally with auto-free; no double-free risk.
  • Stable allocation locations: debuggers can rely on pointers staying put for the lifetime of an owning binding.
  • Learning curve: users must understand owning vs value types and move semantics.
  • Back-pointers and observer state require a separate weak pointer type or restructuring as indices.
  • Partial moves (extracting one field of a struct) are restricted, complicating linked-list-style code.
  • Compiler must track Live/Moved state through control flow.
  • API design becomes harder: every function signature must commit to consuming vs borrowing, and refactoring between the two ripples through call sites.
  • Closures that capture data have to choose between owning the captured value (consuming it from the surrounding scope) or borrowing (requiring lifetime tracking that this model doesn’t provide).

4. Generational references

Every heap allocation carries a generation counter in its header. Every pointer carries the generation it expected when the pointer/reference was created. Dereferencing checks that the generation still matches. If it doesn’t (because the memory was freed and possibly reused), it panics with a diagnostic. Use-after-free becomes a runtime crash with a clear message instead of silent undefined behavior.

This is the model originally used by Vale. The key insight is that ownership tracking and aliasing analysis (the hard parts of move semantics and borrow checking) are replaced by a small, predictable runtime check. The user can have as many pointers to the same allocation as they like; each one independently validates that the target is still live. When the allocation is freed, the generation counter is bumped, and every outstanding pointer to it now fails the check on next use.

For cyclic structures and back-pointers this is a near-perfect fit. Player can point to Weapon, Weapon can point back to Player, and both pointers are gen-checked on every dereference. There’s no special “weak” type, no index-based workaround, no cycle to break in the type system. The cycle exists in memory and the runtime ensures it stays safe.

The cost lands in three places. Pointers grow from 8 bytes to 16 bytes (target + expected generation). Allocation headers grow by 8 bytes. Each dereference does one comparison, which is usually cheap, and the compiler can hoist or elide the check in cases where it’s provably unnecessary. The overall runtime overhead is in the range of 3-8% for pointer-heavy code; for value-heavy code it’s effectively zero.

The most important tradeoff is that errors surface at runtime rather than compile time. A use-after-free becomes a clear panic with a stack trace and a diagnostic pointing at both the free site and the access site. This is better than C’s silent corruption, but worse than a compile-time error that prevents the bug from ever shipping. For library code where the developer doesn’t control the calling context, this is a genuine concern.

Conjure Source conjure
struct Player {
  name    string
  health  i32
  weapon  ?*Weapon  // gen ref
}

struct Weapon {
  damage  i32
  wielder ?*Player  // gen ref
}

struct Inventory {
  items []string
}

struct World {
  players []*Player  // gen refs
}

func main() {
  var hero = &Player{
      name = "Hero",
      health = 100,
      weapon = null,
  }

  var sword = &Weapon{
      damage = 50,
      wielder = hero,
  }
  hero.weapon = sword
  // both pointers are gen-checked;
  // aliasing is fine.

  var inv = Inventory{ items = []string{} }
  inv.items.append("potion")

  var world = World{ players = []*Player{} }
  world.players.append(hero)
  // world.players[0] is another gen ref
  // to the same allocation as 'hero'.

  println("${hero.name}: ${hero.health}")

  // At scope exit, both 'hero' and
  // 'world.players[0]' will try to free
  // the same allocation. The first free
  // bumps the generation; the second
  // sees the mismatch and is a no-op.
  // No double-free, no panic.
  //
  // If we'd manually freed hero earlier,
  // any subsequent access through
  // world.players[0] would panic with
  // a clear use-after-free diagnostic.
}
C (equivalent) c
typedef struct {
  uint64_t gen;
} AllocHeader;

typedef struct {
  void*    target;
  uint64_t expected_gen;
} Ref;

#define HEADER(p)   ((AllocHeader*)((char*)(p) - sizeof(AllocHeader)))

static inline void* check(Ref r) {
  if (HEADER(r.target)->gen
          != r.expected_gen)
      panic("use-after-free");
  return r.target;
}

static Ref alloc_ref(size_t size) {
  AllocHeader* h =
      malloc(sizeof(*h) + size);
  h->gen = 1;
  return (Ref){
      (char*)h + sizeof(*h), 1
  };
}

static void free_ref(Ref r) {
  AllocHeader* h = HEADER(r.target);
  if (h->gen != r.expected_gen)
      return;  /* idempotent */
  h->gen = 0;
  free(h);
}

int main(void) {
  Ref hero  = alloc_ref(sizeof(Player));
  Ref sword = alloc_ref(sizeof(Weapon));

  Player* hp = check(hero);
  Weapon* sp = check(sword);
  /* ... initialize fields ...
     hp->name = cjr_str_lit("Hero")
     hp->health = 100, etc. */

  hp->weapon  = sword;
  sp->wielder = hero;

  Inventory inv = {0};
  cjr_arr_append(&inv.items, cjr_str_lit("potion"));

  World world = {0};
  cjr_arr_append(&world.players, hero);
  /* both 'hero' and world.players[0]
     are the same Ref struct, copied
     by value. Each carries a copy of
     the expected_gen. */

  printf("%s: %d
",
      cjr_str_cstr(&((Player*)check(hero))->name),
      ((Player*)check(hero))->health);

  /* scope exit: free_ref called for
     both hero and world.players[0].
     First call bumps gen; second sees
     mismatch and is a no-op (idempotent).
     inv strings are released via the
     arr_free machinery. */
  cjr_arr_free(&world.players);   /* frees each Ref */
  cjr_arr_free(&inv.items);       /* frees each cjrString */
  free_ref(sword);
  free_ref(hero);
  return 0;
}
  • Use-after-free becomes impossible: caught at runtime with a clear diagnostic.
  • No move semantics, no ownership tracking, no borrow checker.
  • Back-pointers, doubly-linked lists, and observer patterns all work naturally.
  • Aliasing works without ceremony (no weak[T] or pool-index workarounds).
  • Idempotent free: aliased pointers can both auto-free without double-free.
  • Compiler stays small; no per-binding state tracking required.
  • Debug builds can attach the source location of each free to the gen counter, so a runtime panic can point at both the access site and the free site.
  • Pointers double in size (8 → 16 bytes) due to expected-generation field.
  • Allocation headers grow by 8 bytes.
  • Runtime cost: one compare per dereference (often optimized away, 3-8% typical overhead).
  • Some bugs that move semantics catches at compile time become runtime panics here. This is better than C’s silent corruption, but worse than a build failure.
  • FFI with C requires marshaling between fat *T and thin T*.

5. Generational references with refcount

Splits the generation field into two parts: a generation counter and a reference count. Multiple bindings holding the same pointer each contribute to the refcount; the allocation is only freed when the count reaches zero. Combined with auto-free, this gives correct cleanup semantics for shared pointers without requiring move semantics or explicit ownership.

This is the natural evolution of pure gen refs when shared ownership is common. Where the previous model relied on “first free wins, rest are no-ops” (which is fine when one binding is the canonical owner) this version actually counts references and only frees when the last reference disappears. The result is that the user can put a pointer in two places (a local variable and a container) without thinking about which one is “really” the owner. Both contribute to the refcount; whichever frees second is the one that actually frees.

The cost is that every pointer copy and every free becomes a non-trivial operation: read the header, modify the refcount, write back. In single-threaded code this is cheap (a few instructions); in multithreaded code it requires atomic operations, which are an order of magnitude slower per op. In practice the per-op slowdown rarely translates to a 10x whole-program hit because most code is doing work other than copying pointers, but for sharing-heavy workloads (graph traversal, observer-heavy systems) the cost is real and measurable. The compiler can elide refcount operations when escape analysis proves they’re unnecessary (pointers that don’t escape their function don’t need refcount management) but the residual cost depends on how much sharing the program actually does.

Reference cycles are the other notable failure mode. Two allocations that point at each other can keep their refcounts above zero forever, even after all external references are gone. This is the same problem Swift and Nim face; the standard answers are either a separate cycle detector that runs periodically or a programmer discipline of breaking cycles explicitly. Conjure would have to pick one.

Conjure Source conjure
struct Player {
  name    string
  health  i32
  weapon  ?*Weapon
}

struct Weapon {
  damage  i32
  wielder ?*Player
}

struct Inventory {
  items []string
}

struct World {
  players []*Player
}

func main() {
  var hero = &Player{
      name = "Hero",
      health = 100,
      weapon = null,
  }
  // refcount = 1

  var sword = &Weapon{
      damage = 50,
      wielder = hero,  // hero refcount = 2
  }
  hero.weapon = sword  // sword refcount = 2

  var inv = Inventory{ items = []string{} }
  inv.items.append("potion")

  var world = World{ players = []*Player{} }
  world.players.append(hero)
  // hero refcount = 3

  println("${hero.name}: ${hero.health}")

  // At scope exit:
  //   world frees: hero refcount = 2
  //   inv frees
  //   sword binding frees: sword
  //     refcount = 1 (held by hero.weapon)
  //   hero binding frees: hero
  //     refcount = 1 (held by sword.wielder)
  //
  // hero and sword both have refcount=1
  // and reference each other. Classic
  // cycle. Neither is freed without a
  // cycle detector or manual break.
}
C (equivalent) c
typedef struct {
  uint32_t gen;
  uint32_t refcount;
} AllocHeader;

typedef struct {
  void*    target;
  uint32_t expected_gen;
} Ref;

static Ref ref_clone(Ref r) {
  AllocHeader* h = HEADER(r.target);
  if (h->gen == r.expected_gen)
      h->refcount++;
  return r;
}

static void ref_free(Ref r) {
  AllocHeader* h = HEADER(r.target);
  if (h->gen != r.expected_gen) return;
  if (--h->refcount == 0) {
      h->gen++;
      free(h);
  }
}

/* Compiler inserts ref_clone at every
 pointer copy and ref_free at every
 binding's scope exit. Escape analysis
 elides where it can prove the count
 transitions are balanced and the
 pointer doesn't escape. */

int main(void) {
  /* allocations as before */
  /* hero refcount = 1
     sword refcount = 1
     each cjrString in inv.items has
     its own refcount = 1 */

  sword->wielder =
      ref_clone(hero);   /* hero rc = 2 */
  hero->weapon =
      ref_clone(sword);  /* sword rc = 2 */

  ref_clone(hero);       /* hero rc = 3 */
  /* world.players.append(hero) */

  /* scope exit frees: */
  /* world frees -> hero rc = 2 */
  /* inv frees -> strings freed */
  /* sword binding frees -> sword rc = 1 */
  /* hero binding frees -> hero rc = 1 */

  /* cycle: hero and sword keep each
     other alive. Detector or explicit
     break required. */
  return 0;
}
  • Shared ownership works correctly without explicit move semantics.
  • Auto-free handles cleanup; no free() in user code.
  • Use-after-free still caught at runtime via generation check.
  • Same memory model whether you have one owner or many.
  • Pointer aliasing is safe and natural.
  • Refcount operations on every pointer copy and free (often elided by escape analysis).
  • Multithreaded code requires atomic refcount operations: per-op cost is ~10x higher than non-atomic, though whole-program impact depends on how much actual sharing occurs.
  • Performance becomes harder to predict without inspecting compiler output.
  • Reference cycles leak; need a separate detector or cycle-breaking discipline.
  • Two pointer mechanics to understand (gen check and refcount) bundled into one type.
  • Free timing is non-deterministic across a tree of refs: the actual free happens whenever the last ref leaves scope, which may be in a different function than where allocation occurred.

6. Move + auto-free + generational references (hybrid)

The model used by Vale, adapted. Owning values use move semantics for compile-time ownership tracking. Borrowed references use generational checks for runtime safety. Auto-free handles cleanup based on move tracking. This catches the common bug classes at compile time while keeping the difficult patterns (back-pointers, shared references) safe at runtime.

The intuition: the bugs caught by move semantics and the bugs caught by gen refs are largely different bugs. Move semantics excels at “I forgot to free this” and “I used this after passing it away”. These are classic linear-flow ownership mistakes. Gen refs excel at “this pointer is one of many to the same thing, and somebody invalidated it” (the aliasing and back-reference cases). By using each technique where it’s strongest, the hybrid model covers more ground than either alone, at the cost of being a larger system to learn.

How the compiler tells owning pointers from gen-checked references. The distinction is determined by where the pointer comes from, tracked by the compiler at the point of creation:

  • A pointer obtained by constructing a fresh value (e.g. var hero = &Player{...}) is an owning pointer. The compiler tracks its Live/Moved state and inserts an auto-free on the binding that holds it.
  • A pointer obtained by taking the address of an existing value or aliasing another pointer (e.g. wielder = hero, where hero is already a *Player) is a gen-checked reference. It carries the expected generation and is checked on every dereference, however no auto-free fires for it. Only the owning binding is responsible for cleanup.

In source, both look like *Player. The compiler infers which is which based on the assignment expression. This keeps the type system small (one pointer type, not two) but means the user has to think about what each assignment means semantically: “am I taking ownership, or am I just aliasing?”

When an owning pointer is moved (stored in a container, returned, passed to a consuming function), the move is tracked exactly as in section 3. The source binding becomes Moved and any subsequent use is a compile error. The destination is now the owner. Anything that previously aliased the source (the gen-checked references) continues to point at the same allocation and continues to gen-check on access (they don’t move, they’re just observers).

When the owning binding (wherever it now lives) eventually frees, the allocation is freed and its generation is bumped. Any outstanding gen-checked references now fail their check on next use, exactly like in section 4.

Conjure Source conjure
struct Player {
  name    string
  health  i32
  weapon  ?*Weapon
}

struct Weapon {
  damage  i32
  wielder ?*Player
}

struct Inventory {
  items []string
}

struct World {
  players []*Player
}

func main() {
  var hero = &Player{
      name = "Hero",
      health = 100,
      weapon = null,
  }
  // 'hero' is an owning pointer
  // (created from fresh construction);
  // compiler tracks Live/Moved state.

  var sword = &Weapon{
      damage = 50,
      wielder = hero,
  }
  // 'sword' is also an owning pointer.
  // sword.wielder is a gen ref to hero
  // (aliasing an existing pointer, not
  // a fresh construction). hero binding
  // is still Live.

  hero.weapon = sword
  // sword moved into hero.weapon;
  // sword binding now Moved.
  // hero.weapon owns the Weapon.

  var inv = Inventory{ items = []string{} }
  inv.items.append("potion")

  var world = World{ players = []*Player{} }
  world.players.append(hero)
  // hero moved into world.players[0];
  // hero binding now Moved.
  // println(hero.name) <- COMPILE ERROR

  // sword.wielder still aliases hero's
  // allocation. It's a gen ref so the
  // move doesn't invalidate it; it
  // just points at the same memory,
  // now owned by the world.

  println(world.players[0].name)

  // At scope exit:
  //   world frees -> cascades to each
  //     player -> cascades to its
  //     owned weapon. Weapon.wielder
  //     is a gen ref so nothing to
  //     do for it. Player's gen is
  //     bumped on free.
  //   inv frees -> frees items.
  //
  // No double-free, no leaks, no
  // dangling back-pointer.
}
C (equivalent) c
/* Owning pointers are thin (8 bytes,
 tracked by move analysis).
 Gen-checked references are fat
 (16 bytes: target + expected_gen). */

typedef struct {
  cjrString name;
  int32_t   health;
  Ref       weapon;   /* gen ref */
} Player;

typedef struct {
  int32_t damage;
  Ref     wielder;    /* gen ref */
} Weapon;

typedef struct {
  cjrArray(cjrString) items;
} Inventory;

typedef struct {
  cjrArray(Player*) players;  /* owning */
} World;

/* Compiler tracks move state for owning
 bindings (sections 3-style).
 Gen ref fields use the gen-check
 machinery (section 4-style).
 Each mechanism applies only to its
 own subset of pointers. */

int main(void) {
  Player* hero  = alloc_with_gen(sizeof(*hero));
  /* hero->name, health, weapon initialized */

  Weapon* sword = alloc_with_gen(sizeof(*sword));
  /* sword->damage, wielder initialized;
     wielder = make_ref(hero)
     (gen ref, not an ownership transfer) */

  /* hero.weapon = sword:
     owning move tracked by compiler.
     sword binding becomes Moved. */
  hero->weapon = make_ref(sword);

  /* world.players.append(hero):
     owning move into the container.
     hero binding becomes Moved. */
  World world = {0};
  cjr_arr_append(&world.players, hero);

  Inventory inv = {0};
  cjr_arr_append(&inv.items, cjr_str_lit("potion"));

  printf("%s
",
      cjr_str_cstr(&world.players.ptr[0]->name));

  /* scope exit:
     World_free cascades to each player,
     which cascades to its owned weapon.
     Each owned allocation has its gen
     bumped on free; gen refs to it
     (sword.wielder) become stale but
     safely detected on any later use. */

  World_free(&world);
  Inventory_free(&inv);
  return 0;
}
  • Compile-time use-after-free for the common owning-value case.
  • Runtime use-after-free protection for shared and aliased pointers.
  • Back-pointers and cyclic structures work naturally with gen refs.
  • No weak[T] type needed; all references are uniformly safe.
  • Most ownership patterns expressible without unsafe escape hatches.
  • Cost lands where it’s earned: gen check overhead only on shared refs, no overhead on owning pointers.
  • Owning pointers stay 8 bytes (thin); only gen refs pay the size cost.
  • Two mechanisms to understand: move semantics AND generational refs.
  • Spec is larger than either approach alone.
  • Runtime cost on shared references (same as pure gen refs for that subset).
  • Compiler must implement both consume tracking AND gen check insertion.
  • Subtle: programmer must understand when a pointer is owning vs gen-checked, even though both look like *T in source.
  • Refactoring an aliased reference into an owning one (or vice versa) may ripple through code in non-obvious ways.

7. Compiler-managed refcount (Lobster-style)

A reference-counted model where the compiler runs escape analysis aggressively and elides most refcount operations when it can prove they’re balanced. The user writes simple code with no explicit allocation, no free(), and no move tracking. The compiler decides where allocations live, whether refcount ops are needed, and when to emit them. This is the model used by Lobster.

The pitch: simplest possible user model. You write a struct, you assign pointers, you stop thinking about memory. The compiler treats every pointer as if it’s reference-counted, then proves which refcount operations are unnecessary and removes them. In practice, ~70-90% of refcount ops in typical code get elided. The residual ones happen at points where sharing is real, like pointers stored in containers, returned from functions, or captured by closures.

The Conjure flavor of this would lean on the compiler intrinsics already used elsewhere: alloc and free would remain compiler-known, but the user wouldn’t call them. Escape analysis would decide stack-vs-heap placement and refcount-vs-direct-free at compile time. Profile-guided builds could emit a report of exactly where the residual refcount ops live, so a performance-conscious user can see them and rewrite to eliminate them. This would make the cost visible, just not in the source code.

Conceptually this is the most ergonomic option in the document: the user writes essentially Go-like code, with no GC pause, and the compiler arranges the memory underneath. The big tradeoff is that the user gives up direct control. What allocates and what doesn’t is not visible in source; it’s the compiler’s decision. For game and engine developers who profile hot loops, this is a real concern: “the compiler should have elided this refcount op” is the kind of complaint that drives people to languages with more explicit control like C or Zig.

Conjure Source conjure
struct Player {
  name    string
  health  i32
  weapon  ?*Weapon
}

struct Weapon {
  damage  i32
  wielder ?*Player
}

struct Inventory {
  items []string
}

struct World {
  players []*Player
}

func main() {
  var hero = &Player{
      name = "Hero",
      health = 100,
      weapon = null,
  }

  var sword = &Weapon{
      damage = 50,
      wielder = hero,
  }
  hero.weapon = sword
  // no move semantics; both bindings
  // remain usable. Compiler will
  // emit refcount ops where sharing
  // actually happens.

  var inv = Inventory{ items = []string{} }
  inv.items.append("potion")

  var world = World{ players = []*Player{} }
  world.players.append(hero)
  // hero is now shared between local
  // 'hero' binding and world.players.
  // Compiler emits a refcount inc here.

  println("${hero.name}: ${hero.health}")

  // scope exit:
  //   compiler-emitted refcount decs
  //   for all bindings still live;
  //   when count hits 0, free runs.
  //   cycle between hero and sword
  //   needs cycle detector or
  //   programmer discipline.
}
C (equivalent) c
/* Compiler walks the function and
 inserts refcount ops at every
 point where ownership analysis
 can't prove the count would be
 balanced anyway. */

int main(void) {
  Player* hero = alloc_with_gen(sizeof(*hero));
  /* init hero */

  Weapon* sword = alloc_with_gen(sizeof(*sword));
  sword->wielder = hero;
  /* compiler analysis: sword.wielder
     outlives the local 'hero' binding
     (sword is stored elsewhere later),
     so emit rc_inc on hero here. */
  rc_inc(hero);

  hero->weapon = sword;
  /* analysis: sword stored in hero;
     sword binding still used later
     in this function... depending on
     how, may or may not need an inc.
     Lobster-style analysis tries to
     prove no inc is needed by
     checking remaining uses. */

  /* world.players.append(hero):
     another shared owner. */
  rc_inc(hero);

  printf("...");

  /* scope exit decs:
     local 'hero' binding releases its
     reference. local 'sword' binding
     releases its reference.
     world and inv go out of scope,
     triggering their own frees which
     in turn release any contained
     references. */
  rc_dec(hero);  /* now hero is owned
                   only by world */
  /* etc. */
  return 0;
}
  • Simplest user model: no free(), no move semantics, no explicit ownership.
  • Sharing patterns (back-pointers, observers, graphs) require no special syntax.
  • Compiler does the work; user doesn’t have to think about memory in most code.
  • Predictable: refcount ops happen at well-defined points (assignment, scope exit).
  • Naturally handles closures capturing data, since captured values are just shared references.
  • Where the compiler emits refcount ops is invisible in source code; a performance-conscious user has to read the compiler’s output to see them.
  • Cycles still leak; needs detector or discipline (same problem as section 5).
  • Refactoring code can change escape analysis results, making refcount ops appear or disappear with no visible source change.
  • Multithreaded sharing requires atomic ops; per-op cost is ~10x higher when atomic.
  • Doesn’t catch use-after-free at compile time; bugs that move semantics would catch become runtime issues (though refcount means most don’t actually crash).
  • Profile-guided optimization becomes important to verify the compiler is eliding what you expect.

Cross-cutting concerns

The seven models above answer “how is ownership tracked,” but each one composes with the rest of the language differently. This section walks through the parts of Conjure that the memory model has to interact with: threading, coroutines, sum types, low-level memory, slices, error handling, and observability.

Throughout this section, the current spec is the reference point for syntax and existing semantics. The spec currently specifies move semantics (section 13), but that decision is not final; the comparisons below describe how each model would interact with the surrounding language rather than committing to one.

Concurrency and threading

A language for game and engine development needs a concurrency story. Conjure’s stated target users write job systems, parallel ECS update loops, and async asset streaming. Each memory model interacts with multi-threaded sharing differently, and the cost of that interaction is where most of the per-model differences land.

The unifying observation is that sharing across threads forces atomic operations on any runtime bookkeeping the model performs. Single-threaded use of the same model can stay on cheap non-atomic ops. So the type system needs a way to distinguish “this allocation is observed by one thread” from “this allocation is observed by many,” analogous to Rust’s Send/Sync markers.

Conjure can derive these markers from type structure without user annotation:

  • A type is Send (can be moved to another thread) if its free can run on any thread. POD types are trivially Send. Owning types containing only Send fields, are Send. Types holding thread-local handles (file descriptors flagged as thread-local, OS handles) are not Send.
  • A type is Sync (a &T can be shared across threads) if concurrent reads through borrowed references are safe. POD is Sync. Owning types with no interior mutability and no thread-local state are Sync.

Per-model breakdown:

  • Manual + defer. Zero compiler help; the user manages atomics via the standard library. Same model as C where nothing prevents accidentally sharing a non-thread-safe pointer.

  • Auto-free. Free runs on whichever thread holds the owning binding when scope exits. Moving an owning value across threads transfers the free site. Standard library can provide thread-safe wrappers (the C++ shared_ptr analog mentioned below) for shared ownership across threads.

  • Move + auto-free. Move semantics naturally encode Send: moving a value into a worker thread’s task transfers ownership cleanly. The compiler already tracks the move; the threading question is whether the type is Send, which is a structural check independent of the move analysis.

  • Generational references. The generation counter must be atomic when the allocation is shared across threads. For purely thread-local allocations, a non-atomic counter suffices. The compiler can specialize: if escape analysis proves an allocation never escapes its originating thread, emit a non-atomic check; otherwise emit atomic. On modern hardware, an atomic load is roughly the same cost as a regular load (acquired ordering is cheap on x86 and ARMv8.1+), so the cost is primarily on the bump path (one atomic store on free) rather than the dereference path.

  • Generational references with refcount. Refcount must be atomic when shared. This is where the ~10x per-op cost lands. A standard split would mirror Rust: Rc[T] (non-atomic, thread-local) and Arc[T] (atomic, sharable). The compiler can pick which to use based on whether the type is observed across threads, or the user can spell it explicitly through a stdlib type.

  • Hybrid. Move side stays compile-time and has no threading impact. Gen-ref side follows the gen-ref model: atomic gen counter when shared. The “best of both” property holds here too: most allocations are owning and pay nothing for threading; shared aliases pay the atomic check.

  • Compiler-managed refcount. The hardest model to make thread-safe without explosion in cost. The compiler must know across function boundaries whether a value is reachable from multiple threads, which is the same inter-procedural escape analysis problem amplified. Conservative answers (assume everything is shared) push every pointer copy onto the atomic path; aggressive answers risk wrong behavior on a refactor. This is the largest tax on the model’s “simplest user experience” pitch.

Per-thread arena pattern. A pattern that composes well with every model: each thread owns its own arena for short-lived allocations, with cross-thread communication restricted to explicit message-passing or carefully wrapped shared types. This is the same convention used in modern game engines (frame allocators per worker, plus a small set of explicitly-shared resources), and it removes most of the threading cost regardless of which model is chosen.

// Per-thread arena, single-threaded sharing within the thread, explicit handoff between threads.
func worker(jobs &JobQueue, frame &Arena) {
    while job = jobs.pop() {
        var scratch = alloc(WorkBuffer, frame)!     // thread-local, no atomics
        process(job, scratch)
        // scratch is freed at scope exit; arena reclaims on frame end
    }
}

Coroutines and generator state

Generators are stackless coroutines: the compiler synthesizes a state struct holding the function’s locals and a resume token, then drives it forward on each call. Because the state struct is a real type the user code touches indirectly, the memory model has to apply to it the same way it applies to any other struct.

Capture semantics. A generator’s locals become fields of the state struct. Owning locals are moved into the struct when the generator is constructed (under move semantics) or copied via alias-tracking ownership transfer (under auto-free). Either way, the result is that the state struct owns the locals; releasing the generator releases them.

Yield is not scope exit. A common confusion: yield suspends, it does not exit a scope. Locals that were Live before a yield are still Live after the resume. Frees do not fire at yield boundaries; they fire when the generator value itself is freed (when the binding holding it goes out of scope or is explicitly consumed).

Per-model behavior:

  • Manual + defer. Generators are state machines; defers registered inside a generator fire when the generator completes (the synthesized exit point), not at each yield. This matches the user’s mental model of “the function eventually returns,” with yield as a pause rather than a return.

  • Auto-free and Move + auto-free. Owning locals captured into the state struct are freed when the generator is freed. If the user holds a generator past its last yielded value (.done is true) without consuming it, the free still fires on scope exit, so resources don’t leak.

  • Generational references. Gen refs in the state struct work like gen refs anywhere else. A generator that captures &T from outside is constrained and cannot outlive the referenced data, and the escape rules prevent storing such a generator anywhere that would outlive the borrow.

  • Refcount variants. Refcounted captures stay alive as long as the generator does. This makes long-lived generators (a coroutine that yields once per frame for many frames) hold their captures across the entire animation, which is usually the right semantics for game coroutines.

The async question. Conjure does not currently spec async/await. If it ever does, the answers above generalize: an async state machine is a generator with an external scheduler. The memory model treats the state struct the same way; the only addition is that the scheduler may invoke the state machine from a different thread than the one that created it, which folds back into the threading discussion above.

Closures

Conjure currently has no closure type, though it’s not out of the question and should be a consideration. Generators cover most of the use cases (custom iteration, callback-driven game logic via gen[void], event-loop integration), and the rest can be expressed by passing a function pointer plus an explicit state struct.

A reason to leave closures out is that they multiply the memory model’s surface area in ways that compound across every section above. Each model has to answer:

  • Capture by value or by reference? Auto-free must decide whether captured values get freed when the closure is freed. Move semantics must decide whether capture moves the source. Refcount must decide whether capture increments.
  • Single-use or multi-use? A closure that’s called once can consume its captures; one called many times cannot. Move semantics would need a FnOnce/Fn distinction (Rust’s solution).
  • Heap or stack? If a closure escapes its construction scope, it must be heap-allocated. If it doesn’t, stack is fine. This forces every closure to go through escape analysis.
  • Trait/interface dispatch. Calling a closure of varying capture shape requires either monomorphization (compile-time specialization) or a function pointer plus an opaque environment pointer (uniform calling convention). Both have costs Conjure has so far avoided.

The patterns Conjure can offer instead, none of which require new language features:

// 1. Generators for iteration and coroutine-shaped callbacks.
func tween(start f32, end f32, frames u32) gen[f32] {
    for i in <frames {
        yield lerp(start, end, f32(i) / f32(frames))
    }
}

// 2. Function pointer + explicit state struct for event handlers.
struct OnClick {
    target  &Button
    handler func(target &Button, event &MouseEvent)
}

// 3. UFCS + module-level functions when the "closure" is really a method call.
struct Counter { value i32 }
func increment(c &Counter) { c.value += 1 }

var c = Counter{}
button.onClick = c.increment    // bound method via UFCS

If closures are added later, the cleanest path is probably a Fn[Args, Ret] interface in the standard library backed by compiler-synthesized structs, where the user opts into capture explicitly. This keeps the language core small and pushes the model-specific decisions (move vs copy on capture) into stdlib types that can vary per use case.

Tagged unions, untagged unions, and distinct types

Conjure’s type system has three constructs that interact with the memory model in subtle ways: data enums (tagged unions), standalone unions (untagged), and distinct types.

Data enums (tagged unions)

Data enums carry a tag plus the active variant’s payload. Different variants may have different ownership profiles. For example:

enum Message {
    Text     string          // likely owning: string can be heap-bearing
    Code     i32             // POD: no ownership, copy on assignment
    Buffer   *[]u8           // owning: pointer to dynamic array
    Empty                    // no payload
}

The compiler-synthesized free for Message dispatches on the tag, runs the appropriate per-variant free, and is a no-op for variants with no payload or plain data payloads.

Per-model behavior:

  • Manual + defer. User writes the dispatch by hand in overload free, with a when over the tag.
  • Auto-free and Move + auto-free. Compiler generates the dispatch automatically. The data enum is heap-bearing if any variant’s payload is heap-bearing; the move/free machinery applies at the enum level.
  • Generational references. Pointer-typed variants are gen refs; the gen check applies per dereference. POD variants are unchanged.
  • Refcount variants. Each pointer-typed variant participates in refcount; tag-aware ops on assignment.
  • Hybrid. Each variant follows its individual payload semantics.

Pattern matching and partial moves. The spec forbids partial moves out of struct fields. The same rule applies to data enum payloads: a when arm that destructures a payload binds it by reference, not by move. Code that wants to consume the payload must consume the whole enum value.

when msg {
    case .Text s:   process(s)        // s is &string, borrowed from msg's payload
    case .Code n:   compute(n)
    case .Buffer b: render(b)
}
// msg is still Live here; payloads were borrowed, not moved.

Untagged unions

union { ... } is a low-level construct where all fields share the same address and there is no discriminator. The compiler cannot know which field is active, which means it cannot generate a safe free.

Implication: untagged unions cannot contain owning fields under any model that performs automatic cleanup. The compiler should reject such declarations with a diagnostic:

union BadUnion {
    bytes  [16]u8
    name   string       // error: untagged union cannot contain owning field
}

This is the only construct in the language that is intentionally outside the memory model. Untagged unions are for FFI compatibility and bit-level reinterpretation. Heap ownership and ambiguous interpretation are mutually exclusive.

The escape valve, when the user genuinely needs owning data in a union shape, is to use rawptr and take responsibility for cleanup manually:

union HandleOrPtr {
    handle u64
    ptr    rawptr       // ok: rawptr is opaque to the free machinery
}

Distinct types

distinct is mostly orthogonal to memory management: a distinct type inherits its underlying type’s representation and ownership classification. distinct UserId = u32 is just a value. distinct Handle = *Player is owning, moves on assignment, and follows whichever model is active.

The interesting case is using distinct to partition pointers by allocation source. A distinct alias of rawptr makes “this pointer was allocated from a specific arena” a compile-time fact:

distinct ArenaPtr = rawptr

func arenaAlloc(a &Arena, size usize) ArenaPtr { ... }
func arenaFree(p ArenaPtr) { ... }

// free(p) where p is *Player won't compile if free expects a different distinct type.
// arenaFree(p) where p is *Player also won't compile.

This is a lightweight way to encode “this pointer participates in a specific cleanup discipline” without adding a new construct to the type system, and it composes with every memory model.

Pinning and fixed-address memory

Operating-system kernels, embedded firmware, GPU drivers, and DMA-using code all share a constraint Conjure’s higher-level models don’t natively express: certain pointers must never be relocated, and certain memory regions must never have language-managed headers prepended.

While Conjure’s stated goals aren’t explicitly about kernel or embedded development, there is a desire to accommodate those use cases.

The canonical cases:

  • Memory-mapped I/O. Hardware registers live at fixed virtual addresses. The pointer is given to the program by the platform, not allocated.
  • DMA buffers. Hardware reads/writes directly from a buffer whose physical address was promised to the device; the language cannot move that buffer.
  • FFI handles. A C library may hold a pointer to a Conjure-managed struct across a callback boundary. Moving the struct invalidates the C side’s pointer.
  • Self-referential structs. A struct with a field pointing into itself; moving the struct invalidates the field.
  • Kernel paging structures. Page tables, descriptor tables, and trap frames are at architecturally-mandated addresses.

The shared requirement: the user needs a pointer-to-T that has no compiler-inserted header, no movement under any operation, and predictable cleanup discipline (often “never, the OS or hardware owns this”).

In almost every model, rawptr operates at the natural escape valve for this use case: it’s a pointer type that opts out of all compiler tracking and management. However, rawptr is opaque to the compiler, so it can’t be dereferenced or used in safe code without unsafe escape hatches. This is fine for opaque handles but less ergonomic for MMIO and self-referential structs.

Per-model behavior:

  • Manual + defer. Direct fit. rawptr cast to *T.

  • Auto-free. Needs an opt-out for fields and locals that hold pinned pointers. The natural mechanism is &T (which is 8 bytes, no header, no auto-free) for MMIO and external memory. The user marks fields as &T to communicate “this points at something this struct doesn’t own and shouldn’t try to release.” rawptr can once again be used for opaque handles that the language shouldn’t touch at all.

  • Move + auto-free. Move semantics moves bindings, not memory. A *T field can stay in the same allocation across a struct move (the struct’s bits move; the pointed-to data does not). However, self-referential structs are still a hazard: moving the outer struct invalidates the inner pointer. The fix is either to disallow self-references or to add a Pinned[T] stdlib type that opts out of move.

  • Generational references. Hardest fit for kernel work. The gen-ref model assumes every allocation carries a header. Memory the user doesn’t allocate (MMIO, DMA, hardware-handed buffers) has no place for the header. Conjure would need a parallel thin-pointer mode for these cases. &T (already 8 bytes, no header) is the natural escape valve: declare MMIO regions as &MMIORegisterBlock, use them like any other reference, and pay no per-deref check. The cost is that the dangling-detection benefit of gen refs is lost for those regions, which is acceptable because the kernel manages those lifetimes manually.

  • Refcount variants. Same constraint as gen refs: any model that requires a per-allocation header excludes user-from-platform memory. &T is again the escape.

  • Hybrid. Inherits gen refs’ constraint for the gen-checked subset. The owning subset (8 bytes, no header) works for kernel code as long as &T is used for MMIO.

Possible recommendation. An alternative to over-reliance on rawptr would be to add a @pinned annotation (or distinct Pinned[T] type). This would be applied to struct types that must not be moved and pointer types that must not be relocated. The compiler reports an error on any operation that would relocate a @pinned value. This composes with every memory model: it’s a structural constraint on the type, not a new tracking mechanism.

@pinned
struct DescriptorTable {
    entries [256]Descriptor
}

// Compiler rejects: cannot move a @pinned value, cannot return by value.
func makeTable() DescriptorTable { ... }    // error

// Allowed: construct in place, hand out borrows.
func setupTable(t &DescriptorTable) { ... }

Another treatment of this would be on global variables, where the @pinned annotation could accept an address argument to place the variable at a specific location in the binary, which is useful for kernel paging structures and MMIO regions.

@pinned(0xFFFF_FFFF_8000_0000)
var pageTable = [512]PageTableEntry{ ... }

Borrowed slices [:]T

Slices are 16-byte (ptr, len) borrowed views into existing contiguous memory. They do not own their backing storage; calling free() on a slice is a compile error. The backing buffer must outlive the slice.

The slice lifetime question is “how does each model keep the backing buffer alive long enough.” Because slices appear everywhere (function parameters, return values, struct fields, embedded data via embed), this question recurs throughout the language.

Per-model behavior:

  • Manual + defer. The user is responsible for ordering: free the backing buffer only after all uses of any slice into it. The compiler cannot help; sliced pointers and the underlying buffer are tracked separately, and there is no flow analysis tying them together. Code that returns a slice from a function whose local owns the backing storage is a classic dangling-slice bug.

  • Auto-free (section 2). Slices behave like the &T borrows discussed in section 2’s “Owning vs borrowing in the type system.” A slice into a local []T is bounded by the local’s lifetime: the compiler can statically detect a slice escaping into a longer-lived slot (struct field, global, container of slices) and emit a diagnostic, because slice provenance is the same syntactic check the escape rules already perform.

  • Move + auto-free. Same provenance check as auto-free. Additionally, moving the source buffer is a hazard: a move invalidates any outstanding slice into it. Pure move semantics doesn’t catch this without a borrow checker. The mitigation is the slice escape rule where slices, like &T, cannot escape their derivation scope. This should prevent the most common cases but does not catch every reordering.

  • Generational references. A slice can carry the expected generation of the buffer’s allocation header. When the backing buffer is freed (or reallocated, which bumps the generation of the old header), the next slice access panics with a clear use-after-free diagnostic. This is the single largest safety win of gen refs and addresses Challenge 5 (pointer to an element in a dynamic array) directly. The cost is that slices grow from 16 bytes to 24 bytes to carry the expected generation.

  • Generational references with refcount. Slice carrying refcount keeps the backing buffer alive while the slice exists. No panic, no dangle, but allocations live longer than the visible scope of the underlying owner. Trade-off: more memory pressure, no surprises.

  • Hybrid. Slices are gen-checked the same way as in pure gen refs. The 24-byte representation applies. Owning pointers stay 8 bytes; only the slice path pays the size cost.

  • Compiler-managed refcount. Slice handling depends on whether the compiler is conservative or aggressive. Conservative: every slice extends the backing buffer’s refcount. Aggressive: prove the slice doesn’t escape and elide the bump.

Reallocation hazard. When a []T grows past its current capacity, the backing buffer is reallocated and the old buffer is freed. Any slice into the old buffer is now invalid. The models differ sharply here:

ModelBehavior on grow-after-slice
Manual + deferSilent dangling pointer; UB on next slice access
Auto-freeSame; alias tracking doesn’t extend to interior pointers
Move + auto-freeSame without a borrow checker
Generational refsPanic with diagnostic on next slice access
Gen refs + refcountSlice keeps old buffer alive; reads stale data silently
HybridPanic with diagnostic
Compiler-managed RCDepends on analysis; usually keeps old buffer alive (stale data)

Gen-ref models are the only ones that catch this at all without a full borrow checker. This is the central argument for adding gen refs as a runtime safety layer on top of whichever ownership model is chosen at compile time.

Allocation failure: alloc(), try, else

The current spec specifies that alloc(T) returns !*T. The fallible result must be handled using try, ! suffix notation, or else:

var p1 = alloc(MyStruct)!                         // panic on OOM
var p2 = try alloc(MyStruct)                      // auto-propagate to caller's !T
var p3 = alloc(MyStruct) else return .OutOfMemory // manual propagation

The memory model interacts with allocation failure at one specific point: partial initialization. A function that does multiple allocations to build up a structure must handle the case where the second allocation fails after the first succeeded.

Per-model behavior:

  • Manual + defer. Each successful alloc needs a paired defer free, and the defer must be registered before anything fallible runs:

    func build() !*World {
        var w = alloc(World)!
        defer if !success { free(w) }
        var p = try alloc(Player)
        w.player = p
        success = true
        return w
    }

    This is the C/Zig/Odin pattern and is famously error-prone: a missing defer, a misplaced flag, or a refactor that reorders calls is enough to introduce a leak.

  • Auto-free and Move + auto-free. Frees fire automatically on the error return path. The first alloc produces an owning binding; if the second try propagates an error, the first binding is freed on the way out because it’s still Live at the early-return point. The user writes:

    func build() !*World {
        var w = alloc(World)!                  // frees on error
        w.player = try alloc(Player)
        return w                               // moves out; no free fires
    }

    This is the ergonomic answer for partial-init failure and is, by itself, a strong argument for auto-free.

  • Generational references. OOM is orthogonal to gen tracking; same as manual. The user still needs explicit cleanup unless gen refs are paired with auto-free.

  • Hybrid. Owning side gets the auto-free benefit; gen-checked references are unaffected (they don’t own).

Interior mutability

Rust requires Cell, RefCell, and Mutex because its borrow checker enforces “either one mutable reference or many immutable references, never both.” Code that needs aliased mutation has to opt out into a runtime-checked wrapper.

Conjure’s &T is mutable by default and does not enforce aliasing-xor-mutation. Multiple &T references to the same allocation can coexist and all mutate. This is the C / C# / Go default and matches the audience expectation.

The consequence is that Conjure does not need a Cell/RefCell analog. Shared mutable state works without ceremony in every model. Publisher/subscriber, caches, observer lists, mutable graphs all work with naked references.

The cost is that Conjure cannot statically prevent iterator invalidation (mutating a container while iterating it) or two-pointer interference (passing the same buffer in twice to a function that assumes them distinct). These are bugs that Rust catches at compile time and Conjure does not. The mitigation is debug-mode runtime checks (iterator generation counters, debug-only aliasing assertions on restrict-style boundaries), and gen refs at the model level give a strong runtime safety net against the most damaging variant (use-after-realloc).

This is a deliberate trade and matches the stated audience. The game and engine programmers we interviewed state that they do not want to fight a borrow checker for the few cases where aliasing actually matters.

Cache-line and header overhead

Models that add per-allocation metadata pay a price both in memory and in cache behavior.

ModelPer-allocation overhead
Manual + defer0 bytes (just malloc’s own overhead, typically 16-24)
Auto-free0 bytes
Move + auto-free0 bytes
Generational refs8 bytes (gen counter)
Gen refs + refcount8 bytes (gen + rc packed into u32+u32)
Hybrid8 bytes for gen-checked allocs; 0 for owning
Compiler-managed RC8 bytes (rc + sentinel)

Pointer width. Gen-ref-based models also widen the pointer itself from 8 to 16 bytes (target + expected gen). For owning pointers in the hybrid model, this is avoided: only the gen-checked subset pays.

Cache impact for small allocations. If a struct is 16 bytes and the header is 8, the cache line fetched for a deref includes 50% header data. For larger structs (game-scale Entity types often 64-256 bytes), the header is amortized.

Mitigations. Arenas with single-header-per-arena designs (one gen counter for the whole arena, accessed via a handle-plus-arena-id pattern) eliminate per-object headers entirely. This is one of the strongest reasons to make arenas first-class: they allow the safety guarantees of gen refs at the memory cost of section 1.

Allocator alignment. Adding an 8-byte header in front of each allocation can disturb the alignment of the allocation itself. The allocator must place the header at (aligned_addr - 8), which works for power-of-two alignments up to 8 but requires extra padding for SIMD-aligned (16/32/64) types. Standard library allocators must handle this transparently.

Leak detection guarantees

ModelCaught at compile timeCaught at runtime (debug)Not caught
Manual + deferNoneAllocation tracking via debug instrumentationAnything not traced
Auto-freeForgotten free (impossible)Cycle leaks via debug GC sweepNone
Move + auto-freeSameSameNone
Generational refsNone directlyAllocation trackingForgotten frees
Gen refs + refcountForgotten free (impossible)Cycle leaks via debug detectorCycles in release
HybridOwning side: forgotten frees impossible. Gen-ref side: same as gen refsCycle detection on RC subsetMixed-model leaks
Compiler-managed RCForgotten free (impossible)Cycle leaks via debug detectorCycles in release

The shared theme: every model except manual eliminates “forgot to free” as a class. The remaining leak class is cycles, which only the refcount-based models suffer from natively. Auto-free and move models leak only through @nofree escapes or recursive types the user didn’t break manually.

Debug builds across all models should ship with allocation tracking (each alloc records its source location, each free clears it, scope-exit reports anything still tracked) as standard infrastructure. This is the kind of feature that’s cheap to add and pays back across the entire user base regardless of which model is chosen.

Generics and monomorphization

Conjure aims for fast compile times. Generics interact with the memory model through free generation: each instantiation of a generic over a heap-bearing type needs its own synthesized free function.

Cost analysis:

  • Manual + defer. No free generation; nothing to monomorphize.
  • Auto-free and Move + auto-free. Per-instantiation free is small (one function per heap-bearing type). For containers, the per-element free is generated. For the container itself, the compiler can share a type-erased core (the spec notes that []T uses a type-erased runtime with a typed wrapper).
  • Generational references. No per-instantiation cost: gen check applies to any *T uniformly. This is one of the model’s underappreciated advantages.
  • Refcount variants. Refcount ops are type-uniform; insertion does not require monomorphization. Elision analysis runs per function and uses the type’s heap-bearing classification, which is a single bit per type.
  • Hybrid. Free generation per type; gen check is type-uniform.
  • Compiler-managed RC. Same as refcount variants.

Practical compile-time impact. The dominant cost is free generation for nested generic containers (e.g. Map[string, []*Entity]). The synthesized free walks the type structure recursively; for deep generics this can produce hundreds of small functions. Two mitigations apply:

  1. Free dedup. Frees with identical structure (modulo type names) can be deduplicated by the linker, much like vtables in C++. The compiler emits the same function body for []string and []Path if Path is a distinct alias of string.
  2. Type-erased core + typed shell. The spec already commits to this for []T. The same pattern can apply to Map, Set, and other generic containers in the standard library, keeping monomorphization confined to small typed wrappers.

A generic constraint system (when Conjure adds one) does not need a Drop trait analog: free is structural, derived from field types, and emitted automatically. This keeps the user-facing generics surface smaller than Rust’s.

Observability and tooling

The runtime overhead of any model is only as predictable as the user’s ability to see it. This matters most for the models with the most invisible mechanism (compiler-managed RC, hybrid, gen refs), and across all models the same set of tools applies.

Debug build instrumentation:

  • Per-allocation source location and stack trace, dumped on leak.
  • Use-after-free poisoning (debug fills with 0xDE or similar).
  • Iterator generation counters on dynamic arrays (catches mutate-during-iterate).
  • Sanitizer-compatible memory tracking (ASan, MSan) or built-in equivalent solution for debug builds.
  • Gen-ref free-site capture: on a runtime panic, point at both the access site and the original free location.

Profile-guided builds:

  • Refcount op counts per function (compiler-managed RC and gen + RC).
  • Gen check counts per function with hot-path callouts.
  • Escape analysis report: which allocations were promoted to stack, which were classified as escaping.
  • Free elision report: which scope-exit frees were eliminated by alias tracking or move analysis.

LSP integration:

  • Hover tooltip on a type shows the POD/heap-bearing/pinned classification.
  • Inline hints on var declarations show whether the binding will move, free, or refcount on assignment.
  • Highlight where the compiler inserts frees at scope exit (similar to how Rust Analyzer surfaces type inference inline).

IDE diagnostics for invisible mechanism:

  • Compiler-managed RC: highlight residual refcount ops the analysis could not elide, so the user can see exactly where the cost lives.
  • Hybrid: distinguish owning pointer assignments from gen-checked reference assignments inline, since both spell *T in source.
  • Gen refs: show the size of the gen header in the type’s hover tooltip.

The unifying principle: every model that does invisible work needs a visible counterpart in the tooling. Compiler-managed RC is the model that depends on this most; without it, the user cannot reason about performance. Manual + defer needs the least tooling because the model itself is visible. The tooling investment scales inversely with model transparency.

Arenas: an orthogonal concern

Arenas (also called regions or pools) are not a memory model in the same sense as the seven options above. They’re an allocation strategy that composes with any of them. The core idea: instead of allocating individual objects, you allocate a large slab of memory once (an “arena”) and then bump-allocate inside it. When you’re done with the entire arena, you free it all at once with a single call. Cleanup is one operation regardless of how many objects the arena contains.

This pattern is heavily used in game engines, compilers, and request-handling systems where lifetimes are tied to natural boundaries (one frame, one compilation unit, one HTTP request). It composes well with all of Conjure’s memory model options because the question of “how does the language track ownership” is mostly orthogonal to “where do allocations actually live.” A manual-defer codebase can use arenas to avoid per-object free() calls. An auto-free codebase can use arenas to short-circuit the free machinery for entire subgraphs. A gen-refs codebase can use arenas to amortize the gen-check headers (one header per arena, not per allocation).

Conjure’s design has always pushed users gently toward this pattern as convention. The arena/pool/ECS shape avoids most of the bugs that any individual memory model is trying to catch: if all entities in a level live in an arena that’s freed at level-end, use-after-free is impossible by construction, not because the type system caught it. If players live in an ECS pool and are referenced by stable handle (an index plus generation), there’s no dangling-pointer question to answer.

What “first-class arenas” would mean for Conjure: a built-in Arena type in the standard library with intrinsic support from the compiler. Allocations made inside an arena scope would bypass whatever memory model is otherwise active and use bump allocation instead. The arena’s free would release everything at once. Conjure-specific extras could include: compile-time annotations to require certain operations happen inside an arena (e.g. parsing functions), debug-build tracking of which arena each pointer came from, and idiomatic helpers for the common arena-per-frame and arena-per-request patterns.

Arenas don’t replace any of the seven models but they meaningfully reduce how often the model itself matters. In a codebase where 80% of allocations live in arenas, the choice between auto-free, move semantics, or gen refs only matters for the remaining 20%.

Conjure (composes with section 1) conjure
func processFrame(frame &Arena) {
  var entities = frame.alloc([]Entity, 1024)
  var collisions = frame.alloc([]Pair, 256)

  spawnEntities(frame, entities)
  detectCollisions(entities, collisions)
  render(entities)

  // No free() calls anywhere.
  // Caller will free the entire
  // arena in one operation.
}

func main() {
  var frame = Arena.new(16 * 1024 * 1024)
  defer free(frame)

  while running {
      processFrame(&frame)
      frame.reset()  // reuse the arena
                     // for next frame
  }
}
C (equivalent) c
typedef struct {
  char*  base;
  size_t cap;
  size_t used;
} Arena;

static void* arena_alloc(Arena* a, size_t n) {
  void* p = a->base + a->used;
  a->used += n;  /* + alignment */
  return p;
}

static void arena_reset(Arena* a) {
  a->used = 0;
}

void processFrame(Arena* frame) {
  Entity* entities = arena_alloc(frame,
      1024 * sizeof(Entity));
  Pair* collisions = arena_alloc(frame,
      256 * sizeof(Pair));

  spawnEntities(frame, entities);
  detectCollisions(entities, collisions);
  render(entities);
}

int main(void) {
  Arena frame = {
      .base = malloc(16 * 1024 * 1024),
      .cap  = 16 * 1024 * 1024,
      .used = 0,
  };

  while (running) {
      processFrame(&frame);
      arena_reset(&frame);
  }

  free(frame.base);
  return 0;
}
  • Eliminates most per-object cleanup. Lifetimes follow natural program boundaries.
  • Allocation is bump-pointer: as fast as the CPU can do it.
  • Composes with any of the seven models. Arenas are an allocation strategy, not a replacement.
  • Cache-friendly: objects allocated together stay close in memory.
  • Easy to reason about: arena free is one operation, no ordering questions.
  • Resets are free: reusing an arena for the next frame costs one integer write.
  • Doesn’t fit data with mixed lifetimes (some short-lived, some long-lived within the same scope).
  • Worst-case memory use is the high-water mark; not great for memory-constrained environments.
  • Cross-arena references need a separate ownership story that usually involves handles plus a registry.
  • Doesn’t help with logical correctness since a pointer into a freed arena is still a dangling pointer.
  • Requires API discipline: functions need to take an arena explicitly so callers control allocation lifetime.

Other models considered

A few additional approaches were evaluated and currently look like a poor fit, but are worth knowing about. Both to make the comparison space explicit and because some elements of these models could be selectively borrowed even if the whole approach isn’t adopted.

Full borrow checking (Rust). Discussed in detail in the section 3 aside. The short version: the engineering cost of lifetime annotations and refactor-induced borrow-check failures may outweigh the safety benefit for game and engine code, especially when a runtime check (gen refs) catches the same bugs at runtime for a few percent overhead. Rust already exists for users who want this; building a “lite” version is hard to justify against the cost of just learning Rust.

Linear types (Austral, Idris). A stricter form of move semantics where every owning value must be used exactly once with no implicit frees at scope exit. This catches resource leaks at compile time (you can’t accidentally fail to close a file or release a lock by simply freeing its handle). The cost is ceremony: every owning value’s flow must be explicit, often requiring helper functions just to discard things. The discipline is genuinely interesting for systems programming, but asking users to track “every owning value is used exactly once, by name” is a significant cognitive tax. Parts of this idea (compiler-enforced resource handling) may be worth borrowing in narrower contexts like file handles even if the whole linear-types model isn’t adopted.

Mutable value semantics (Hylo / Val). No user-visible pointer types at all; the compiler handles all storage based on inout, sink, let, and set parameter modes. Functions describe how they use parameters; the compiler decides whether to copy, move, or borrow under the hood. This produces some of the cleanest possible function signatures for a low-level language and avoids whole categories of pointer-related bugs by removing pointers from the surface syntax. The tradeoff is that users give up the pointer-thinking that game and engine developers often depend on for performance reasoning, and the resulting code looks substantially different from C/C++. Hylo is doing important research here; for Conjure’s stated direction of staying recognizable to C-style programmers, the mental model shift may be too aggressive. That said, feedback from users coming from other backgrounds might change that calculation.

Reference capabilities (Pony). Pony classifies every reference with a capability: iso (isolated, unique), trn (transition), ref (mutable, thread-local), val (immutable, sharable), box (read-only view), and tag (opaque identity, no read/write). The capability is part of the type, and the compiler uses it to prove that no two threads simultaneously hold mutable access to the same data. The result is data-race freedom at compile time without locks or atomics in user code. Mapped to Conjure syntax this might look like:

struct Player { name string, health i32 }

func handle(p iso *Player) { ... }     // owns exclusively, can move to another thread
func observe(p val *Player) { ... }    // immutable, freely sharable across threads
func touch(p ref *Player) { ... }      // mutable, thread-local only

The strength is that it makes the concurrency story above (Send/Sync, atomic refcount) into a type-system question rather than a runtime question. The cost is significant: six capabilities, capability-recovery rules, and viewpoint adaptation for nested types. This is the right answer for an actor-style language but is heavier ceremony than Conjure’s target audience expects. Worth borrowing one specific idea: a single iso capability could be added to mark “this owning pointer is exclusively held and safe to move across threads,” which is a much smaller delta than the full Pony system.

Opportunistic refcount with uniqueness (Roc). Roc’s runtime uses refcounting but the compiler does aggressive uniqueness analysis: when it proves at a call site that a value is unique (refcount == 1), it mutates in place; otherwise it copies. The mental model is “everything is immutable and value-typed; the compiler decides whether that requires copying.” This is a different optimization target from Lobster’s elision: instead of removing refcount ops entirely, Roc keeps the count and uses it to choose between mutate-in-place and copy-on-modify at runtime. For Conjure this would manifest as a compiler optimization on top of section 5 or 7 rather than a separate model: the existing refcount header would carry one extra bit (or the count itself would serve), and operations that currently allocate on modify would mutate in place when the count permits. The trade-off is the same as section 7: invisible behavior change on refactor, but with the additional benefit that the cost manifests as fewer copies rather than fewer refcount ops.

Region-based types (Cone, Verona). Both languages treat regions as a type rather than a runtime concept. Every allocation lives in a region, and the region is part of the value’s type. References can only cross region boundaries with explicit conversions. This is the type-system version of arenas: instead of arenas being a library convention that the user opts into, they are a structural property of every type. Mapped to Conjure:

region Frame { ... }
region Persistent { ... }

func process(input &Entity in Persistent) *Entity in Frame {
    // returned pointer lives in the Frame region; cannot escape into Persistent
}

Verona adds a concurrency story on top: each region is owned by a single thread at a time, and cross-region access goes through a scheduler. This is where the model gets compelling for game engines: per-frame regions, per-worker regions, and persistent regions could all coexist with compile-time guarantees that pointers don’t cross dangerously. The cost is that the type system grows substantially: every pointer carries a region tag, every function signature describes which regions it touches, and the user has to learn region annotations.

A lighter-weight version of this is already discussed under “Arenas: an orthogonal concern” below. The difference is purely whether the discipline is at the type level (Cone/Verona) or the library level (Conjure’s current direction). Cone and Verona are doing the research worth watching here; promoting arenas to a first-class type concept later is always an option if the library-level discipline proves insufficient.

Hardware-assisted memory safety (ARM MTE, CHERI). Modern hardware can implement the gen-ref runtime check at the instruction level. ARM’s Memory Tagging Extension (MTE) puts a 4-bit tag in each pointer and a matching 4-bit tag on every 16-byte memory chunk; a load or store with a mismatched tag traps. CHERI capabilities go further: each pointer carries an architecturally-protected (base, length, permissions) triple that hardware checks on every dereference. Either feature could replace or augment the gen-ref runtime check:

  • On MTE-capable hardware, the per-allocation header collapses to zero bytes (the tag lives in the pointer’s high bits and in the memory map, not as a header in the allocation). The cost shifts from per-deref software check to per-deref hardware check, which is essentially free on capable cores.
  • On CHERI-capable hardware (currently rare outside research platforms), bounds checking becomes architectural, eliminating Challenge 5 (pointer to an element in a dynamic array) at the instruction level.

Conjure could be designed to opt into these features at the codegen layer when targeting compatible hardware, without changing the source-level memory model. The gen-ref instrumentation pass described in section 7 of the compiler mechanisms becomes a no-op on MTE-targeted builds, and the runtime cost frees to zero. This is a “future-proofing” consideration rather than a primary decision driver, but it argues mildly in favor of the gen-ref family: those models become free on hardware that’s increasingly common.

Garbage collection (Go, Java, C#). Conjure’s stated principles include “optimal and predictable performance at runtime,” which is the standard argument against a tracing GC for game and engine code. Frame-time spikes from GC pauses are exactly what game developers move away from C# to avoid. There is no version of the seven models above that pairs well with a tracing GC: each one assumes deterministic cleanup at known points, while a GC defers cleanup to opaque heuristics. This is not a debate Conjure intends to reopen; the entire memory model space considered here excludes tracing collection by design.

That said, in recent years we have seen improvements around garbage collection strategies and implementations that reduce pause times and make GC more palatable for certain workloads. If the GC landscape evolves to the point where a tracing collector can meet Conjure’s performance goals, it would be worth revisiting this question.

Some of these newer techniques include:

  • Generational GC. Objects are allocated in a “young” generation that’s collected frequently, while long-lived objects are promoted to an “old” generation that’s collected less often. This can reduce pause times for workloads with many short-lived objects, which is common in game code.
  • Concurrent GC. The GC runs concurrently with the application, reducing pause times by doing most of the work in parallel. This is more complex to implement but can provide smoother performance.
  • Incremental GC. The GC work is broken into small increments that can be interleaved with application execution, further reducing pause times.
  • Region-based GC. Objects are allocated in regions that can be collected independently, allowing for more fine-grained control over memory management.

Common Challenges

Each memory model handles certain patterns more easily than others. Here are seven patterns that highlight the differences, each shown across all seven memory model options.

Challenge 1: Cleaning up after early return

A function allocates a buffer, then needs to return early on an error. The cleanup must happen for every exit path.

Manual + defer
func parse(path string) !Result {
  var buf = alloc(64)!
  defer free(buf)

  if !load(buf, path) {
      return ParseErr
  }
  return processData(buf)
}
Auto-free
func parse(path string) !Result {
  var buf = alloc(64)!

  if !load(buf, path) {
      return ParseErr
  }
  return processData(buf)
}
Move + auto-free
func parse(path string) !Result {
  var buf = alloc(64)!

  if !load(buf, path) {
      return ParseErr
  }
  return processData(buf)
}
Gen refs
func parse(path string) !Result {
  var buf = alloc(64)!
  defer free(buf)

  if !load(buf, path) {
      return ParseErr
  }
  return processData(buf)
}
Gen refs + refcount
func parse(path string) !Result {
  var buf = alloc(64)!

  if !load(buf, path) {
      return ParseErr
  }
  return processData(buf)
}
Compiler-managed refcount
func parse(path string) !Result {
  var buf = &Buffer{ cap = 64 }

  if !load(buf, path) {
      return ParseErr
  }
  return processData(buf)
}
Hybrid
func parse(path string) !Result {
  var buf = alloc(64)!

  if !load(buf, path) {
      return ParseErr
  }
  return processData(buf)
}
scroll to compare techniques →

Challenge 2: Doubly-linked list with back pointers

Each node needs to point to its previous and next neighbors. Pure ownership is impossible because back-pointers can’t own.

Manual + defer
struct Node {
  value i32
  next  ?*Node
  prev  ?*Node
}
Auto-free
struct Node {
  value i32
  next  ?*Node
  prev  ?*Node
}
Move + auto-free
struct Node {
  value i32
  next  ?*Node
  prev  ?weak[Node]
}
Gen refs
struct Node {
  value i32
  next  ?*Node
  prev  ?*Node
}
Gen refs + refcount
struct Node {
  value i32
  next  ?*Node
  prev  ?*Node
}
Compiler-managed refcount
struct Node {
  value i32
  next  ?*Node
  prev  ?*Node
}
Hybrid
struct Node {
  value i32
  next  ?*Node
  prev  ?*Node
}
scroll to compare techniques →

Challenge 3: Function returning a pointer to a local

A factory function constructs a value and returns a pointer to it. The local must outlive the function call.

Manual + defer
func make() *Player {
  var p = alloc(Player{
      name = "Hero",
  })!
  return p
}
Auto-free
func make() *Player {
  var p = alloc(Player{
      name = "Hero",
  })!
  return p
}
Move + auto-free
func make() *Player {
  var p = alloc(Player{
      name = "Hero",
  })!
  return p
}
Gen refs
func make() *Player {
  var p = &Player{
      name = "Hero",
  }
  return p
}
Gen refs + refcount
func make() *Player {
  var p = &Player{
      name = "Hero",
  }
  return p
}
Compiler-managed refcount
func make() *Player {
  var p = &Player{
      name = "Hero",
  }
  return p
}
Hybrid
func make() *Player {
  var p = alloc(Player{
      name = "Hero",
  })!
  return p
}
scroll to compare techniques →

Challenge 4: Storing one pointer in two places

A pointer is stored persistently in one location, but also kept locally for further use.

Manual + defer
var p = alloc(Player{...})!
defer free(p)
world.players.append(p)
p.health = 100
Auto-free
var p = alloc(Player{...})!
world.players.append(p)
p.health = 100
Move + auto-free
var p = alloc(Player{...})!
world.players.append(p)
// p.health = 100
// ^ COMPILE ERROR: p was moved
Gen refs
var p = alloc(Player{...})!
world.players.append(p)
free(p)
// world's ref panics on
// next access (gen mismatch)
Gen refs + refcount
var p = alloc(Player{...})!
world.players.append(p)
p.health = 100
Compiler-managed refcount
var p = &Player{...}
world.players.append(p)
p.health = 100
Hybrid
var p = alloc(Player{...})!
world.players.append(p)
// p.health = 100
// ^ COMPILE ERROR: p was moved
scroll to compare techniques →

Challenge 5: Pointer to an element in a dynamic array

Taking the address of an element, then potentially growing the array.

Manual + defer
var items = []Player{...}
var p = &items[0]

items.append(newPlayer)
println(p.name)
Auto-free
var items = []Player{...}
var p = &items[0]

items.append(newPlayer)
println(p.name)
Move + auto-free
var items = []Player{...}
var p = &items[0]

items.append(newPlayer)
println(p.name)
Gen refs
var items = []Player{...}
var p = &items[0]

items.append(newPlayer)
println(p.name)
Gen refs + refcount
var items = []Player{...}
var p = &items[0]

items.append(newPlayer)
println(p.name)
Compiler-managed refcount
var items = []Player{...}
var p = &items[0]

items.append(newPlayer)
println(p.name)
Hybrid
var items = []Player{...}
var p = &items[0]

items.append(newPlayer)
println(p.name)
scroll to compare techniques →

Challenge 6: A graph with shared nodes

A Vertex holds a list of edges to other vertices. Multiple vertices may reference the same neighbor; cleanup must release each vertex exactly once.

Manual + defer
struct Vertex {
  id    u32
  edges []*Vertex
}

// teardown requires a separate
// "visited" set to avoid double-
// freeing shared vertices.
Auto-free
struct Vertex {
  id    u32
  edges []*Vertex
}
Move + auto-free
struct Vertex {
  id    u32
  edges []weak[Vertex]
}
// store vertices in a separate
// pool; edges are weak refs.
Gen refs
struct Vertex {
  id    u32
  edges []*Vertex
}
// natural syntax; runtime gen
// checks handle shared edges.
Gen refs + refcount
struct Vertex {
  id    u32
  edges []*Vertex
}
Compiler-managed refcount
struct Vertex {
  id    u32
  edges []*Vertex
}
Hybrid
struct Vertex {
  id    u32
  edges []*Vertex
}
// vertices are owned by a pool;
// edges are gen-checked refs.
scroll to compare techniques →

Challenge 7: A callback that captures data

A function registers a callback that needs access to some captured state. The state must outlive the registration; the callback may fire many times.

Manual + defer
func register(state *State) {
  var cb = makeCallback(state)
  events.subscribe(cb)
  // who owns state now?
  // who calls free(state)?
}
Auto-free
func register(state *State) {
  var cb = makeCallback(state)
  events.subscribe(cb)
  // state auto-frees at function
  // return -> callback now holds
  // a dangling pointer.
}
Move + auto-free
func register(state State) {
  var cb = makeCallback(state)
  events.subscribe(move(cb))
  // state moved into closure;
  // closure moved into subscribe.
}
Gen refs
func register(state *State) {
  var cb = makeCallback(state)
  events.subscribe(cb)
  // gen ref to state; if state
  // is freed, callback panics
  // safely on next invocation.
}
Gen refs + refcount
func register(state *State) {
  var cb = makeCallback(state)
  events.subscribe(cb)
  // refcount keeps state alive
  // as long as the callback
  // (and any others) exist.
}
Compiler-managed refcount
func register(state *State) {
  var cb = makeCallback(state)
  events.subscribe(cb)
}
Hybrid
func register(state *State) {
  var cb = makeCallback(state)
  events.subscribe(cb)
  // closure captures a gen ref;
  // state ownership is unchanged.
}
scroll to compare techniques →

Summary

TechniqueUAF DetectionCleanupEst Runtime CostLearning Curve
Manual + deferNoneManualZeroLow (C-like)
Auto-freeNoneAutomaticZeroLow
Move + auto-freeCompile-timeAutomaticZeroMedium
Gen refsRuntime panicManual or auto3-8%Low
Gen refs + refcountRuntime panicAutomatic5-15% (variable)Low-medium
HybridMostly compile-timeAutomatic3-8% on shared refsMedium
Compiler-managed refcountRuntime (rare)AutomaticVariable (often <5%)Low

Arenas compose with any of the above and reduce how often the model itself matters.

Game engine patterns at a glance

The stated audience writes engines, tools, and games. The patterns below recur across most of those codebases. This table shows which model each pattern fits naturally and which models need workarounds.

PatternBest fitAcceptableNeeds workaround
Frame arena (per-frame scratch)All (arenas are orthogonal)AllNone
ECS storage + handle/generationManual, Auto-free, MoveGen refs (redundant gen)Refcount (rc adds nothing)
Asset hot-reload (mutate pointee in place)Manual, Auto-free, Gen refsMoveRefcount (releases old when count hits 0; new must reuse slot)
Scene graph with shared subtreesHybrid, Gen refs, Gen + RCCompiler RCManual (cleanup is hand-rolled)
Job system with parallel workersMove + auto-free (Send tracking)Hybrid, Auto-freeRefcount needs atomics (10x op cost)
Streaming asset loaderMove + auto-free, HybridGen refsManual (lifetime juggling across threads)
Observer / event busGen refs, Gen + RC, Compiler RCHybridMove (needs weak ref or restructure)
Doubly-linked list (UI tree, intrusive list)Gen refs, HybridGen + RCMove (needs weak), Auto-free (cycles)
Replay buffer / ring of recordsArena + anyAllNone
Save game serialization graphArena + ManualAuto-free, MoveRefcount (cycles in saved state)

Two patterns are worth pointing out:

ECS plus stable handles. The standard high-performance engine pattern (entity = (index, generation) into a pool, components stored in dense arrays) bypasses the memory model question for entity lifetimes. The model only matters for what’s stored inside components and for systems that allocate transient data. Manual and auto-free are both fine here; gen refs effectively duplicate the generation check the ECS already does.

Asset hot-reload. Replacing the data behind a pointer (a texture, mesh, or shader being reloaded from disk) is a pattern refcount-based models handle poorly: bumping a refcount doesn’t help when the content changes, and the old version may still be referenced by in-flight render commands. The natural design is a handle table where the handle is stable and the table entry is mutated, which composes best with manual or auto-free and is independent of the model’s safety guarantees.

Perceived compiler complexity

Each memory model places different demands on the compiler. The table below estimates the relative complexity of each major subsystem that the compiler would need to implement. “None” means the subsystem is not required at all. “Low” through “High” reflects implementation effort, ongoing maintenance burden, and the surface area for compiler bugs.

SubsystemManualAuto-freeMoveGen refsGen + RCHybridCompiler RC
Scope trackingLowMediumMediumMediumMediumMediumMedium
Free generationNoneMediumMediumMediumMediumMediumMedium
Move analysisNoneNoneMediumNoneNoneMediumNone
Escape analysisNoneNoneNoneMediumMediumMediumHigh
Pointer instrumentationNoneNoneNoneMediumMediumMediumLow
Refcount elisionNoneNoneNoneNoneLowNoneHigh
Diagnostic qualityLowLowMediumMediumMediumHighLow
Runtime supportNoneNoneNoneLowMediumLowMedium

Key takeaways:

  • Manual + defer is by far the simplest compiler to build. The only non-trivial piece is defer scope tracking, but that was already solved and implemented in previous Conjure prototypes.
  • Auto-free adds recursive free generation, which is moderate work but well understood (C++ compilers have done this for decades).
  • Move + auto-free layers per-binding dataflow analysis on top of auto-free. The analysis itself is local (no inter-procedural lifetime solving), but it touches every assignment and function call in the program.
  • Gen refs shift complexity from compile-time analysis to runtime instrumentation. The compiler is simpler than move tracking, but the runtime support (alloc headers, gen-check insertion, diagnostic formatting) is new infrastructure.
  • Compiler-managed refcount has the highest total complexity because escape analysis must be both correct and aggressive to deliver on the “most refcount ops are elided” promise. If the analysis is conservative, the runtime cost goes up. If it is aggressive but wrong, the program crashes. This is the only model where compiler quality directly determines runtime performance characteristics.
  • The hybrid model combines move analysis and gen-check instrumentation, making it the widest in surface area, but each piece is individually moderate. The main challenge is diagnostic quality: explaining to the user why a pointer is owning in one context and gen-checked in another requires clear, context-sensitive error messages.

The choice depends on what’s being optimized for. Each row above is the right answer for some real use case; none is universally best. Feedback from developers planning to build games or engines in Conjure is what will ultimately decide the direction here.

Compiler mechanisms in detail

The seven options place demands on different parts of the compiler pipeline. This section walks through each mechanism the compiler would need, sketches a rough implementation, estimates its complexity, and notes whether the work fits more naturally on the AST during semantic analysis or on a linear SSA IR as a later pass.

A general rule emerges from this breakdown. Anything that depends on the type structure of a value (which fields exist, what the field types are, whether something is heap-bearing) is easier on the AST because that information is local to each declaration node and is already being computed by the type checker. Anything that depends on the flow of values through control flow (when a value is alive, where it escapes, whether two bindings alias the same allocation) is easier on a linear SSA IR because the control flow graph and def-use chains are made explicit by the representation, and standard dataflow algorithms apply directly.

Diagnostics tend to straddle both stages. Errors are detected during analysis (often on the IR), but the messages refer to source positions on the AST. Source locations must therefore be preserved across lowering so that diagnostics can reach back to the user’s original code.

1. Scope tracking

Purpose. Identify the points at which scopes begin and end so that cleanup operations can be emitted at the right boundary. Required by defer in the manual model, and by every model that has any automatic cleanup.

Complexity. Low. A stack of scopes, each holding the bindings declared in it and (for the manual model) the registered defers. Each block entry pushes; each exit pops.

Stage. AST during semantic analysis. Block boundaries are explicit syntactic nodes and the type checker is already walking them. Linear IRs frequently flatten scope information away, so doing this work later requires reconstructing what was already known.

Outline.

ScopeStack: stack of Scope
struct Scope { bindings: []BindingId; defers: []ExprId }

enterBlock():    scopes.push(Scope{})
declareBinding(name, type) -> BindingId:
    bid = ...
    scopes.top.bindings.append(bid)
    return bid
emitDefer(expr): scopes.top.defers.append(expr)
exitBlock():
    s = scopes.pop()
    for expr in reverse(s.defers):  emitCall(expr)
    for bid  in reverse(s.bindings): maybeEmitDrop(bid)

2. plain-old-data vs heap-bearing classification

Purpose. Determine whether a type holds heap-owned resources (and therefore participates in alias, move, or refcount machinery) or copies freely as a value. Required by auto-free, move + auto-free, hybrid, and compiler-managed refcount.

Complexity. Low. A single bit per type, computed recursively from the type’s fields. Cycles in the definition graph are handled with the standard “mark as in-progress, return an optimistic answer, fix up afterward” pattern.

Stage. AST during semantic analysis. The classification depends entirely on the structure of the type, which is known at definition time. This means you could even surface the classification to the LSP via the hover tooltip to help users understand how the compiler sees their types.

Outline.

classify(T):
    if T in cache: return cache[T]
    if T is primitive, fixed-array of POD, or fully-POD struct: return POD
    if T is *T, &T-not-tracked, string, []T, Map: return HeapBearing
    if T is struct:
        cache[T] = POD                              // optimistic, allows recursion
        for field in T.fields:
            if classify(field.type) == HeapBearing:
                cache[T] = HeapBearing
                break
        return cache[T]

3. Free generation

Purpose. For each heap-bearing type, synthesize a function that releases the resources the value owns. The synthesized function calls the user’s overload free (if any), then (or?) recursively cleans up each heap-bearing field. Required by auto-free, move + auto-free, hybrid, and compiler-managed refcount.

Complexity. Medium. The recursion is straightforward; the corner cases are nullable pointers, dynamic arrays of heap-bearing elements, recursive types (where a free has to call itself indirectly), and generics whose free has to be specialized per instantiation. To be done effectively, this also relies on escape analysis to know when a free should be elided because “ownership” is transferring to another scope rather than actually being released.

Stage. AST during semantic analysis. The field list, field types, and pointer sigils are already on the AST. Emitting free bodies at this stage produces regular functions for later passes to optimize rather than inlined cleanup blocks that need re-recognition by IR-level passes.

Outline.

genDrop(T) -> FuncId:
    if cached(T): return cache[T]
    cache[T] = synthesize_placeholder(T)             // breaks recursive types
    f = newFunction("free_" + T.name, params=[*T])
    if T has overload free: f.emitCall(user_free, p)
    for field in T.fields:
        if field.sigil is borrowed or field is POD: continue
        switch field.type.kind:
            case Pointer:    f.emit("if field != null { genDrop(*field)(field); free(field) }")
            case String:     f.emit("cjr_str_free(&field)")
            case DynArray:   f.emit("for e in field { genDrop(elemT)(&e) }; cjr_arr_free(&field)")
            case Struct:     f.emit("genDrop(field.type)(&field)")
    cache[T] = f
    return f

4. Move analysis

Purpose. Track per-binding Live / Moved / ConditionallyMoved state across the function body so that use-after-move is a compile error and only the current owner emits the free. Required by move + auto-free and the hybrid model.

Complexity. Medium. State per binding is simple; the cost is that states must merge correctly across branches and converge across loops. Partial moves (consuming one field of a struct without consuming the rest) add a sub-binding state space.

Stage. Linear SSA IR. Move analysis is a forward dataflow problem and benefits directly from an explicit CFG. On the AST the control flow is implicit in the tree walk, so the analysis has to thread state through every if/while/match recursively, which is the same work in a less natural shape. Standard worklist algorithms, basic blocks, and predecessor sets are the right vocabulary for both the analysis and its diagnostics.

Outline.

state: map[(BasicBlock, BindingId)] -> {Live, Moved, CondMoved}

for each block in CFG (reverse postorder, fixpoint):
    in_state = merge(state[end of each predecessor])
    for each instr in block:
        match instr:
            Move(src -> dst):  in_state[src] = Moved; in_state[dst] = Live
            Use(b):            if in_state[b] != Live: emitDiagnostic(b, instr.loc)
            Free(b):           if in_state[b] == Live: emit free; in_state[b] = Moved
    state[block.end] = in_state

merge rule: Live + Moved = CondMoved   // requires a hidden free flag at runtime

5. Alias tracking

Purpose. Group bindings that currently point at the same allocation into an alias set so that free responsibility transfers from the whole set to whatever escape sink consumes it, without the user ever seeing a move. Required by auto-free (section 2).

Complexity. Medium-high. Union-find over bindings keeps the data structure simple. The hard parts are merging alias sets at control-flow joins and tracking conditional escapes (where the same allocation escapes on one branch and not the other), which require runtime free flags.

Stage. Linear SSA IR. Aliasing is a flow-sensitive value-flow property, and SSA’s def-use chains are exactly the data the analysis needs. Doing it on the AST is possible but requires reconstructing those chains by walking the tree.

Outline.

sets: union-find over BindingIds
owner: map[AllocId -> SetRepresentative | External]

on "var dst = src" (heap-bearing):           sets.union(dst, src)
on "f(arg)" where arg is *T:                 owner[sets.find(arg).alloc] = External
on field store / container append (*T sink): owner[sets.find(v).alloc]   = External
on branch merge:                             reconcile sets per predecessor (conditional free flags)
on scope exit, for each scope-local alloc A:
    if owner[A] == this scope's set rep: emit free once for A
    else:                                 no free (escaped)

6. Escape analysis

Purpose. For each allocation, decide whether it outlives the function that produced it. Drives stack-vs-heap promotion (gen refs, compiler-managed refcount), free elision, and refcount elision. Required by gen refs and every model built on top of it.

Complexity. Medium for the local conservative form, high for the aggressive inter-procedural version that compiler-managed refcount needs to actually deliver on its performance promise.

Stage. Linear SSA IR. Escape analysis is a traversal over def-use chains: an allocation escapes if any SSA value derived from it reaches a non-local sink. SSA gives this directly; on the AST those chains have to be built by hand.

Outline.

for each allocation A in the function:
    escapes[A] = false
    worklist = [A]
    while worklist:
        v = worklist.pop()
        for each use u of v:
            match u:
                StoreToField(struct_ptr, _, v): if classify(struct_ptr.target) != local: escapes[A] = true
                StoreToGlobal(_, v):            escapes[A] = true
                Return(v):                      escapes[A] = true
                Call(f, args containing v):     if !inter_procedural_summary(f).keeps_arg_local: escapes[A] = true
                Copy(v -> w):                   worklist.push(w)
                LocalLoad/Use:                  continue
    if !escapes[A]:
        promote A to stack, elide refcount ops, free locally on scope exit

7. Pointer instrumentation (generational checks)

Purpose. Add allocation headers, insert gen-check sequences before each dereference, and bump the generation on free. Required by gen refs, gen refs + refcount, and hybrid.

Complexity. Medium. The rewriting itself is mechanical. The substantive work is the runtime support (header layout, attaching the free site to each generation bump for diagnostics) and the elision pass that removes provably-unnecessary checks.

Stage. Linear SSA IR. Instrumentation is a pure lowering: every load through a *T becomes a check-then-load sequence; every &T{...} becomes a header allocation followed by the body allocation and a fat-pointer initialization. Running this as an IR pass lets the same optimizations that operate on the rest of the program (constant folding, redundant-check elimination, loop-invariant code motion) apply to the inserted checks. Doing this work at the AST level both produces less optimization-friendly output and conflates rewriting with type checking.

Outline.

lowering pass:
    match instr:
        Alloc(T):
            h = malloc(sizeof(Header) + sizeof(T))
            h.gen = next_gen()
            replace with FatPointer{target: h + sizeof(Header), gen: h.gen}
        Load(p, T):
            if p in elision_set: emit raw load
            else: emit "if header(p).gen != p.gen: panic('use-after-free', site=this.loc, freed_at=header(p).free_site)"
                   emit raw load
        Free(p):
            emit "header(p).free_site = this.loc; header(p).gen = bump(header(p).gen); free(header(p))"

separate elision pass:
    within a basic block, the second check on the same pointer is redundant if no
        intervening Free of that pointer occurs
    a loop-invariant pointer's check can be hoisted to the loop preheader

8. Refcount insertion and elision

Purpose. Emit rc_inc at every pointer copy and rc_dec at every free, then prove which pairs are balanced and remove them. Required by gen refs + refcount and (especially) compiler-managed refcount.

Complexity. Insertion is low. Elision is high, and it is the central engineering challenge of compiler-managed refcount. Aggressive elision is what determines whether the model performs competitively or pays refcount cost on every pointer assignment.

Stage. Linear SSA IR. Both insertion and elision are flow-sensitive, and SSA’s def-use information is what the elision pass needs to identify balanced regions. Lobster’s reference implementation and the Swift compiler’s ARC optimizer both operate on linear IRs for this reason.

Outline.

insertion pass:
    for each instr:
        Copy(src -> dst) where src is heap-bearing pointer:
            insert "rc_inc(src)" before
        ScopeEnd binding b where b is heap-bearing:
            insert "rc_dec(b); if header(b).rc == 0: free(b)"

elision pass:
    for each rc_inc(p) ... rc_dec(p) pair within a scope with no intervening external
        store or external call: remove both
    if escape analysis proves p never escapes the function:
        remove every rc_inc and rc_dec on p
    fold consecutive inc/dec on the same pointer along straight-line paths

9. Diagnostic generation

Purpose. Turn errors detected by the above analyses into messages the user can act on. Required by every model that catches errors at compile time (move, hybrid) and by gen-ref models for runtime panics.

Complexity. High for move semantics and the hybrid model. Pointing at the prior move is the easy part; explaining why a control-flow merge produced a ConditionallyMoved state, or why a pointer that looks owning is being treated as a gen-checked reference in the hybrid model, requires the diagnostic to walk back through analysis state and produce a narrative the user can follow.

Stage. Both. The error condition is detected by an IR-level analysis pass, but the diagnostic format references source positions and the user’s original syntax. Source locations must therefore be threaded through lowering so that every IR instruction carries the AST node it originated from.

Outline.

struct Diagnostic {
    primary_span:  SourceSpan
    related_spans: []SourceSpan
    message:       string
    suggestion:    ?string
}

// move-after-move case
on Use(b) where state[b] == Moved:
    earlier_move = find_first_move_site_on_paths_into(this_block, b)
    emit Diagnostic{
        primary_span:  this.loc,
        related_spans: [earlier_move.loc],
        message:       "binding `{b.name}` was moved here and cannot be used again",
        suggestion:    if classify(b.type) == POD: "consider declaring `{b.type}` as a value type"
                       else: "clone the value before the move, or restructure to avoid the second use",
    }

Summary by model

The choice of memory model is, mechanically, a choice of which subset of these passes to build:

MechanismManualAuto-freeMoveGen refsGen+RCHybridCompiler RC
Scope trackingxxxxxxx
POD classificationxxxx
Free generationxxxx
Move analysisxx
Alias trackingx
Escape analysisxxxx (aggressive)
Pointer instrumentationxxx
Refcount insertionxx
Refcount elisionx (light)x (aggressive)
Diagnosticslowlowhighmediummediumhighlow

The AST/IR split is fairly clean. Scope tracking, POD classification, and free generation belong on the AST during semantic analysis where type structure is rich and syntactic boundaries are explicit. Move analysis, alias tracking, escape analysis, pointer instrumentation, and refcount work belong on a linear SSA IR where control flow and data flow are explicit and standard dataflow algorithms apply. Diagnostics span both stages, since the error is detected on the IR but the message refers to AST source positions that must be preserved through lowering.

This division is one of the practical reasons a clean multi-pass architecture matters. The AST does what it is good at (structural, type-directed code generation), the SSA IR does what it is good at (flow-sensitive analysis and instrumentation), and each pass operates on the representation that makes its job easiest.