RFC-0017 — Init Decoupling
- Status: Accepted, shipped in 4.2.0
- Author: Mark Truluck
- Companion: RFC-0015 (factory-only construction) — D7
@@!Foo()no-init sigil
Summary
Decouple the user-visible $> start-event handler from the target language constructor. Every backend now emits three artifacts where it used to emit one:
- Bare constructor — framework setup only. State stack, compartment placeholder, domain field defaults. No user
$>body, no enter cascade, no transition loop. Per-backend spelling:Counter()/Counter::new()/Counter_new()/start_link/0/ etc. __frame_init(args)method — runs the user$>body, fires the enter cascade, drains the transition loop. Takes the original ctor params. Per-backend spelling:_frame_init(Python/Dart/JS/TS/Ruby/Lua/PHP/GDScript),__frame_init(Rust/Java/Kotlin/Swift/C#/C/C++),frame_init/(N+1)(Erlang).__create(args)factory — calls the bare ctor then__frame_init(args)and returns the instance. Per-backend spelling:_create(dynamic backends),__create(typed backends),CreateCounter(Go),Counter_create(C),create/N(Erlang).
What @@Counter(7) (construct) and @@!Counter() (no-initialization) turn into
in the generated target code changes accordingly:
@@Counter(7)→ a call to the new__createfactory —Counter::__create(7), or the per-backend equivalent (see Generated calls, per backend below).@@!Counter()→ a plain call to the bare constructor.
Why
Before this RFC, the user’s $> body ran inside the language constructor. This forced four backends (Java, Kotlin, Swift, Erlang) to carry a __skipInitialEnter static flag — a runtime sentinel toggled around constructor calls to suppress $> for @@!Foo(). The flag-based approach worked but was a symptom of the underlying coupling, not the fix.
D7’s @@!Foo() originally synthesized per-backend “no-init” helpers (__no_init, _no_init, Foo_alloc, start_link_no_init/0, etc.) that bypassed the constructor via various tricks: __new__ in Python, Object.create(prototype) in JS, Unsafe.allocateInstance candidates for the JVM, marker-class patterns for Swift, skip-flag toggles. Eight different mechanisms for one semantic: “give me an instance without running init.”
Decoupling collapses all of that to a uniform shape: bare construction is the no-init form, by construction. The user’s $> runs only when __frame_init is called explicitly. No skip flags, no synthesized bypass helpers, no Unsafe, no marker classes.
The decouple also resolves a previously-noted Erlang persist wart (restore_state/1 returns a fresh Pid rather than mutating an existing one): the bare start_link/0 now produces a Pid that’s safe to feed into sys:replace_state, so the @@! + restore_state flow is structurally coherent.
What changed in the codegen
Per-backend constructor split
The Constructor IR node in each backend’s emit arm now classifies body statements into three buckets:
- Cascade triggers (
__fire_enter_cascade/__process_transition_looptext match) → emitted in__frame_initonly. - Statements that mention a constructor param by name → emitted in
__frame_init(with the user args bound); a stripped form (param-list args replaced with empty literal) is emitted in the bare ctor so the placeholder compartment is constructed with empty enter_args / state_args.restore_stateoverwrites these when the@@!-then-restore_statepattern is used;__frame_initoverwrites them when the factory path is used. - Everything else (domain field defaults, state stack init,
__next_compartment = None) → emitted in the bare ctor.
Each backend defines its own *_strip_param_lists helper that recognizes the backend’s collection-literal idiom and substitutes an empty form when every argument is a known param name.
Per-backend factory spelling
The __create factory is emitted by the Constructor arm directly for most backends. Kotlin emits it as a @JvmStatic companion-object member via a NativeBlock injected by generate_kotlin_machinery (companion-object grouping). Swift emits it as a static func. Erlang emits it as a module function alongside start_link/0 and frame_init/(N+1). C emits it as a free function alongside Counter_new and Counter_frame_init.
Removed infrastructure
The following are gone from every backend:
__skipInitialEnterstatic flag (Java, Kotlin, Swift, Dart, GDScript, C++)__no_init/_no_init/Foo_allocsynthesized helpers (all 8 D7 backends that had them)kotlin_type_default_expr,swift_type_default_exprhelper functionsframe_skip_enter__record field for Erlang kept — still needed because gen_statem’sstate_entercallback mode firesstate_enterautomatically on init even with[]args. The skip flag now defaults totrueon every barestart_link/0call; theframe_initcast handler clears it and re-firesstate_enterviarepeat_state.
The legacy restore_state codegen branches that emitted __skipInitialEnter = true/false; around value-init of a fresh instance are removed (5 sites in interface_gen.rs). They were guarded by !uses_new_contract which is unreachable since E814 hard-cut the legacy persist contract.
Generated calls, per backend
When you write @@Counter(7) (construct) or @@!Counter() (no-initialization)
in Frame source, framec emits this in the generated target code:
| Backend | @@Counter(7) |
@@!Counter() |
|---|---|---|
| Python | Counter._create(7) |
Counter() |
| Rust | Counter::__create(7) |
Counter::new() |
| Kotlin | Counter.__create(7) |
Counter() |
| Java | Counter.__create(7) |
new Counter() |
| Swift | Counter.__create(7) |
Counter() |
| C# | Counter.__create(7) |
new Counter() |
| Go | CreateCounter(7) |
NewCounter() |
| C | Counter_create(7) |
Counter_new() |
| C++ | Counter::__create(7) |
Counter() |
| Dart | Counter._create(7) |
Counter() |
| GDScript | Counter._create(7) |
Counter.new() |
| JavaScript | Counter._create(7) |
new Counter() |
| TypeScript | Counter._create(7) |
new Counter() |
| Ruby | Counter._create(7) |
Counter.new |
| Lua | Counter._create(7) |
Counter.new() |
| PHP | Counter::_create(7) |
new Counter() |
| Erlang | counter:create(7) |
element(2, counter:start_link()) |
Naming convention: single-underscore (_create / _frame_init) on backends with name-mangling (Python) or library-private convention (Dart, JS/TS, Ruby, PHP, GDScript, Lua); double-underscore (__create / __frame_init) on backends where Frame’s existing __compartment / __fire_enter_cascade infrastructure already uses double-underscore.
D4 invariant
The D4 invariant from RFC-0015 — $> runs exactly once on the factory path and never on the @@! path — is preserved. Verified end-to-end on Python, Rust, Kotlin, Java, Swift, Dart, Go, Erlang with the standard Counter fixture ($>(seed: int) increments fire_count; assertion: value=9, fire_count=1 after @@Counter(7) → bump → bump → save → @@!Counter() → restore_state).
Migration
For Frame source: zero changes. @@Counter(7) and @@!Counter() are unchanged.
For users who called the generated constructor directly (outside the @@ machinery) with arguments — e.g., Counter(7) in Python, new Counter(7) in Java, Counter::new(7) in Rust — that path is now a compile error or ill-formed call. Migrate to the explicit factory:
- Python:
Counter(7)→Counter._create(7) - Java:
new Counter(7)→Counter.__create(7) - Rust:
Counter::new(7)→Counter::__create(7) - Erlang:
counter:start_link(7)→counter:create(7) - etc. per the table above.
Bare calls like Counter() / new Counter() / Counter::new() still work but now produce a no-init instance. Previously they would have run $>; if your code depended on that, use the factory form.
Companion change
@@!Foo()’s meaning is unchanged — only the per-backend mechanism is simpler. RFC-0015 describes the design; it points here for what @@!Foo() and the factory call become in each target language.
Rejected alternatives
Unsafe.allocateInstancefor JVM: bypasses constructors entirely. Considered for@@!Java/Kotlin, but the JVM internal-API tax (--add-openson JDK 9+, removal threats) plus the requirement to manually initialize framework fields made it strictly worse than the bare-constructor approach.- Marker-class secondary constructor pattern for JVM/Swift: cleaner than Unsafe but restructures the class shape and forces the user’s natural constructor signature into a secondary position. Bare-ctor split is more uniform across the 17 backends.
- Keep
__skipInitialEnterflag, just split__frame_initout: would have worked but left the runtime flag as dead state on 4 backends. The decouple removes it cleanly.
Files changed
framec/src/frame_c/compiler/codegen/backends/{python,rust,java,kotlin,swift,csharp,go,c,cpp,dart,gdscript,javascript,typescript,ruby,lua,php}.rs— 16 Constructor arm rewrites.framec/src/frame_c/compiler/codegen/erlang_system.rs— exports, init clause, frame_init handler emission.framec/src/frame_c/compiler/codegen/system_codegen.rs— removedkotlin_type_default_expr/swift_type_default_expr, removed init_event_code__skipInitialEnterwraps.framec/src/frame_c/compiler/codegen/interface_gen.rs— removed 5 legacy restore_state__skipInitialEnterbranches.framec/src/frame_c/compiler/codegen/frame_expansion.rs—generate_no_initializationdispatch table updated.framec/src/frame_c/compiler/assembler/mod.rs—generate_constructorfactory-spelling table updated.