Product stance: ClassyC collections follow a C++ vector / RAII model, not C# GC reference-type List.
- The container is an owning value (stack or
ownedheap) that owns a heap buffer. - Elements are stored inline (by value) or as pointers (
T*+ optional.owns()). - Transfer with
move; bare assign ofList/Map/Setis an error. - Domain / identity objects stay
List<T*>.owns(). - Small DTOs / POD stay
List<T>by value.
This file is the implementer’s plan: what works, memory rules, remaining phases, file touch points, tests, and code samples.
Related: GENERICSMEM.md, include/list.h, include/map.h, cy-validate/val-038-*.cy, val-039-*.cy.
class LapSample {
int lap;
int ms;
int faction;
LapSample(int lap, int ms, int faction) {
this.lap = lap;
this.ms = ms;
this.faction = faction;
}
// Prefer no user dtor, or only “quiet” counting / no free of shared bits.
// String fields are OK (arena). Do not free unique malloc in ~T if List
// will bitwise-copy you.
};
int main() {
auto laps = List<LapSample>(); // stack List, owns buffer
laps.Add(LapSample(1, 59840, 0)); // prvalue → buffer (P0)
laps.Add(LapSample(2, 60112, 1));
owned auto quick = laps.Where((LapSample s) => s.ms < 62000);
// Where returns heap List*; non-owning of nothing extra for POD copies
for (auto s in laps)
printf("L%d %d\n", s.lap, s.ms);
// ~quick (owned) → delete Where-result list
// ~laps → __destroy each LapSample + free(buffer)
}Brace-init on heap list:
owned auto xs = new List<LapSample>{
LapSample(1, 59840, 0),
LapSample(2, 60112, 1)
};class Pilot {
int id;
String callsign;
Pilot(int id, String callsign) {
this.id = id;
this.callsign = callsign;
}
~Pilot() { printf("~Pilot %s\n", callsign); }
};
int main() {
auto grid = List<Pilot*>().owns(); // stack List; owns pointees
grid.Add(new Pilot(1, "AURORA"));
grid.Add(new Pilot(2, "HEXFIRE"));
for (auto p in grid)
p.Banner();
// ~grid: delete each Pilot*, then free pointer buffer
}Non-owning view (same pointees, one owner):
owned auto aces = grid.Where((Pilot* p) => p.IsAce());
// delete aces → frees List shell only; Pilots still owned by gridauto a = List<int>();
a.Add(1);
a.Add(2);
auto b = List<int>();
b = move a; // buffer ownership → b; a zeroed
// a.Count() == 0
// b owns data
auto c = move b; // transfer again
// ~c frees once; ~b / ~a no-op on bufferIllegal (and should stay illegal):
auto a = List<int>();
auto b = List<int>();
b = a; // ERROR: shallow copy would double-free
List<int> c = a; // ERROR: copy-init same problemExplicit second list object:
owned auto clone = a.Copy(); // new heap List + bitwise element copies
// caller owns cloneList<int>* make_scores() {
owned auto xs = new List<int>();
xs.Add(10);
xs.Add(20);
return move xs; // ownership leaves function
}
int main() {
owned auto scores = make_scores();
scores.Add(30);
} // delete scores → ~Listauto board = Map<String, int>();
board.Set("AURORA", 2140);
board["HEXFIRE"] = 1912;
auto by_id = Map<int, Pilot*>().ownsValues();
by_id.Set(1, new Pilot(1, "AURORA"));
// ~by_id: delete values if ownsValues; free tables
auto tags = Set<String>();
tags.Add("nova");
// String content hash/eq already special-cased// auto xs = List<LapSample>(); xs.Add(...); xs.Add(...);
stack frame:
xs { data*, length, capacity, _owns_ptrs }
│
▼
heap: [ LapSample | LapSample | ... empty slots ... ]
↑ inline values (sizeof(LapSample) each)
// auto grid = List<Pilot*>().owns(); grid.Add(new Pilot(...));
stack:
grid { data*, length, ... _owns_ptrs=1 }
│
▼
heap: [ Pilot* | Pilot* | ... ]
│ │
▼ ▼
heap Pilot heap Pilot
Scope exit LIFO (typical):
~owned results first (Where/Copy lists)
~grid: for i: delete data[i]; free(data)
~xs: for i: __destroy(data[i]); free(data)
| Feature | Where / notes |
|---|---|
By-value List<T> buffer + ABI |
compiler + list.h |
__destroy on dtor / Clear / Set / RemoveAt |
list.h, map.h, set.h |
is_pointer<T> + .owns() |
compiler intrinsic + headers |
Stack List<T> a; / auto a = List<T>() |
RAII in create_decl + auto inference for ClassName() |
| Move-only List/Map/Set assign | class_is_move_only_collection_p, move class path |
ClassName(args) prvalue construction |
check/gen N_CALL when callee is class |
Brace-init new List<Pt>{ Pt(...), ... } |
uses prvalue path + Add protocol |
| Tests | val-038-list-stack-byval.cy, val-039-brace-init-move.cy, val-015-*, examples/test-list-byval.cy |
| P0 done — prvalue temp dtors at consuming call | class_prvalue_call_p + gen_class_temp_dtor in classyc.c; val-056 |
P1 done — by-value element gate + [[copyable_no_release]] |
parse-time class registry + gate in get_or_create_specialization; val-056 |
| P5 done — C-array element dtors at scope exit | create_decl RAII for TM_ARR of dtor-class (constant bound); val-056 |
// Approximate names / roles — search for these when coding:
find_class_dtor_def(c2m_ctx, class_def);
class_has_dtor_p(c2m_ctx, type);
class_is_move_only_collection_p(c2m_ctx, type);
// true for __generic_List_*, __generic_Map_*, __generic_Set_* with dtorClass prvalue path (sketch of current behavior):
// check N_CALL: callee is N_ID naming a class
// → type = TM_CLASS, builtin_call_p, def_node = ctor
// → update_call_arg_area_offset for temp slot
// gen N_CALL: class prvalue
// → stack slot in call-arg area
// → memset 0, call __ctor_T(this, args)
// → yield MIR_OP_MEM of aggregateGap (FIXED): temp is not always registered for end-of-full-expression ~T after the value has been copied into a list (or after a call). POD is fine; nontrivial ~T is the bug class for P0.
Resolution (compiler-validated): Add(T(...)) / brace-init were already balanced (the container adopts the temp's bits and destroys it once). The real leaks were by-value call arguments (take(Box(7))) and prvalue method receivers (Pt(1,2).getX()) — fixed by emitting ~T for the temp right after the consuming call, except (a) adopting protocol methods (Add/Set/Insert/Push/Enqueue on a move-only collection — the container owns it) and (b) calls returning a pointer (result may alias the temp — kept alive, conservative).
Status: P0, P1, P2 and the P5 array item are landed (val-056 + gate diagnostics; suite 58/58 green). Remaining: P3 (optional clone polish), P5 leftovers (bare
T(...);statement temps, ctor-arg prvalues).
What validation showed: the doc's original worry (
Add(T(...))temp leaks) did not reproduce — Add/brace-init were already balanced. The actual leaks, confirmed with strict ctor/dtor probes (sketch/probe-p0-prvalue-lifetime.cy,probe-p0b-arg-leak.cy), were:take(Box(7))prvalue call args andBox(9).getId()prvalue receivers. Both now destroy the temp exactly once after the consuming call (val-056). Remaining known edges (documented, low value): a bareBox(7);expression-statement temp still leaks; class-prvalue args to a ctor of another prvalue (Box(Box2(1))) are not destroyed; calls returning pointers intentionally keep the temp alive.
T(...) as a temporary is either:
- Relocated into destination storage (list buffer, param, field) and not double-destroyed, or
- Destroyed exactly once at end of full-expression.
C++-ish rule (simplified): construct temp → use (copy/relocate into owner) → destroy temp if still “alive”.
Construct-in-place into the destination slot when the argument is a pure prvalue:
// Ideal lowering for: xs.Add(LapSample(1, 2, 0));
// 1. EnsureCapacity
// 2. Construct LapSample directly at &xs.data[xs.length] (placement ctor)
// 3. length++
// No separate temp → no temp dtor problemIf placement into data[i] is hard in today’s MIR path, fallback:
// 1. ClassName(args) → stack temp (existing)
// 2. bitwise relocate temp → data[length]
// 3. memset temp 0 (moved-from: ~T is no-op if T only frees owned pointers)
// 4. OR register ~T on temp at full-expression end WITHOUT zeroing if we deep-copiedFor List’s bitwise model, moved-from zeroing matches container move and is simpler than full-expr dtor lists for every Add.
void take(LapSample s); // by value: owns its own copy in param slot
take(LapSample(1, 2, 0));
// After call returns, the prvalue temp (if any) must not leak.
// Param slot is destroyed at end of take() — that’s the callee’s stack.Gen already has:
VARR (node_t) * defer_stmts;
gen_run_defers(c2m_ctx, from);
// N_BLOCK / N_RETURN / break / continue unwind defer_stmtsPossible approach:
// At gen of class prvalue N_CALL (when type has user dtor):
// after constructing temp at slot S:
// synthesize a dtor call node that takes address of S
// push onto a "full_expr_temps" stack tied to current statement
//
// At end of gen for N_EXPR statement (expression-statement):
// run full_expr_temps LIFO (destroy temps)
//
// When temp is known moved-from (zeroed into list):
// do not push dtor (or push no-op)Find expression-statement gen (N_EXPR) and block boundaries.
| File | Work |
|---|---|
src/classyc.c |
prvalue gen: moved-from vs full-expr dtor; optional place-into-Add |
include/list.h |
no API change unless special Add path |
cy-validate/val-040-class-prvalue-lifetime.cy |
new |
int ctors = 0, dtors = 0;
class Box {
int id;
Box(int id) { this.id = id; ctors++; }
~Box() { dtors++; }
int getId() { return id; }
};
void take(Box b) {
check(b.getId() == 7, "param received");
}
int main() {
// Named stack: 1 ctor, 1 dtor at scope end
{
Box a = Box(1);
check(ctors == 1, "named ctor");
}
check(dtors == 1, "named dtor at scope exit");
// Prvalue arg: ctor for temp/param; dtor when param dies (and temp if separate)
int d0 = dtors;
take(Box(7));
check(dtors > d0, "take(Box(7)) ran dtor(s)");
// List stores copies; each Add should not leak temps
d0 = dtors;
int c0 = ctors;
{
auto xs = List<Box>();
xs.Add(Box(10));
xs.Add(Box(20));
check(xs.Count() == 2, "list has 2");
// At least the two elements die with xs; temps must not leave leftover alive
}
check(dtors - d0 >= 2, "list elements destroyed");
// Optional stricter: (dtors - d0) == (ctors - c0) // exact match once P0 done
return failed;
}-
Add(T(...))with counting dtor: no leak of temps; no double-free (was already balanced — verified) - Brace-init
new List<T>{ T(...), T(...) }same (verified) -
take(T(...))destroys (fixed — temp dtor after the call) -
val-038/val-039still pass -
examples/test-list-byval.cystill pass
Shipped as an error with the
[[copyable_no_release]]opt-out, as designed below. Implementation notes: the gate lives inget_or_create_specialization(single choke point forList/Set/Map) and resolves element classes through a parse-time class registry (parsed_classes/copyable_no_release_classesVARRs inparse_ctx) because class symbols only enter check-time scopes after specialization runs. Map checks bothKandV; pointer elements and nested__generic_*collections are exempt. Counting-dtor test/example classes were annotated:val-015(Tag/Item/Key),val-038/val-039(Pt),val-052(Ship),examples/test-list-byval.cy(Track),examples/classy-aurora-ops.cy(Ship),examples/classy-space-trader-2000.cy(Planet/Outpost/Trader). Regression coverage:val-056(positive) +sketch/probe-p1-relocate-gate.cy(negative — must not compile). The original double-free repro (WhereonList<Owns>→ SIGABRT,sketch/probe-p1b-transform-doublefree.cy) is now rejected at compile time.
Refuse (or hard-warn) List<T> / Map values/keys / Set<T> by value when T is not safe to bitwise relocate.
List internals always do the moral equivalent of:
data[j] = data[j - gap]; // Sort / Insert / EnsureCapacity
T item = data[i]; // Get / WhereIf ~T frees a unique resource still aliased in two slots → double-free.
| Kind | Rule | Examples |
|---|---|---|
| Trivial / safe | no user dtor, or only scalars + String |
int, LapSample POD, Pt with quiet ~ optional |
| Attribute opt-in | [[copyable_no_release]] |
intentional POD-with-dtor-for-counting |
| Unsafe by value | user dtor frees unique resource, or owns raw ptr | FileHandle, Buffer with free in ~ |
// At List<T> / Set<T> / Map K,V specialization or first method check:
if (element is TM_CLASS
&& class_has_dtor_p(T)
&& !is_copyable_no_release(T)
&& !is_move_only_collection(T)) { // don't confuse List itself
error(...,
"type '%s' has a destructor and is not marked [[copyable_no_release]]; "
"List/Set/Map store elements by bitwise copy, so each copy would run ~T. "
"Use List<%s*>.owns(), or mark [[copyable_no_release]] if the destructor "
"does not release unique resources (counting/log-only)",
name, name);
}Attribute (parser + type flag):
[[copyable_no_release]]
class Probe {
int id;
~Probe() { /* counting only — no free of shared bits */ }
};// class_is_safe_byvalue_element_p(type):
// !class_has_dtor_p(type) → true
// else if name has attribute flag → true
// else → false // or warn-only first weekShip error once attribute exists; warning is OK for first PR if you want less breakage for counting-dtor test types (Track in test-list-byval).
Counting-dtor tests should use:
[[copyable_no_release]]
class Track {
int id;
~Track() { count++; }
};| File | Work |
|---|---|
src/classyc.c |
parse attribute; class_is_safe_byvalue_element_p; gate in specialization or List method check |
include/list.h |
comment on allowed T |
cy-validate/val-041-byvalue-element-gate.cy |
new |
// Positive: POD
class Pod { int x; Pod(int x) { this.x = x; } };
// List<Pod> compiles
// Positive: attribute
[[copyable_no_release]]
class Counted {
int x;
Counted(int x) { this.x = x; }
~Counted() { /* count++ */ }
};
// List<Counted> compiles
// Negative (expect compile error):
// class Owns {
// char* p;
// Owns() { p = (char*)malloc(8); }
// ~Owns() { free(p); }
// };
// List<Owns>* bad = new List<Owns>(); // ERROR
//
// Fix:
// List<Owns*>* ok = new List<Owns*>().owns();For automated suite: keep negative case in a separate file run with “expect fail”, or only document until runner supports expected-fail.
- Non-relocatable + dtor as List element → diagnostic
- POD /
String/ pointer elements unchanged - Existing byval tests annotated
[[copyable_no_release]]if they have counting dtors - Map/Set share the same helper
// ── Values / DTOs → by-value List (vector<T>) ─────────────────────────
auto samples = List<LapSample>();
samples.Add(LapSample(1, 59840, 0));
// ── Identity / resources → owning pointers (vector<unique_ptr<T>>) ────
auto people = List<Pilot*>().owns();
people.Add(new Pilot(1, "AURORA"));
// ── Transfer container ────────────────────────────────────────────────
auto other = move samples;
// ── Explicit second container ─────────────────────────────────────────
owned auto clone = people.Copy(); // new list; still one Pilot owner = people
// ── Heap container when you need List* ────────────────────────────────
owned auto heap = new List<int>();| Write this | Owns buffer? | Owns pointees? | Cleanup |
|---|---|---|---|
auto xs = List<T>() |
yes | values in buffer | ~List |
auto xs = List<T*>() |
yes | no | free buffer only |
auto xs = List<T*>().owns() |
yes | yes | delete each + free buffer |
owned auto xs = new List<T>() |
yes | as above | auto delete xs |
b = move a |
transferred | transferred | one owner |
xs.Copy() / Where / … |
new list | never copy .owns() |
caller frees list shell |
| Location | Change |
|---|---|
GENERICSMEM.md TL;DR |
Stop saying “classes are only by pointer”; dual path (DTO by value / domain by pointer) |
GENERICSMEM.md “value construction” |
Reflect stack List + move + prvalues |
examples/classy-neon-grid.cy |
Remove or rewrite caveats: brace-init works; enum-as-int may still be recommended; Sort+String mostly OK with arena |
README.md collections blurb |
Point at this file + GENERICSMEM |
include/list.h banner |
Keep move-only + brace samples (already partially done) |
- GENERICSMEM TL;DR matches dual path
- neon-grid comments not lying
- Cross-link: README →
BY-VALUE.md/ GENERICSMEM
Keep no silent deep assign. Optional:
// Already: List<T>* Copy();
// Optional alias:
// List<T>* Clone() { return this.Copy(); }
// Future sugar (language):
// auto b = copy a; // → a.Copy(), never shallowNot required for C++ architecture correctness.
Call the same class_is_safe_byvalue_element_p for:
Set<T>element typeMap<K,V>for bothKandVwhen class-by-value
Document: class keys = memcmp / byte == only; prefer int/String keys.
| Item | Notes |
|---|---|
arr[i] = T(...) on C arrays |
✅ elements now destroyed at scope exit (constant bound; VLAs exempt) — val-056 |
Nested ctor T(-p.getX(), …) |
✅ already works (verified with probe — struck) |
Bare T(...); expression-statement temps |
still leaks (low value) |
Class-prvalue arg to another prvalue's ctor (Box(Box2(1))) |
not destroyed (edge) |
ToJson on List<class> |
keep ToJsonArrayBy; no auto object schema required |
| Sort + element with String | arena makes it usually OK; don’t promise deep move semantics |
Do not implement unless product stance changes:
- Silent
b = adeep copy of List (surprising O(n), still not C# share). - Copy-on-write / refcounted List buffers (C#-like share).
- GC for collections.
- Full C++ rule-of-five / implicit copy ctor generation for all classes.
- By-value List of types that
freeunique resources in~Twithout relocation rules.
Day 1 morning P0: reproduce lifetime bug with strict dtor-count test
Day 1 P0: relocate-into-Add or full-expr temp dtor
Day 1 afternoon val-040 green; re-run val-038/039
Day 2 morning P1: attribute + class_is_safe_byvalue_element_p
Day 2 P1: gate at specialization; fix counting tests with attribute
Day 2 afternoon val-041; Map/Set same helper
Day 2/3 P2: GENERICSMEM + neon-grid + README cross-links
If only one day: P0 only — highest correctness value.
| Concern | Search / area |
|---|---|
| Class prvalue check | ClassName(args) value construction in N_CALL check |
| Class prvalue gen | same comment in gen N_CALL |
| Stack List RAII | create_decl RAII block, ctor_call / dtor_call |
| Move-only collections | class_is_move_only_collection_p, N_ASSIGN, N_MOVE |
| Defer / scope dtor | defer_stmts, gen_run_defers, N_BLOCK |
| Specialization | get_or_create_specialization |
| Protocol Add | find_class_protocol_method(..., "Add", 1) |
| Brace-init gen | N_NEW brace / object-initializer branch |
Build:
cmake --build . --target classyc -j$(nproc)
./bin/classyc -g -I include cy-validate/val-038-list-stack-byval.cy -eg
./bin/classyc -g -I include cy-validate/val-039-brace-init-move.cy -eg
# after P0:
./bin/classyc -g -I include cy-validate/val-040-class-prvalue-lifetime.cy -egDo not use git unless you choose to (per session norms).
Users can write:
// DTO path — no new per element, no delete, no double-free
auto laps = List<LapSample>();
laps.Add(LapSample(1, 59840, 0));
owned auto best = laps.Where((LapSample s) => s.ms < 62000);
// Domain path
auto grid = List<Pilot*>().owns();
grid.Add(new Pilot(1, "AURORA"));
// Transfer
auto other = move laps;
// Forbidden footguns caught by compiler
// b = a; // collection shallow assign
// List<OwnsMalloc> xs; // non-relocatable by-value element (P1)And dtor counts under prvalue Add are exact (P0).
| C++ | ClassyC |
|---|---|
std::vector<Lap> v; |
auto v = List<Lap>(); |
v.push_back(Lap{...}); |
v.Add(Lap(...)); |
std::move(v) |
move v |
std::vector<std::unique_ptr<P>> |
List<P*>().owns() |
std::vector<P*> non-owning |
List<P*>() |
| copy ctor / deleted copy | move-only + Copy() |
| RAII | ~List / owned / scope dtor |
-
Ban only collections, not every class with a dtor, for assign/copy-init — otherwise
List<Pt>element assigns inside Sort/Add explode. Useclass_is_move_only_collection_p(__generic_List_*etc.). -
rhs_nodeis not in scope at the sharedassign:gen label — useNL_EL(r->u.ops, 1)when checkingN_MOVERHS. -
Comment
List<T*>in block comments —*/insideT*>closes the C comment early. Spell out “T-star” or use line comments. -
MIR assert
collect_addr_usesshowed up when gen state was wrong (e.g. calling dtor helpers with baditempointers during assign). Guarddd->u.item != NULLbefore emitting dtor calls in gen. -
Auto + class value:
auto x = List<int>()works via class-name call detection in auto inference — keep that path when refactoring prvalues. -
Parse-time resolution for the P1 gate: class symbols enter check-time scopes only after
get_or_create_specializationruns, so the gate resolves element classes through a parse-time registry (parsed_classes/copyable_no_release_classesVARRs fed by the declaration parser), notfind_def. -
Every new
struct exprfield must be initialized increate_expr(reg_malloc is not zero-fill).hoist_call_pwas added without an initializer; garbage bits randomly marked plain calls as loop-invariant-pure, and the while back-edge then reused a stale pre-header value → infinite loop (gcc/20010129-1.c). See CLASSYC-FINDINGS.md §10.
P0 prvalue lifetime
[x] Strict dtor-count repro (sketch/probe-p0-*.cy)
[x] Relocate or full-expr destroy (temp dtor after consuming call)
[x] val-056 green (val-040/041 names were taken)
[x] val-038/039/test-list-byval green
P1 element gate
[x] class_is_safe_byvalue_element_p equivalent (parse-time registry + gate)
[x] Gate List/Set/Map specialization
[x] Annotate counting-dtor test types
[x] val-056 + negative probe (probe-p1 must not compile)
P2 docs
[x] GENERICSMEM TL;DR dual path (+ gate row/rule added)
[x] neon-grid caveats updated (comment now reflects enforcement)
[x] README link to BY-VALUE.md (Memory Management → .owns() section)
Last updated from the by-value / stack-List / move / brace-init workstream. Architecture: C++ vector + unique ownership, not C# GC List.