Repository navigation
feat: add function-scoped defer semantics - #51
Conversation
4lmighty
left a comment
There was a problem hiding this comment.
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.
4lmighty
left a comment
There was a problem hiding this comment.
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.
|
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. 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, 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. |
Summary
eo_defer_scopeas the canonical per-function registration scopeMotivation
Go
deferis 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 aspush(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:
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.