An implementation plan for making recursive types work — structurally (
[count: Int, next: & mut Foo]) exactly as nominally (class,distinct,distinct enum). Today they fail at an accidental cycle detection in the lowering. The style model: EnumImplementation.md, InterfaceStructural.md.
Recursion needs three things — separating them cleanly is the whole plan:
- A name that closes the loop. One cannot write an anonymous
recursive type — in order to refer to "itself", the field needs a
name (
next: & mut Foo). Recursive types therefore always run over a named alias. The alias is the anchor — nodistinctorclassnecessary. Structural types too are definable recursively this way. - Binding the knot (the identity cycle).
langtypes are interned (types.rs:129). Naive hash-consing of a self-reference fails, because one would need theTypeIdof the inner type before it exists. The resolution: reserve theTypeIdat the alias before lowering the body; a self-reference in the field then already finds it. That holds for structural and nominal RHSs alike. - Indirection (the size cycle). A type that contains itself as a value
is infinitely large — fundamental, no compiler limit. The resolution:
a reference
& T(→ C++T*) or a heap box (Box<T>, internally& mut T) at the back edge makes the size finite.
What goes wrong today: ensure_alias marks the
alias as InProgress before it lowers the body; every self-reference runs
into "type alias cycle involving X". This one check mixes identity
and size and blocks the legal case along with it. The plan separates them: binding the knot
(ingredient 2) allows the recursion, and a finiteness check of its own (ingredient 3)
reports only the real size cycle any more.
// Legal: STRUCTURALLY recursive. The alias name closes the loop,
// the reference makes the size finite. No `class`/`distinct` necessary.
type Foo = [count: Int, next: & mut Foo];
// Legal: mutual structural recursion.
type A = [b: & mut B];
type B = [a: & mut A];
// Legal: nominal (class) + heap indirection through Box.
type Tree = class [
value: Int,
left: Box<Tree>, // Box internally holds `& mut Tree` → finite
right: Box<Tree>,
];
// Legal: a structural recursive enum over a reference payload (copyable).
type List = enum [ Nil, Cons: [head: Int, tail: & List] ];
// AN ERROR (a size cycle): a value self-reference, infinitely large.
type Bad = [next: Bad];
// → "recursive type has infinite size; insert indirection
// (`& mut ...` or a heap `Box<...>`)"
The goal is real structural identity: type Foo = [count, next: & mut Foo]
and an identically shaped Bar should be the same type — and likewise two identically
shaped mutually recursive groups (A↔B = C↔D). An approach with
de Bruijn Self variables (Rec/SelfVar) solves only the self-recursion
canonically; for mutual recursion one would have to serialize the μ term, which is
more work than the procedure below. Hence the direct route:
Recursive types stay a cyclic TypeId graph (binding the knot with
concrete back TypeIds — no new TypeKind). The identity is canonicalized
at interning time: on closing a cycle, a
bisimulation collapse (partition refinement, Paige–Tarjan/Hopcroft-like) runs,
which merges structurally equivalent nodes into one canonical TypeId.
- One mechanism, no frontier. Self- and mutual recursion
are canonicalized uniformly — a mutual group is only a bigger
SCC, no special case.
A↔Band an identically shapedC↔Dcollapse automatically. - A standard procedure. That is the classical decision of equi-recursive structural type equality (Amadio–Cardelli): the type graph is a deterministic, edge-labelled automaton; equality = automaton equivalence, decidable in near-linear time.
- It fits the InternPool invariant. Canonicalization happens eagerly at interning;
afterwards "the same
TypeId⇔ the same type" is an integer comparison as usual (types.rs:1-7). No consumer needs a bisimulation comparison at runtime. - Nominal types carry their
uidin the node label — they never merge wrongly with structural ones or with each other. The canonicalization respects nominality automatically. - The price compared with de Bruijn: no more "free termination" — type walks
(
displayetc.) need a visited set again (local, simple; see M2). In exchange, allRec/SelfVarsurgery by the compiler falls away, and mutual recursion is no special case.
The label & the transitions of the canonicalization. The label of a node is its
TypeKind constructor plus the identity-relevant non-type attributes
(field names + order, variant names, mutable with a Ref, uid with a
Distinct/Class). The transitions are the edges to the field/variant/
inner types. Two nodes are equivalent ⇔ the same label and corresponding
transitions lead to equivalent nodes — the usual partition-refinement
definition.
- TypePool (src/types.rs): operations for provisional
nodes.
reserve(kind) -> TypeIdpushes a placeholder (empty fields/variants) intokindswithout alookupentry;patch(id, kind)overwrites it with the finalized kind. No newTypeKind— recursive types are ordinaryStruct/Enum/Classes whose fieldTypeIds point back cyclically. - ensure_alias (src/sema/lower.rs:6) — for every
RHS that can be recursive (a structural struct, a structural enum, a
Class, aDistinct, later adistinct enum):reservewith a placeholder.- Set
alias_states[index] = Done(reserved)before the body lowering (so far:InProgress). A self-reference now findsDone(reserved)and closes the cycle instead of running into "type alias cycle". - Lower the body (self-references become the reserved
TypeId). patch(reserved, …).
- instantiate_alias (src/sema/lower.rs:113) —
analogously for generic instances (
Box<Tree>,List<Int>): register the reservedTypeIdinalias_instsand fromalias_inst_stackbefore the body lowering; the previous "re-entry = an error" (lower.rs:123-128) becomes "re-entry = return the reserved node". - Canonicalization at the closing of a cycle (the core of the structural identity):
as soon as an alias (or a mutual SCC of aliases) is fully patched,
subject the freshly built nodes to a partition-refinement collapse
(the label + transitions as in the representation chapter) — both
among each other and against already interned types. Equivalent nodes
are mapped onto one canonical
TypeId(a remap table; the reserved IDs that do not become representatives fall away or are redirected). The collapse runs only for recursive new builds — non-recursive types deduplicate unchanged directly throughlookup. - The result:
type Foo = [count: Int, next: & mut Foo]and an identically shapedBar— as well as two identically shaped mutually recursive groups — get the same canonicalTypeId. Non-recursive types are unchanged.
The cyclic graph brings the visited set back: as soon as a field type
points (transitively) at itself, a naive structural walk would descend
endlessly. Nominal types abbreviate to the name/uid today and terminate;
structural ones do not. Before M3/M4, make the affected walks cycle-safe
(a visited set over TypeId or a stop at TypeKind::Ref):
display(types.rs:182) recurses into fields andRef.inner→ an endless loop withnext: & mut Foo. A visited set breaks the cycle off (e.g. printing…at the back edge). Since recursive structural types carry no name of their own in the node, possibly carry the alias name along for the display.contains_class,is_copyableand similar field-traversing predicates: a visited set, so that a recursive field does not descend endlessly.- The movecheck/emit walks over fields: check which stop at references anyway (non-owning) and which need a guard.
The visited set is local and cheap — the deliberate trade compared with the de Bruijn variant (there the walks terminated for free, but at the price of pervasive new variants).
Binding the knot removes the accidental barrier — now the real check is needed, which reports only the illegal value cycle.
- A new check (a pass of its own after the lowering): for every type, follow the
value fields transitively (a visited set on the current path);
if a value path reaches the starting type again without having crossed a
Refbeforehand → the error "recursive type has infinite size; insert indirection".TypeKind::Refand heap boxes break the walk off (pointer-sized). Box needs no special treatment — the walk ends at its& mut Tfield (std/box.lang:23). That is Rust's E0072. A back reference behind at least oneRefis exactly the legal case. - The value dependency graph is thereby guaranteed acyclic — the invariant emit needs in M4.
Today "the pool order = the dependency order" holds (emit/mod.rs:219). Binding the knot breaks that: the type is reserved before its fields. Two additions:
- Forward declarations. Before the definition loop, output a
struct <name>;for every struct-like type — a structuralStruct(S{n}), aClass, a payloadEnum. C++ allows pointers/references (T*) to an incomplete type, and every recursive back edge crosses a pointer by construction. A& mut Foofield emits as a pointer to the (forward-declared) struct of the back-reference type. A prerequisite: pull the name assignment forward — today in the definition loop (emit/mod.rs:229, :256); for forward declarations the names have to stand fixed beforehand. - A topological sort of the definitions by value dependencies.
The pool order no longer suffices: a value field (
left: Box<Tree>) needs the field type completely, but is interned after the enclosing type. So sort the complete definitions by the value graph — reference edges are cut (the forward declaration covers those). The graph is acyclic thanks to M3. (For purely reference-recursive types likeFoothe forward declaration already suffices; the sort applies with value chains.)
- Correct the comment emit/mod.rs:219-220.
It falls out of M1–M4 — structural recursive enums and distinct enums:
type List = enum [ Nil, Cons: [head: Int, tail: & List] ]; // structural
- Reference payloads (
& List) are copyable → they work end to end. - Class payloads (move-only) are now allowed too: the tagged
union gets tag-switched special members (default/move/copy/dtor), is
constructed through placement new and matched through a
const&(seetests/emit/enum_class_payload.lang,tests/e2e.rs::enum_payloads). - Owning recursive trees (
Node: [Box<Tree>, Box<Tree>]) now run end to end (tests/e2e.rs::owning_recursion, ASan/UBSan/leak-clean). For that,Box<T>was made fit for a move-onlyT:varparameters (the function owns and may move), the runtime primitivesconstruct(placement new into raw memory instead of an assignment) andfree= destroy + deallocate. - The EnumImplementation M3 ("recursion over a nominal anchor") thereby becomes a
special case — structural recursive enums need no
distinctwrap.
cargo fmt --check,cargo clippy(0 warnings), all suites green.- The examples through
g++ -std=c++20 -fsanitize=address,undefined— a linked list built up, traversed, released (ASan/UBSan clean; correctmove/deinitwith owning recursion). - New fixtures:
tests/errors/recursive_types.lang— a value cycle (type Bad = [next: Bad]), a mutual value cycle: one precise message each.tests/parser/tests/emit/recursive_types.lang— structural (Foo) and nominal (Tree): forward declarations + the definition order in the snapshot.- An identity test: identically shaped self- and mutually recursive types
collapse into one
TypeId(e.g. a function that expects aFooaccepts an identically shapedBarvalue). - e2e: a list/tree end to end (building, summing, releasing), the exit code checked.
- The emit snapshot changes deliberately in M4 (forward declarations, the order).
- The canonicalization is the only non-trivial algorithm here
(partition refinement over the type graph). It is standard and well-behaved
in time, but the heart of M1 — the test focus:
A↔B=C↔D, butA↔B≠ a differently shaped group; nominal types (uid) never merge. Self- and mutual recursion are thereby both exact. - Polymorphic recursion (
type List<T> = [tail: & List<[T]>],Tgrows per level) produces infinitely many instances — a real endless loop of the monomorphization, as in Rust/C++. Not a goal; possibly a depth limit on instantiation later. Non-polymorphic recursion terminates through thealias_instscache. - Reserved, never patched nodes with field lowering errors: the placeholder (empty fields) has to stay harmless, so that no follow-on errors cascade.
- The
move/deinitof owning recursion (Box<Tree>): the generated rule-of-five paths (emit/decls.rs:45) have to cope with the recursive field type. With reference children that falls away.
Implemented and verified (fmt/clippy/all suites green, the examples through g++/ASan/UBSan):
- M1 — bind the knot + canonicalize.
TypePool::reserve/patch/kill/equiv; SCC detection (recursive_alias_set) gates the knot binding only for recursive aliases (non-recursive ones byte-identical). Pure self-recursion is canonicalized: an identically shapedFoo/Bar→ one TypeId (a coinductiveequiv, a dead placeholder). A cross-round cache (rec_alias_cache) keeps recursive TypeIds stable across monomorphization rounds. - M2 — cycle-safe walks.
display,is_copyable,contains_class, the size/emit walks with a visited set. - M3 — the finiteness check. A value cycle →
recursive type has infinite size; insert indirection. - M4 — emit. A name pre-pass → forward declarations (only for cyclic types,
the snapshots stable) → the definitions in value dependency order (a DFS over
embed_deps), dead nodes skipped. - M5 (partly) — structurally recursive enums with a reference payload run
end to end (
tests/e2e.rs::recursive_types).
Still open (follow-up turns):
- Mutual recursion: it runs and emits correctly, but is not yet deduplicated (only single SCCs are canonicalized; a node of a mutual group is referenced by its partner — killing it would be unsafe). It needs SCC-wide canonicalization.
- Generic recursion (
type List<T> = …):instantiate_aliasdoes not yet bind the knot — a separate path. distinct enumrecursion and owningBox<…>enum payloads (move-only) — they hang on the enum payload copyability (EnumImplementation.md).