plum
git clone https://git.pyrossh.dev/plum
A statically typed, imperative programming language inspired by rust, python
docs/superpowers/specs/2026-07-20-generics-monomorphization-design.md
| ea186e4 | 1 | # Generics monomorphization |
| ea186e4 | 2 | |
| ea186e4 | 3 | ## Problem |
| ea186e4 | 4 | |
| ea186e4 | 5 | Generic type parameters (single lowercase letters: `a`, `b`, `c`, `d`) are supported today only |
| ea186e4 | 6 | at the syntax and permissive-type-check level: `type Box(a) = value: a`, `trait Comparable(a: Ord) |
| ea186e4 | 7 | = compareTo(other: a) -> Int`, and generic function params (`wrap(value: a) -> Bool`, `pair(first: |
| ea186e4 | 8 | a, second: b) -> Bool`) all parse and type-check (`plum-checker` treats an unresolved generic |
| ea186e4 | 9 | param permissively, the same way it treats any unmodeled/unknown type). `plum-wasm-codegen` has no |
| ea186e4 | 10 | representation for a generic parameter at all — any attempt to compile a generic class/function |
| ea186e4 | 11 | hits its permissive-fallback path (`ValType::I32` treated as "pointer to unmodeled type") without |
| ea186e4 | 12 | ever resolving what concrete type is actually present at a given call site, so field |
| ea186e4 | 13 | loads/stores and arithmetic on generic-typed values compile with the wrong width or fail outright. |
| ea186e4 | 14 | |
| ea186e4 | 15 | This is the single largest remaining gap blocking `libs/std`'s actual `Option`/`Result`/`List`/ |
| ea186e4 | 16 | `Map` from compiling — **but fixing generics alone does not get there**: `libs/std/list.plum` and |
| ea186e4 | 17 | `map.plum` also use syntax that doesn't exist in the grammar at all today (closures `|v| ...`, |
| ea186e4 | 18 | `Nil`, `?.` optional chaining, `:=`, decorators like `@HasTrait`, colon-arrow return types). This |
| ea186e4 | 19 | work targets **only** the currently-documented generic syntax (the shapes already exercised in |
| ea186e4 | 20 | `examples/types.plum` and `examples/functions.plum`), not those std lib files. |
| ea186e4 | 21 | |
| ea186e4 | 22 | ## Approach: monomorphization by specialization |
| ea186e4 | 23 | |
| ea186e4 | 24 | Chosen over a type-erasure/boxed-uniform-representation approach because the rest of the compiler |
| ea186e4 | 25 | already assumes every value has a statically-known concrete wasm type (`I32`/`I64`/`F64`) at |
| ea186e4 | 26 | codegen time — monomorphization preserves that invariant everywhere, at the cost of compiling one |
| ea186e4 | 27 | specialized copy per concrete instantiation actually used (standard Rust/C++-template-style |
| ea186e4 | 28 | trade-off), rather than requiring a new runtime representation (boxed/tagged values, out-of-band |
| ea186e4 | 29 | type metadata) that the rest of the codegen doesn't have anywhere else. |
| ea186e4 | 30 | |
| ea186e4 | 31 | ### 1. Instantiation-site collection |
| ea186e4 | 32 | |
| ea186e4 | 33 | Walk the whole program to determine every concrete `(generic item, concrete type argument list)` |
| ea186e4 | 34 | combination actually needed: |
| ea186e4 | 35 | |
| ea186e4 | 36 | - Explicit concrete type annotations naming a generic item with concrete arguments (`Box[Int]`, |
| ea186e4 | 37 | `Box(Int)` — both bracket and paren syntax are already accepted per the grammar). |
| ea186e4 | 38 | - Constructor calls where a generic class's field types resolve the class's generic parameters via |
| ea186e4 | 39 | unification against the supplied argument types (`Box(value: 5)` → `a = Int`). |
| ea186e4 | 40 | - Generic function/method calls where argument types resolve the function's generic parameters via |
| ea186e4 | 41 | unification (`wrap(5)` → `a = Int`; `pair(1, "x")` → `a = Int, b = Str`). |
| ea186e4 | 42 | |
| ea186e4 | 43 | This reuses `plum-checker`'s existing unification (`unify`, `infer_expr`) — extended so a generic |
| ea186e4 | 44 | parameter name occurring in a declared type position is treated as a fresh unification variable |
| ea186e4 | 45 | bound per call site, instead of being ignored/treated as an opaque unmodeled type name. |
| ea186e4 | 46 | |
| ea186e4 | 47 | ### 2. Specialization mechanism |
| ea186e4 | 48 | |
| ea186e4 | 49 | For each unique `(generic item, concrete type args)` pair, produce a substituted copy of the |
| ea186e4 | 50 | `Class`/`Fn` AST item: every occurrence of a generic parameter name in a **declared type |
| ea186e4 | 51 | position** (field types, param types, return types) is replaced by its concrete resolved |
| ea186e4 | 52 | `ast::Type`, under an internal mangled name (e.g. `Box$Int`, `wrap$Int`, `pair$Int$Str` — `$` |
| ea186e4 | 53 | joins the base name and each concrete type argument in declaration order). The substituted copy is |
| ea186e4 | 54 | then run through the **existing, unmodified** non-generic checker (`build_global_tables`, |
| ea186e4 | 55 | `check_fn`) and codegen (`compile_fn_body`, class field layout) pipeline — neither of those needs |
| ea186e4 | 56 | to know or care that its input started out generic. |
| ea186e4 | 57 | |
| ea186e4 | 58 | Mangled names are purely internal (`ClassEnv`/`MethodEnv`/`func_ids` keys, wasm export names where |
| ea186e4 | 59 | relevant) — user source keeps calling `Box(...)`/`wrap(...)` normally; the compiler resolves which |
| ea186e4 | 60 | specialization a given call site needs from its own already-inferred concrete argument types and |
| ea186e4 | 61 | rewrites the call/construction internally to reference the matching mangled specialization. |
| ea186e4 | 62 | |
| a10644c | 63 | **Generic enums** (`enum Option = | Some(a) | None` — matching `libs/std`'s real `Option`) are in |
| a10644c | 64 | scope alongside classes/functions/traits. Unlike `Class`/`Trait`, `ast::Enum` has no explicit |
| a10644c | 65 | `generics: Vec<GenericParam>` field, and neither does `ast::Fn` — both are detected as generic |
| a10644c | 66 | implicitly, the same way: a variant's field type name (`EnumVariant.fields: Vec<String>`, for |
| a10644c | 67 | enums) or a function's param/return type name (for `Fn`) that is a single lowercase letter (`a`, |
| a10644c | 68 | `b`, `c`, `d` — the grammar's only legal generic-parameter spelling) is treated as an implicit |
| a10644c | 69 | generic parameter, exactly like a `Class`'s explicit `generics` list entry. Specialization, |
| a10644c | 70 | mangling, and the worklist apply identically regardless of which item kind introduced the generic |
| a10644c | 71 | parameter. |
| a10644c | 72 | |
| ea186e4 | 73 | A generic **method** (`get<List>(i: Int) -> a`) specializes alongside its receiver class: once |
| ea186e4 | 74 | `List` is specialized as `List$Int`, every method declared with receiver `List` gets a |
| ea186e4 | 75 | correspondingly substituted, separately-specialized copy keyed `List$Int::get`, following the |
| ea186e4 | 76 | existing `MethodEnv` key shape (`(receiver type name, method name)`) with the receiver name being |
| ea186e4 | 77 | the mangled one. |
| ea186e4 | 78 | |
| ea186e4 | 79 | ### 3. Transitive/recursive generics — worklist to a fixed point |
| ea186e4 | 80 | |
| ea186e4 | 81 | A freshly-specialized body can itself call another still-generic function/method — now with |
| ea186e4 | 82 | concrete types known throughout that specialized body, so this is a new, fully-resolved |
| ea186e4 | 83 | instantiation site. Processed as a worklist: seed it from every concrete instantiation site |
| ea186e4 | 84 | reachable from non-generic code, specialize each, scan the newly-produced specialized bodies for |
| ea186e4 | 85 | further instantiation sites they introduce, and repeat until no new specializations are added. |
| ea186e4 | 86 | |
| ea186e4 | 87 | A recursion-depth guard bounds this (e.g. a hard cap on total specializations per compile, or on |
| ea186e4 | 88 | nesting depth of a type argument list) so a pathological unbounded-expanding generic (a generic |
| ea186e4 | 89 | type that embeds a larger instantiation of itself, with no concrete base case) fails with a clear |
| ea186e4 | 90 | compile error rather than hanging or exhausting memory. |
| ea186e4 | 91 | |
| ea186e4 | 92 | ## Out of scope |
| ea186e4 | 93 | |
| ea186e4 | 94 | - Trait-bound enforcement (`trait Comparable(a: Ord)`'s `Ord` bound) — stays exactly as permissive |
| ea186e4 | 95 | as it is today. Monomorphization only needs to substitute concrete types through, not verify |
| ea186e4 | 96 | bounds are satisfied. |
| ea186e4 | 97 | - Generic code with zero reachable concrete instantiation anywhere in the program is simply never |
| ea186e4 | 98 | specialized or compiled — this tree-shaking falls out of the worklist approach for free, it |
| ea186e4 | 99 | isn't a separate feature. |
| ea186e4 | 100 | - `libs/std/list.plum` and `map.plum` compiling as-is — as established above, they need several |
| ea186e4 | 101 | other unimplemented language features first (closures, `Nil`/optional chaining, decorators, |
| ea186e4 | 102 | colon-arrow return syntax) that are unrelated to generics and out of scope here. |
| ea186e4 | 103 | - A general, unbounded-generic-recursion *detector* beyond a simple depth/count cap — this is a |
| ea186e4 | 104 | defensive backstop against a degenerate program, not a soundness analysis. |
| ea186e4 | 105 | |
| ea186e4 | 106 | ## Testing plan |
| ea186e4 | 107 | |
| ea186e4 | 108 | - **Checker tests** (`plum-checker/tests/checker_tests.rs`): a generic class instantiated at two |
| ea186e4 | 109 | different concrete types in one program (`Box(Int)` and `Box(Str)`) resolves each usage's field |
| ea186e4 | 110 | access to the correct concrete type; a generic function called with different concrete argument |
| ea186e4 | 111 | types at different call sites infers correctly per site; a multi-param generic (`Pair`/`Map`- |
| ea186e4 | 112 | shaped, two independent type params) resolves both params independently per call site. |
| ea186e4 | 113 | - **Codegen tests** (`plum-wasm-codegen/tests/codegen_tests.rs`, compiled and executed via |
| ea186e4 | 114 | `wasmtime` matching existing style): a generic class specialized at `Int` and at `Str` in the |
| ea186e4 | 115 | same program, confirming the two specializations don't alias (distinct compiled bodies, correct, |
| ea186e4 | 116 | independent field widths/offsets); a generic function called at multiple concrete types in one |
| ea186e4 | 117 | program; a generic method dispatched on a specialized generic class; a transitively-generic call |
| ea186e4 | 118 | chain (a specialized generic function itself calling another still-generic function) proving the |
| ea186e4 | 119 | worklist reaches a correct fixed point; a deliberately-pathological unbounded-recursive generic |
| ea186e4 | 120 | type, asserting `compile_source` returns a clear error rather than hanging or panicking. |
| ea186e4 | 121 | - **Examples**: extend `examples/types.plum`/`examples/functions.plum` (currently declarations |
| ea186e4 | 122 | only) with actual instantiations and a `main` that runs them end-to-end. Update README's Known |
| ea186e4 | 123 | Gaps to drop the "user-defined generics aren't monomorphized" bullet, while adding/keeping a note |
| ea186e4 | 124 | that this alone doesn't unblock `libs/std`'s actual `List`/`Map`/`Option`/`Result` (separate, |
| ea186e4 | 125 | unrelated gaps remain there). |