The Journey of Building PatLang, Continued Yet Again: Real Inheritance, at Last
A direct continuation of the previous instalment (Acts XV-XXIII: hosting PatLang somewhere new, a real x64 code generator reaching its own fixpoint, a three-act performance saga on this very site's own build pipeline, and a preprocessor that turned out to already exist) — split into its own page for the same reason each earlier page split off from the one before it. Acts XXIV-XXV close out a run of smaller, real gaps found while polishing an already-working system: a third instance of the exact same vacuous-example bug, two more quadratic-time footguns, an idea planted three acts ago (event handlers as real closures) finally built, and a goal-oriented chunk-mapping gap that, once fixed, turned directly into the request that drives the rest of this page. Acts XXVI-XXVIII are one continuous arc: PatLang gaining a genuine, optional class keyword — single inheritance, real method dispatch, composable traits, an inline form needing no declarative block at all, and two spellings for the same block delimiter — built in five deliberate slices, each one shipped and verified before the next began, and each one finding at least one real bug that only existed because the system was finally being exercised for real. Same warts-included approach, same commit-history sourcing, same house style as the earlier pages.
Act XXIV: the same bug a third time, and an idea from three acts ago finally built
Reviewing the paradigm gallery once more turned up an old acquaintance: "Just to note Logic + Event in the singles and pairs doc has the same problem the Functional + Logic had (returns 1...)" — the exact same vacuous-query shape closed out in Act XXII (query's third argument only ever binds, it never checks), just reintroduced independently in a different example pair. A quick audit found it wasn't just the one: Logic + Goal and Logic + DSL had it too, plus a fourth instance sitting inside the Functional + OO + Logic triple. All four got the same fix already proven correct — fold the value being checked into the predicate name itself, so the query genuinely distinguishes a right answer from a wrong one. Lesson: a bug pattern fixed once in one example is worth actively searching for elsewhere the moment it's spotted again — a coincidence the second time is a pattern the third.
Two more real O(n²) instances turned up in the same sweep that had just fixed the syntax-DSL preprocessor's own quadratic blowup: the lexer's own NUM/IDENT/STR token accumulators built each token one character at a time via plain string concatenation, and the code generator's rs_escape did the same escaping a Rust string literal. Both got the same string-builder fix already applied everywhere else this session, closing off two more instances of an anti-pattern that, per the new compile-time warning built the same week, the tooling itself can now catch unprompted.
The bigger piece of this Act was an idea seeded three Acts earlier, in Act XXII's closing note: event handlers, synthesized as isolated pre-pass functions with no access to any enclosing scope, could instead become genuine closures. Built for real this time: when NAME { ... } blocks now lower through the same closure-literal machinery ordinary closures use, registered at runtime rather than by a compile-time function name, so a handler can finally see a variable from its enclosing scope without the old workaround of re-declaring it fresh inside the handler body. Mirrored across the interpreter, the compiled backend's own embedded dispatcher, and the self-hosted compiler — and a real regression surfaced immediately in the compiled path specifically, caught and fixed before it shipped, plus a second, separate gap in the self-hosted lowerer only found by reusing the exact closure-literal machinery rather than hand-rolling a parallel path for it. Two existing tests that had directly inspected the now-removed internal event-handler-by-name state needed updating to match. Lesson: "host functions can't call closures themselves" is a real, recurring architectural constraint in this runtime — anything that needs to invoke one has to live in the interpreter's or the compiled program's own dispatch code, not in an ordinary host function, and that lesson would come up again, twice more, before this page is done.
Act XXV: fixing a compliance gap, and a question that reshaped the rest of this page
A chunk-mapping audit of the goal-oriented paradigm (rule_add/goal_def/solve/action_add/plan and friends) found ten host functions missing from host_chunk_of, the table that tells the self-hosted compiler which precompiled chunk a program actually needs — meaning any compiled program calling one of those ten functions would fail at runtime with a plain "host fn not found," despite compiling without a single warning. Confirmed via git history as a real, pre-existing gap, not a regression from anything done this session. Fixing it surfaced one gap too deep to patch casually: activate called from inside an arbitrary expression (print(activate p), rather than as its own statement) fails via the self-hosted compiler specifically, because its lowerer's expression-handling code never learned to intercept it the way the native one does. Rather than thread the fix through roughly thirty call sites for one edge case, five affected paradigm-gallery examples were rewritten to use the equivalent statement-level form instead — a real, honestly-scoped workaround, not a silent one.
Asked what to prioritize next, the answer folded two things into one instruction: "Ooh well, fix the goal oriented bit first so compiled programs actually comply with language specs... if there is work on the inheritance/traits/interfaces that you can do at the same time, all to the good." The second half was new — PatLang's object system, up to this point, was a flat, string-keyed store with no class registry, no field defaults, and no real method dispatch at all: send(obj, "method", args) only ever understood one built-in verb, "set". Rather than start writing code against an assumption, the actual requirements got asked for directly, and the answer was specific and considered: "we definitely want to maintain composability, and it would be really nice to support interfaces which I think are best matched by your mixins/traits option; and I'm not sure I really want a 'class' definition as a requirement, but also being able to inherit person from animal is desirable; and I guess having a formal definition of a 'class' as an option is no bad thing... whereas our current more ad hoc approach is perhaps not as clear to a human reader as it might be." Method/field resolution order (own class beats traits, last-listed trait wins a collision, traits beat the parent) and syntax shape (both a formal declarative block AND extending new(...) directly, with begin/end floated as an alternative to braces) got confirmed the same way, before a single line of the feature was written. Given the size of what was being asked for, and how often earlier features this session had turned up real, non-obvious bugs the moment they were actually exercised, the work was scoped as a written, approved plan rather than one large change: five slices, each one implemented natively first, verified across all three execution paths, ported to the self-hosted compiler, and re-verified — not just once at the very end. Lesson: a feature request this size deserves the same discipline as a real architecture review before any code gets written — confirming the design and the rollout plan explicitly costs an afternoon; discovering the design was wrong five slices in costs much more.
Act XXVI: a class becomes real, and the wrapper with its own secret list
Slice 1 kept deliberately small: a class declares a name, an optional single parent, and a list of field defaults — no methods, no traits yet. class Animal { field legs = 4 } / class Person inherits Animal { field legs = 2 }, and new("Person", "p1") auto-populates legs by walking the parent chain root-to-leaf, so a more specific class's own default always wins over an inherited one on the same field. Sugar only — it lowers to one host call, class_def(name, parent, field_defaults), the same "declarative block becomes one host call" shape goal/rule already used. Wiring it into the compiled backend surfaced a genuinely new, previously-undocumented footgun: adding a host function to the object-system chunk needs not two registration points but three — the chunk-mapping table, the function's own match arm, AND a small wrapper function around that match arm which turned out to carry its own separate, hardcoded allowlist of which function names it would actually forward to. The new function compiled clean, ran clean under the interpreter, and still failed at runtime under the compiled path with "host fn not found," twice, until that third list was found and updated too. Verified across the interpreter, the compiled backend, and the self-hosted compiler, with a full self-hosting fixpoint — the self-hosted compiler recompiling itself, and that fresh copy still compiling and running the feature's own test example correctly.
Slice 2 added real methods: a make a function called NAME ... end body declared inside a class block becomes a genuine closure at class-definition time, with an implicit leading self parameter bound to the receiver. Real dispatch needed the exact lesson from Act XXIV again — send needed a special case added directly to the interpreter's own instruction dispatch (and the compiled backend's own embedded copy of it), resolving the receiver's class and its method chain and invoking the closure directly, falling back to the old built-in behaviour only when nothing user-defined matches. Verifying it against the FULL test suite (not just the feature's own example) found two more real, separate bugs at once: a comment inside the always-included core prelude text happened to spell out, in plain prose, the exact function name a tree-shaking test searches for to confirm a chunk was correctly excluded — meaning every ordinary compiled program now silently "included" a chunk it never actually used, purely because of a sentence in a code comment. Rewording it once still failed, because the rewording itself quoted the banned string inside its own explanation of why it was banned. And separately, running the fuller test suite for the first time surfaced that this whole class feature had never worked when targeting WebAssembly without shared-memory support — a real, pre-existing gap from Slice 1 itself, since the class registry never got the same target-specific storage split every other shared piece of runtime state already carries. Flagged rather than fixed on the spot, at the user's own insistence once told about it: "Huh I don't remember a wasm test failure from before, make sure it is logged for our attention later." Lesson: a comment embedded in text that's literally compiled into every program is not just documentation — it's fair game for any tool that searches that text, deliberately or not.
Act XXVII: traits, and a class with no class block at all
Slice 3 added composable traits: a traits A, B line inside a class block, resolved own class first, then traits in listed order with the LAST one winning a collision, then the parent chain — confirmed design, built exactly as specified. A trait needed no new concept at all: it's just an ordinary registered class, nothing marks it as "a trait" except how another class chooses to list it. The one real bug this slice found was entirely self-hosted: the new trait-parsing branch never skipped the trailing newline before handing control back to the surrounding loop, so the very next token seen was a stray blank line rather than the next real statement — which surfaced as a deeply misleading error message pointing at the traits line itself, as if the keyword match had failed, when it had actually succeeded one step earlier. Diagnosed fastest not by rebuilding the whole self-hosted compiler on each guess, but by writing a small, disposable script that called the parser function directly and printed its raw output — a much faster loop than a full compiler rebuild for isolating exactly one function's behaviour.
Slice 4 turned out to need no new syntax at all: extending new(...) with optional trailing keyword-style arguments — new("Person", "p1", "inherits", "Animal", "traits", ["Nameable"]) — registers or extends a class entirely inline, with no formal class block ever written, merging onto whatever registration already exists rather than replacing it, so calling new again for the same class without those extra arguments keeps behaving identically. Confirmed before writing a line of code that new(...) was already dispatched as an ordinary function call with no fixed arity checked anywhere in the parser or the lowering code — meaning extra trailing arguments simply worked, needing zero parser changes and zero lowering changes, the smallest slice of the whole arc by a wide margin. Lesson: before assuming a feature needs new grammar, check whether the existing grammar was already general enough to carry it — sometimes the "simplest option" really is free.
Act XXVIII: two spellings for the same word, and the bug that had been there the whole time
The last planned slice: accepting do ... end as an alternative to { } for a class block, matching every other dual-form construct in the language. Before writing it, a naming question got asked rather than assumed: the original design conversation had floated "begin end" colloquially, but every existing dual-form site in the parser already used do, not begin. Offered the choice directly, the answer widened the scope on the spot: "OK go with do end, but if making begin end a possibility across the board is easy, can make that an option too?" It was — every site accepting do as an alternative opener (while, closures, budgeted, when, and now class) was a single string comparison, extended in both the native parser and the self-hosted one to accept begin as a synonym everywhere, not just for classes.
With all five slices complete, the wasm gap flagged back in Act XXVI finally got a real fix rather than staying a logged item: the class registry gained the exact same thread-local-versus-shared-lock split every other piece of cross-thread runtime state in the compiled backend already carries, gated on the same "can this target actually have more than one OS thread" signal. The fix compiled clean and the dedicated wasm test still failed, with the exact same error as before — because that particular test doesn't call the native code generator directly at all; it runs the self-hosted compiler's OWN generated-text mirror, read fresh off disk, through the interpreter, to produce the Rust it then compiles for WebAssembly. The fix needed regenerating that mirror before it could possibly take effect — the same category of "the mirror is stale, not the fix" lesson this whole project has hit repeatedly, just discovered in a slightly new place: a test that looks like it exercises the native compiler directly can, on closer inspection, actually be exercising the self-hosted one instead. Once regenerated, the full test suite came back completely clean for the first time in this entire arc — no known-failure caveat left to carry forward. Lesson: a test's name doesn't tell you which of several genuinely different code paths it's actually running through — check before assuming a native-code fix alone is sufficient.
Lessons from this arc, the short version
- A bug pattern fixed once in one example is worth actively searching for elsewhere the moment it's spotted again. A coincidence the second time is a pattern the third.
- "Host functions can't call closures themselves" is a recurring architectural constraint, not a one-off. Anything that needs to invoke a closure has to live in the interpreter's or the compiled program's own dispatch code.
- A feature request of real size deserves the same discipline as an architecture review before any code gets written. Confirming the design and the rollout plan explicitly costs an afternoon; discovering the design was wrong five slices in costs much more.
- Adding a function to an existing subsystem can have more registration points than the obvious two. A wrapper with its own hardcoded allowlist is exactly the kind of third point that's easy to miss until the runtime says so.
- A comment embedded in text that's literally compiled into every program is not just documentation. It's fair game for any tool — deliberate or not — that searches that text.
- The fastest way to isolate one function's bug is rarely rebuilding the whole pipeline around it. A small, disposable script calling that function directly and printing its raw output is a much faster loop.
- Before assuming a feature needs new grammar, check whether the existing grammar was already general enough to carry it. Sometimes the simplest option really is free.
- A test's name doesn't tell you which of several genuinely different code paths it's actually running through. Check before assuming a fix in one place is sufficient to make a specific test pass.
- A gap logged rather than fixed on the spot is still worth fixing properly once the pressure that caused the deferral is gone. "Flag it for later" only works if later actually arrives.
See also
The Journey of Building PatLang (Acts I-VI), the second instalment (Acts VII-XIV), and the third (Acts XV-XXIII) for where this page picks up from. The Paradigms Guide's Object-Oriented section now documents the full class/inherits/traits syntax this arc built, including the inline new(...) form and both block delimiters. The Grammar's compatibility table lists class alongside every other dual-delimiter construct. the Java, Python, and Ruby comparison pages all now reflect real classes and traits rather than "no class keyword at all."