lang: xx <lvalue> borrows the operand's storage instead of heap-copying

`xx <struct-typed local>` used to heap-copy the value through context.allocator.
The protocol value's `ctx` pointed at the heap copy; the original local was
left behind, untouched. Mutations through the protocol never reached the
original, and direct reads of the original never saw protocol mutations.
Two-fork bug, silent, easy to write by mistake.

New rule (Option 3 in the discussion):

- `xx <lvalue>` — identifier, field access, index expression, deref —
  borrows the operand's storage. No heap copy, no `free` needed.
- `xx <rvalue>` — struct literal, function-call result, arithmetic, etc. —
  heap-copies through context.allocator. Unchanged from today.
- `xx @ptr` and `xx <pointer-typed value>` — borrows the pointee. Unchanged.

Single switch in `buildProtocolErasure` ([lower.zig:10334](src/ir/lower.zig#L10334))
gated by a new `isLvalueExpr` helper ([lower.zig:10322](src/ir/lower.zig#L10322)).
Struct-typed operand: if the AST shape is identifier/field/index/deref,
emit `lowerExprAsPtr(operand_node)` and skip the heap-copy; otherwise
keep the alloca-store-heap_copy path.

specs.md §3 ownership table extended to three rows (rvalue, lvalue,
pointer) with examples and rationale per row.

Regressions:

- `examples/130-xx-value-routes-through-context-allocator.sx` — the
  Phase 1.1 witness for heap-copy-via-context-allocator. Previous shape
  (`xx <local-value>`) is now a borrow under Option 3 and no longer
  exercises the heap-copy path. Rewritten to use a struct literal
  (`xx ByValue.{...}`) which still heap-copies through context.allocator
  — Tracer.count = 1 as before.
- `examples/135-xx-lvalue-borrows.sx` — new test. Dereferences a
  TrackingAllocator into a stack value, does `xx tracker` inside a
  push Context, and asserts alloc_count/dealloc_count on the LOCAL go
  up. Under old semantics this would have stayed at 0 (heap copy got
  the increments, local stayed stale).

157/157 example tests pass; chess clean on macOS / iOS sim / Android
(`tools/verify-step.sh` ran green immediately before this work).
This commit is contained in:
agra
2026-05-25 15:23:13 +03:00
parent 82e7b04cca
commit b710a0a42a
7 changed files with 135 additions and 23 deletions

View File

@@ -372,32 +372,50 @@ allocators : [2]Allocator = .[xx gpa, xx arena]; // protocol values in array
#### Ownership and Lifetime
Protocol values have two ownership modes depending on how they are created:
Protocol values have two ownership modes. The mode is selected by the
shape of the operand to `xx`:
| Conversion | `ctx` points to | Lifetime | Who frees |
|------------|----------------|----------|-----------|
| `xx value` | Heap-allocated copy | Until `free(p)` | Caller |
| `xx @ptr` | Original pointee | Tied to pointee | Caller manages pointee |
| Operand shape | `ctx` points to | Lifetime | Who frees |
|---|---|---|---|
| `xx <rvalue>` (struct literal, call result, etc.) | Heap-allocated copy | Until `free(p)` | Caller |
| `xx <lvalue>` (identifier, field, index, deref) | The named storage | Tied to that storage's scope | Caller manages the storage |
| `xx <pointer>` / `xx @ptr` | Original pointee | Tied to pointee | Caller manages pointee |
**`xx value`** — the concrete data is heap-copied so the protocol value is self-contained.
It can be stored in containers, returned from functions, and outlives the scope where it was created.
Call `free(p)` to release the backing memory when done:
**`xx <rvalue>`** — when the operand has no storage of its own (struct
literal, function-call result, arithmetic expression, etc.) the concrete
data is heap-copied through `context.allocator` so the protocol value is
self-contained. It can be stored in containers, returned from functions,
and outlives the scope where it was created. Call `free(p)` to release
the backing memory when done:
```sx
s : Sizable = xx Widget.{ value = 42 }; // heap-copies Widget
print("{}\n", s.size());
free(s); // frees the heap-allocated Widget copy
```
**`xx @ptr`** — the protocol borrows the pointer. The protocol value is only valid as long as
the pointee is alive. Mutations through the protocol are visible through the original pointer:
**`xx <lvalue>`** — when the operand names existing storage (a local
variable, struct field, array element, or dereferenced pointer) the
protocol borrows that storage directly. No heap copy, no allocation,
no `free` needed; mutations through the protocol are visible to the
original. The protocol value is only valid while the named storage is
alive:
```sx
w := Widget.{ value = 0 };
s : Sizable = xx @w; // borrows &w
s : Sizable = xx w; // borrows w's storage; no copy
s.add(5); // modifies w through ctx
print("{}\n", w.value); // 5
// do NOT free(s) — w owns the data
```
**`xx @ptr`** is equivalent to `xx <lvalue>` for the dereferenced
pointee — the protocol borrows. It's mostly redundant under the
lvalue rule above but stays valid for explicit clarity when the
operand is a pointer you want to make obvious is being borrowed:
```sx
w := Widget.{ value = 0 };
s : Sizable = xx @w; // identical to `xx w` — borrows w
```
**Vtables** are global constants — shared across all protocol values of the same `(Protocol, ConcreteType)` pair. They are never allocated or freed at runtime.
#### Default Methods