Skip to content

feat: add function-scoped defer semantics - #51

Merged
conr2d merged 24 commits into
mainfrom
feat/function-scoped-defer
Sep 16, 2026
Merged

conr2d merged 24 commits into
mainfrom
feat/function-scoped-defer

Conversation

@4lmighty

@4lmighty 4lmighty commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • replace lexical scope-exit defer behavior with one function-scoped registration stack
  • add eo_defer_scope as the canonical per-function registration scope
  • drain deferred calls explicitly on translated normal exits while function locals are still alive
  • evaluate and save deferred callees, receivers, and arguments in source order before registration
  • preserve Go value- vs pointer-receiver binding for deferred method calls
  • execute repeated defers in LIFO order across nested blocks and loops
  • cover ordinary functions and coroutine completion with focused conformance tests
  • migrate existing examples and smoke coverage to the function-scoped shape
  • freeze the deterministic defer translation rule for the supported normal-return subset

Motivation

Go defer is function-scoped, while Eo's previous RAII guard ran when the surrounding C++ block exited. A top-of-function RAII stack also runs too late for references to later C++ locals, because those locals are destroyed before the stack destructor. In addition, an ordinary C++ call such as push(f(), x(), y()) cannot preserve Go's required source evaluation order.

This PR therefore separates defer registration from execution. The stack stores already-prepared nullary callables, and translated normal exits explicitly drain the stack before local destruction.

Canonical shape

A translated function containing at least one defer declares one stack at function scope:

func<> f() {
  eo_defer_scope;
  // ...
}

A direct defer first evaluates and saves the required callee/receiver/argument expressions in source order, then registers a nullary deferred callable. Method receivers preserve Go's receiver binding: value receivers save a value, while pointer receivers on addressable values save the corresponding address. Every translated normal exit drains the stack before return / co_return.

Scope

Named-result mutation, panic/recover, and deferred closures whose captured variables require lifetime extension beyond their translated C++ lexical scope remain separate translation/runtime concerns. This PR freezes function-scoped registration, source-ordered registration evaluation, receiver binding, LIFO execution, normal local lifetime ordering, repeated registration, and ordinary/coroutine normal-return behavior for the supported subset.

@4lmighty 4lmighty left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function-scoped stack is the right semantic direction compared with lexical scope-exit, but I don't think this particular top-of-function RAII design is safe to freeze yet. I found two blocking semantic gaps: deferred calls run after later C++ locals have already been destroyed, and direct eo_defer(f, args...) does not preserve Go's observable evaluation order for side-effecting argument/function expressions. Both matter directly to mechanical translation. Build/Style are green, but these are semantic rather than CI failures.

Design-wise, I would keep the single function-scoped registration stack, but decouple registration storage from execution timing: drain the stack explicitly on translated normal exits while the function's locals are still alive, rather than relying on the stack object's destructor. For direct calls, capture the function value and arguments through a source-ordered mechanism (adjacent temporaries or an equivalent left-to-right construction) before pushing the deferred thunk. That also leaves a cleaner place to extend named-result and panic/recover behavior later.

Before calling the mapping fully frozen, I would also document the canonical method-call form (defer x.M(args...)) because receiver evaluation/saving is part of Go defer semantics and real Go code uses this shape heavily. I see that as a design-completeness item once the two blockers below are fixed.

Comment thread src/eo/defer.h Resolved
Comment thread src/eo/defer.h Resolved

@4lmighty 4lmighty left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the revised design. The two original blockers are fixed cleanly: defer execution is now explicitly drained before normal exits while active locals are alive, and deferred callee/receiver/argument evaluation is serialized through source-ordered temporaries. Build/Style are green on this head.

I still don't think the mapping is ready to freeze as a general defer rule, though. Two lifetime/receiver cases remain semantically unsafe: a deferred closure can reference a local from a nested block that has already been destroyed by the time the function-level drain runs, and pointer-receiver method calls on addressable values need to preserve Go's implicit &x receiver binding rather than unconditionally copying x. Both are common Go shapes and both follow directly from the documented canonical forms below. I would either solve these now or narrow the frozen subset explicitly so translators cannot silently emit the incorrect forms.

Comment thread docs/TRANSLATION_RULES.md Resolved
Comment thread docs/TRANSLATION_RULES.md Resolved

Copy link
Copy Markdown
Collaborator Author

Design direction note after reviewing this defer work more deeply:

I think this PR is revealing an important project boundary. Eo still makes sense as a runtime/library target, but some Go semantics are inherently source-lowering problems rather than library API problems. defer is a good example: preserving source-order evaluation, rewriting every normal return path, handling named results later, and eventually extending lifetimes for captured/escaping locals all require awareness of the Go AST/control-flow/lifetime model.

Because of that, I would not try to force all future compatibility into library macros/RAII. A better long-term shape seems to be Eo runtime + a deterministic Go→Eo/C++ translator. The translator can stay intentionally mechanical: use Go's own parser/type checker, preserve source structure, lower only constructs that need semantic rewriting, and target the Eo runtime for channels/Select/context/waitgroup/etc. Unsupported constructs should remain explicit rather than being guessed.

That also fits the project's original goal well: produce a structurally close C++ baseline very quickly, then rewrite only critical paths as native C++. The existing Go-like Eo API is still valuable in that model because it keeps the generated C++ and the emitter simple.

Concretely, defer looks like a good first translator spike after this PR: AST-level lowering into function-scoped registration + explicit normal-exit drain + source-ordered temporaries. That would validate whether the translator layer should become a first-class part of Eo without committing to a full compiler up front.

So my current recommendation is: keep the runtime/library work, but treat "library-only" as no longer a hard constraint. The semantics that require source rewriting should live in a deterministic lowering layer instead of growing increasingly compiler-like macro conventions.

@conr2d
conr2d merged commit b87d7b9 into main Sep 16, 2026
3 checks passed
@conr2d
conr2d deleted the feat/function-scoped-defer branch September 16, 2026 05:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants