Proposal: Auto Lambda Control Flow Semantics
This proposal extends the Auto Lambda parser-level sugar with a codegen-level companion feature. Auto Lambda itself is purely syntactic; this proposal is about what happens inside an Auto Lambda block once break, continue, and return are used.
Background
Consider a function foo that uses repeatUntil, an Auto-Lambda-enabled command, to loop until some condition holds:
func foo(...) (T1, T2, ...) {
x := 0
repeatUntil x > 10 {
x = modify(x)
if cond1(x) {
break // "break out of the repeatUntil"?
}
if cond2(x) {
continue // "skip to the next repeatUntil iteration"?
}
if cond3(x) {
return v1, v2, ... // "return from foo, not from the block"?
}
}
return ...
}Auto Lambda lets repeatUntil x > 10 { ... } be written without =>, but the block passed to repeatUntil is not actually inlined into foo — it is compiled as an ordinary Go closure and passed as a function value to repeatUntil's implementation. Go's break, continue, and return are lexically scoped to the nearest enclosing closure, so as a closure, none of these keywords can do what a user visually expects when reading the code above.
If the block were compiled as a naive closure, break/continue would simply be illegal (there is no enclosing for/switch/select inside the closure), and return v1, v2, ... would return from the anonymous closure itself — which discards the values, since repeatUntil's implementation does not know what to do with an arbitrary (T1, T2, ...) tuple. Control would fall back to wherever repeatUntil itself returns to (i.e. back into foo, at the call site), not out of foo entirely as the user's return statement visually suggests.
Certain Auto Lambda commands need break, continue, and return inside their block to behave as if the block were inlined native XGo control flow — not as if it were an ordinary closure body. This proposal defines that behavior, precisely which commands qualify and how, and the compilation strategy that implements it.
Non-Goals
- This proposal does not change the parsing rules defined by Auto Lambda.
- This proposal does not give explicit-lambda call sites (
=> { ... }) any new control-flow behavior, regardless of which command they call. Explicit lambdas always keep ordinary Go closure semantics, exactly as today. - This does not introduce a general "inline block" or macro-expansion mechanism to XGo. The block is still compiled as a real closure; only the interpretation of
break/continue/returninside it changes, for specific classes of signatures, via the mechanism described below.
Motivating Example
onStart {
x := 1
repeatUntil x > 10 {
echo "Hi"
x++
}
when x == 11 {
echo "x = 11"
}
forEver {
step 1
}
}Four different commands appear here: onStart, repeatUntil, when, and forEver. They are not all alike, and a two-way split ("does the block run synchronously or not") is not sufficient to describe them correctly — in particular, naively treating when the same way as repeatUntil gives break/continue inside a when block the wrong target: a user writing break inside when almost always means "break the loop when is sitting inside," not "stop evaluating when itself" (which isn't even a coherent thing to break out of, since when's body runs at most once). This proposal therefore splits Auto-Lambda-enabled commands into three categories, not two.
Three Categories of Commands
| Category | Body parameter name | Lambda signature | Command's own return type | break/continue target |
return target |
Examples |
|---|---|---|---|---|---|---|
| Function-type | any name not starting with __xgo_ |
func() or func() T for any T |
unconstrained; if present, ordinary data private to the command | not allowed (compile error) | the closure itself (ordinary Go semantics) | onStart, onKey |
| Conditional-type | must start with __xgo_cond_ |
func() int |
int |
the loop lexically enclosing the whole command call (not the command itself) | the enclosing XGo function (e.g. foo) |
when |
| Loop-type | must start with __xgo_loop_ |
func() int |
int |
the command's own loop | the enclosing XGo function (e.g. foo) |
repeatUntil, times, forEver |
The dividing line between function-type and the other two is conceptual, and is about whether the block's entire lifetime is nested inside the command's own call (synchronous), or the block is handed off to run later as an event callback (deferred) — see What Motivates the Three Categories below. But that conceptual line is not, by itself, something the compiler can detect from a signature: a function-type block is not restricted to returning nothing. A framework author may give a function-type command's body parameter a return type of int (or any other type) purely for the command's own private bookkeeping, with no relationship whatsoever to XGo control flow — a block shaped func() int is therefore not sufficient, on its own, to distinguish "this is loop-type or conditional-type dispatch" from "this happens to be a function-type callback that returns an int for unrelated reasons." Signature shape alone cannot separate function-type from the other two.
This proposal therefore uses the body parameter's name, and only the name, as the sole discriminator for all three categories:
- Body parameter name starts with
__xgo_loop_→ loop-type. Requires the parameter's own type to befunc() intand the command's own return type to beint. - Body parameter name starts with
__xgo_cond_→ conditional-type. Same signature requirement as loop-type. - Body parameter name starts with
__xgo_but continues with neither recognized sub-prefix → compile-time error: reserved prefix, category cannot be determined. (This leaves room for future control-flow categories without silently misclassifying such a parameter as function-type.) - Any other body parameter name (i.e., any name not starting with
__xgo_at all) → function-type, regardless of the lambda's own signature shape or return type.
The __xgo_ prefix is thus reserved for the compiler's own control-flow-category bookkeeping. Ordinary XGo command authors who are not opting into loop-type or conditional-type dispatch simply never use it — they name their body parameter whatever they like (cmd, body, handler, ...), exactly as they always have.
Why Conditional-Type Needs to Exist Separately From Loop-Type
when's block runs at most once per call — there is no second iteration to continue to, and nothing of when's "own" to break out of. Given:
outer:
for {
when x == 11 {
echo "x = 11"
if verbose {
break // user intent: break out of `outer`, not out of `when`
}
}
}a user writing break here is reading when { ... } exactly as they would read a native if x == 11 { ... } block — and a native break inside a native if breaks the nearest enclosing for/switch/select, which is outer, not the if. when should behave identically. Treating when as loop-type would make break a no-op that merely stops evaluating when's own block — which is indistinguishable from when's block simply finishing normally, and is never what the user means. Conditional-type exists to give break/continue the correct target — the loop actually surrounding the when statement — for commands whose block is not itself loop-shaped.
What Motivates the Three Categories
The three categories are recognized by the compiler purely from the body parameter's name, as described above. This section explains the semantic criterion a framework author should use when deciding which category is the right one to implement for a given command — i.e., which prefix (or the absence of one) is the correct choice, not how the compiler detects it once chosen.
It is tempting to read the three-way (or an original two-way) split as being about "loop vs. not a loop." That reading is wrong. The criterion is about when and how many times the block runs relative to the enclosing call:
Synchronous, call-scoped commands — the block runs zero or more times, but strictly during the call to the command function, and the command function does not return to its caller until the block is done running for this invocation.
repeatUntil,times,forEver, andwhenare all in this category:when's block runs at most once,forEver's runs indefinitely, but in both cases every execution of the block happens whilefoo's stack frame for that statement is still live. These commands takefunc() intwith a__xgo_loop_- or__xgo_cond_-prefixed body parameter — because it is meaningful and safe forreturninside the block to unwind all the way out offoo, and it is meaningful forbreak/continueinside the block to target either the command's own loop (loop-type) or a real enclosing loop (conditional-type).Deferred / event-driven commands — the block is stored by the command (e.g. as an event handler) and invoked later, possibly zero or many times, on its own schedule, generally after the call to the command function itself has already returned.
onStartandonKeyare in this category. By the time such a block actually runs,foomay have already returned — there is no meaningful "return fromfoo" to unwind to, and no meaningful "break out of an enclosing loop" either, since that loop (if any) may no longer be executing. These commands must give their body parameter a name that does not use the__xgo_prefix, so that the block keeps ordinary Go closure semantics, exactly as under the base Auto Lambda proposal — whatever the block's own signature shape happens to be.
Applying this to the motivating example: onStart's block is handed off to run later as an event callback, so its body parameter is named plainly (cmd, not __xgo_...), making it function-type. repeatUntil, when, and forEver's blocks all execute synchronously within the current call, so all three take func() int with a reserved-prefix name; repeatUntil and forEver use __xgo_loop_body, and when uses __xgo_cond_body.
Determining Control-Flow Category From the Lambda's Parameter Name
An Auto Lambda block never takes parameters of its own — a trailing { ... } block is a block, not a parameterized function literal — so the block's Go signature can be any of func() or func() T for some result type T. Regardless of that shape, category is determined entirely by inspecting the body parameter's name:
- Name starts with
__xgo_loop_→ loop-type. The parameter's own type must befunc() int, and the command's own return type must beint; any other signature shape is a compile-time error. - Name starts with
__xgo_cond_→ conditional-type. Same signature requirement as loop-type. - Name starts with
__xgo_but matches neither recognized sub-prefix → compile-time error: reserved prefix, category cannot be determined. - Any other name → function-type. The parameter's own type may be
func()orfunc() Tfor anyT; the command's own return type is unconstrained and, if present, is treated as ordinary data returned to the command's own caller, not as an XGo control-flow status code.
Consistency Requirements
| Body parameter name | Lambda signature | Command's own return type | Category | Valid? |
|---|---|---|---|---|
not starting with __xgo_ |
func() or func() T for any T |
any | function-type | ✅ |
__xgo_loop_... |
func() int |
int |
loop-type | ✅ |
__xgo_cond_... |
func() int |
int |
conditional-type | ✅ |
__xgo_loop_... or __xgo_cond_... |
not func() int |
any | — | ❌ compile-time error: this category requires a func() int body parameter |
__xgo_loop_... or __xgo_cond_... |
func() int |
not int |
— | ❌ compile-time error: command's return type must match its lambda parameter's return type |
__xgo_... (neither recognized sub-prefix) |
any | any | — | ❌ compile-time error: reserved prefix, unrecognized category |
Concretely:
func RepeatUntil(__xgo_autoclosure_cond func() bool, __xgo_loop_body func() int) int {
...
}
func When(__xgo_autoclosure_cond func() bool, __xgo_cond_body func() int) int {
...
}Both RepeatUntil and When share the outer int-returning shape and the func() int body-parameter shape, but it is the __xgo_loop_/__xgo_cond_ prefix on the body parameter's name — not that shared shape — that tells the compiler which dispatch strategy, described below, to generate at each call site. A third command sharing that exact same func() int / int shape but naming its body parameter, say, statusHandler would be function-type: the shape is compatible with loop-type/conditional-type, but since the name carries none of the reserved prefixes, the compiler leaves the block as an ordinary closure and treats the returned int as private data belonging to statusHandler's own implementation.
The guard parameter (__xgo_autoclosure_cond above, an ordinary func() bool autoclosure unrelated to this proposal) is unaffected by any of this and is unchanged from the base Auto Lambda proposal; only the body parameter's name is control-flow-relevant.
Compilation Strategy
The Status-Code Protocol
For a conditional-type or loop-type command, the well-known status values are exposed via a runtime support package (github.com/qiniu/x/xgo):
package xgo
const (
ContinueLabel = -4 // continue with label (ContinueLabel - N)
Continue = -3 // continue without label
ReturnVals = -2 // return with value
Return = -1 // return without value
Break = 1 // break without label
BreakLabel = 2 // break with label (BreakLabel + N)
)A status of 0 means "block finished normally, keep going." This part of the protocol — the constants and the meaning of 0 — is shared by both loop-type and conditional-type; what differs between them is how the call site interprets a nonzero status, described next. This protocol exists only for loop-type and conditional-type blocks; function-type blocks never produce or consume these constants, since their return value (if any) is ordinary closure data private to the command's own implementation.
return is rewritten identically for both categories, since in both cases it targets the enclosing XGo function, not the command:
- Bare
returnrewrites toreturn xgo.Return, relying on the block having already assigned intofoo's named results. return v1, v2, ...rewrites toxgo.SetRetVal(...); return xgo.ReturnVals, boxing the values for the call site to unbox and return positionally.
break and continue, however, are rewritten and dispatched differently depending on category.
Loop-Type Dispatch: break/continue Target the Command's Own Loop
Inside a loop-type block:
| User writes | Compiles to |
|---|---|
break |
return xgo.Break |
break label |
return xgo.BreakLabel + N (N = the label's index among break label uses in this block, starting at 0) |
continue |
return xgo.Continue |
continue label |
return xgo.ContinueLabel - N (N = the label's index among continue label uses in this block, starting at 0) |
where label must name a real enclosing for/switch/select statement that lexically contains the entire Auto Lambda call.
At the call site, the call to the loop-type command is wrapped in a switch with a goto-based continue label. Applying the rewrite rules above to a concrete example — a real enclosing labeled loop, a bare break, a labeled break outer, a bare continue, a labeled continue outer, a value-returning return, and a named-result bare return:
func foo(...) (ret1 T1, ret2 T2, ...) {
x := 0
outer:
for {
repeatUntil x > 10 {
x = modify(x)
if cond1(x) {
break
}
if cond2(x) {
break outer
}
if cond3(x) {
continue
}
if cond4(x) {
continue outer
}
if cond5(x) {
return v1, v2, ...
}
if cond6(x) {
ret1, ret2, ... = v1, v2, ...
return
}
}
}
return ...
}compiles to:
import "github.com/qiniu/x/xgo"
func foo(...) (ret1 T1, ret2 T2, ...) {
x := 0
outer:
for {
_xgo_continue_1:
switch RepeatUntil(
func() bool {
return x > 10
},
func() int {
x = modify(x)
if cond1(x) {
return xgo.Break
}
if cond2(x) {
return xgo.BreakLabel + 0 // break outer
}
if cond3(x) {
return xgo.Continue
}
if cond4(x) {
return xgo.ContinueLabel - 0 // continue outer
}
if cond5(x) {
xgo.SetRetVal(struct{v1 T1; v2 T2; ...}{v1, v2, ...})
return xgo.ReturnVals
}
if cond6(x) {
ret1, ret2, ... = v1, v2, ...
return xgo.Return
}
return 0
},
) {
case xgo.Break:
// no-op — falls through to the code after the loop
case xgo.BreakLabel + 0:
break outer
case xgo.Continue:
goto _xgo_continue_1
case xgo.ContinueLabel - 0:
continue outer
case xgo.Return:
return
case xgo.ReturnVals:
_xgo_ret := xgo.RetVal().(struct{v1 T1; v2 T2; ...})
return _xgo_ret.v1, _xgo_ret.v2, ...
}
}
return ...
}Every break/continue/return in the repeatUntil block has been rewritten per the table above, and the call to RepeatUntil is wrapped in the switch/goto _xgo_continue_1 dispatch shown here. Note in particular:
cond2'sbreak outerbecomesreturn xgo.BreakLabel + 0inside the closure — sinceouteris the first (and only) label this block breaks to, it is assigned index0— and the matchingcase xgo.BreakLabel + 0:executes the realbreak outerstatement back infoo's own scope, which is the only place that label is actually visible.cond4'scontinue outerbecomesreturn xgo.ContinueLabel - 0—outeris likewise the first (and only) label this block continues to, so it gets index0in the separateContinueLabelnumbering — andcase xgo.ContinueLabel - 0:executes the realcontinue outerstatement infoo's scope.cond6assignsv1, v2, ...directly intofoo's named resultsret1, ret2, ...from inside the closure (an ordinary captured-variable assignment, not part of the rewriting), and only the trailing barereturnis rewritten, toreturn xgo.Return. The call site'scase xgo.Return:then does a barereturn, which returns whateverret1, ret2, ...were just set to.cond5'sreturn v1, v2, ...still goes through theSetRetVal/RetValboxing round trip, since it supplies an explicit expression list rather than reusingfoo's named results.
Everything outside the repeatUntil block — the x := 0 initialization, the enclosing outer: loop, and the trailing return ... — is untouched by this rewriting and compiles exactly as written. The label _xgo_continue_1 (rather than a fixed name) ensures that multiple loop-type Auto Lambda blocks within the same function, including nested ones, each get a distinct, compiler-generated continue label (_xgo_continue_2, _xgo_continue_3, ...) with no risk of collision; the same numbering-from-a-fresh-block principle applies to BreakLabel + N indices, as noted above.
case xgo.Break is a no-op, and case xgo.Continue does a goto, because bare break/continue target the command's own (simulated) loop, which has no real Go for construct at the call site — the loop lives inside RepeatUntil's own implementation and is simulated at the call site purely through this dispatch.
func RepeatUntil(__xgo_autoclosure_cond func() bool, __xgo_loop_body func() int) int {
for !__xgo_autoclosure_cond() {
if ret := __xgo_loop_body(); ret != 0 {
return ret
}
}
return 0
}RepeatUntil only ever che
Source: goplus/xgo