mem: allocator init returns state by value (drops state-struct heap alloc)
Building on the Option 3 lvalue-borrow rule, the long-lived allocators
in `library/modules/allocators.sx` (GPA, Arena, TrackingAllocator) now
return their state by value instead of via a heap-allocated `*T`. The
caller binds the result to a local; the local IS the allocator state.
`xx local` borrows that storage under Option 3, so the `Allocator`
protocol value's `ctx` points at the local — no heap allocation for
the state struct, no `free` of the state needed.
```sx
gpa := GPA.init(); // GPA (value)
arena := Arena.init(xx gpa, 4096); // Arena (value)
tracker := TrackingAllocator.init(xx gpa); // TrackingAllocator (value)
push Context.{ allocator = xx tracker, data = null } { ... }
```
Why by-value:
- One fewer `libc_malloc` per allocator instance.
- No state-struct leak. The local is reclaimed at scope exit; `deinit`
only handles downstream resources (chunks, etc.) — not its own struct.
- Owning structs can embed allocators as value fields directly.
Callsite changes:
- `library/modules/ui/pipeline.sx`: `arena_a: Arena;` / `arena_b:
Arena;` (was `*Arena;`). The `build_arena: *Arena` local takes
`@self.arena_a` / `@self.arena_b`.
- `examples/126-xx-recover-then-dispatch.sx`: `recovered == @gpa`
instead of `recovered == gpa` (gpa is a value now).
- `examples/135-xx-lvalue-borrows.sx`: drop the `tracker_ptr.*`
deref — `init` already returns the value.
- `examples/50-smoke.sx`: Arena alloc counts dropped by 1 (no
state-struct allocation). Comments + snapshot updated.
`Arena.deinit` drops the trailing `parent.dealloc(xx a)` — the
caller's local owns the storage.
FFI IR snapshots regenerated to reflect the new signatures:
`@GPA.init` returns `i64` (was `ptr`); `@Arena.init` and
`@TrackingAllocator.init` use sret returns (was `ptr`).
CLAUDE.md "Allocator construction" rule rewritten around the
by-value convention. The forbidden caller-provides-storage and
redundant-pointer-rename patterns are still forbidden but for the
right reasons now (verbose, fragile) rather than as a workaround
for the old `init() -> *T` shape.
157/157 example tests pass; chess clean on macOS, iOS sim, and
Android via `tools/verify-step.sh`.
This commit is contained in:
109
CLAUDE.md
109
CLAUDE.md
@@ -164,59 +164,84 @@ beats `else => unreachable` beats `else => /* hope */`.
|
||||
|
||||
### Allocator construction
|
||||
|
||||
❌ **Forbidden:** the "caller provides storage" pattern (in any form):
|
||||
✅ **Required shape:** `init` returns the concrete state **by value**.
|
||||
The caller binds it to a local (or embeds it in a struct field); that
|
||||
local IS the allocator's storage. `xx local` borrows the local's
|
||||
address into the `Allocator` protocol value — no heap allocation for
|
||||
the state struct, no `free` of the state needed, no caller-provides-
|
||||
storage ceremony at the call site.
|
||||
|
||||
```sx
|
||||
// NEVER write this — explicit @ptr:
|
||||
g_gpa : GPA = ---;
|
||||
alloc := GPA.create(@g_gpa);
|
||||
|
||||
// NEVER write this — UFCS-disguised same pattern:
|
||||
gpa_state : GPA = .{ alloc_count = 0 };
|
||||
gpa := gpa_state.create();
|
||||
|
||||
// NEVER write this — in-place init on a struct field:
|
||||
self.arena_a.create(parent, size);
|
||||
```
|
||||
|
||||
❌ **Also forbidden:** wrapping an `init` result through a cast just
|
||||
to bind a typed pointer you already have:
|
||||
|
||||
```sx
|
||||
// NEVER write this — tracker is already *TrackingAllocator:
|
||||
tracker := TrackingAllocator.init(context.allocator);
|
||||
t : *TrackingAllocator = xx tracker; // redundant rename
|
||||
t.report();
|
||||
```
|
||||
|
||||
✅ **Required:** `init` returns the concrete typed pointer (`*T`);
|
||||
caller casts `xx ptr` to `Allocator` only at use sites that need the
|
||||
protocol value.
|
||||
|
||||
```sx
|
||||
gpa := GPA.init(); // *GPA
|
||||
arena := Arena.init(xx gpa, 4096); // *Arena ; xx gpa → Allocator for parent
|
||||
tracker := TrackingAllocator.init(context.allocator); // *TrackingAllocator
|
||||
gpa := GPA.init(); // GPA (value, stack-local)
|
||||
arena := Arena.init(xx gpa, 4096); // Arena (value)
|
||||
tracker := TrackingAllocator.init(xx gpa); // TrackingAllocator (value)
|
||||
|
||||
push Context.{ allocator = xx tracker, data = null } { ... }
|
||||
|
||||
print("gpa allocs: {}\n", gpa.alloc_count); // direct field access
|
||||
tracker.report(); // direct method call
|
||||
arena.reset(); // direct method call
|
||||
arena.deinit(); // frees the chunks; the
|
||||
// Arena struct itself
|
||||
// goes away with the local
|
||||
```
|
||||
|
||||
The rule exists because:
|
||||
- `create` returning `Allocator` forces an `instance()` accessor or a
|
||||
cast-back to recover the typed pointer (extra step every time).
|
||||
- Caller-storage patterns are verbose, error-prone (easy to pass the
|
||||
wrong @ptr), and an artifact of an earlier allocator design.
|
||||
- `init` returning `*T` matches Zig conventions and lets the caller
|
||||
decide where/how to cast to `Allocator`.
|
||||
Why by-value:
|
||||
|
||||
See `current/CHECKPOINT-MEM.md` ISSUE-MEM-005 for the migration
|
||||
history. If an existing allocator type still uses the old `create`
|
||||
pattern, migrate it OR ask the user — never propagate the pattern
|
||||
in new code, docstrings, examples, or tests.
|
||||
- No state-struct leak. The local is reclaimed when its scope ends; no
|
||||
explicit `deinit` is needed to free the struct (chunks/buffers the
|
||||
allocator manages downstream still need cleanup — that's orthogonal).
|
||||
- One fewer `libc_malloc` per allocator instance.
|
||||
- Composition stays clean: a struct that owns an allocator embeds it
|
||||
directly (`arena_a: Arena;`) rather than holding a pointer (`arena_a:
|
||||
*Arena;`). The owning struct's heap-alloc covers it.
|
||||
- `xx local` is borrow-mode under sx's protocol-erasure rule (see
|
||||
`specs.md §3` — Ownership and Lifetime). Mutations through the
|
||||
protocol are visible to the local.
|
||||
|
||||
❌ **Forbidden:** the *manual* "caller provides storage" pattern,
|
||||
because it pushes raw-struct construction at the user. This is a
|
||||
different shape from the value-return rule above — the user writes
|
||||
out the type, declares uninitialised state, and invokes a separate
|
||||
`create`/`init_in_place` that mutates it. Verbose, fragile, easy to
|
||||
forget the init step:
|
||||
|
||||
```sx
|
||||
// NEVER write this — explicit @ptr:
|
||||
g_gpa : GPA = ---;
|
||||
GPA.create(@g_gpa);
|
||||
|
||||
// NEVER write this — UFCS-disguised same pattern:
|
||||
gpa_state : GPA = .{ alloc_count = 0 };
|
||||
gpa_state.create();
|
||||
|
||||
// NEVER write this — in-place init on a struct field:
|
||||
self.arena_a.create(parent, size);
|
||||
```
|
||||
|
||||
The value-return pattern subsumes these use cases without the
|
||||
gotcha: `gpa := GPA.init();` already gives the caller a local; if
|
||||
they want the storage in a struct field, `self.arena_a =
|
||||
Arena.init(parent, size);` works directly.
|
||||
|
||||
❌ **Also forbidden:** wrapping an `init` result through a cast just
|
||||
to bind a "typed pointer" you don't actually need (it's a value now):
|
||||
|
||||
```sx
|
||||
// NEVER write this — tracker is already a TrackingAllocator value:
|
||||
tracker := TrackingAllocator.init(xx gpa);
|
||||
t : *TrackingAllocator = xx @tracker; // redundant rename
|
||||
t.report();
|
||||
```
|
||||
|
||||
Call methods directly on the local — `tracker.report();` works via
|
||||
UFCS auto-address-of, no manual pointer juggling required.
|
||||
|
||||
When migrating an existing allocator from the old `init() -> *T`
|
||||
shape to the new `init() -> T`, also drop the trailing
|
||||
`parent.dealloc(xx a)` from any `deinit` — the caller's local owns
|
||||
the storage now, deinit only frees downstream resources (chunks,
|
||||
counters' backing, etc.).
|
||||
|
||||
### Long-lived containers growing through `context.allocator`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user