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/return inside 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 be func() int and the command's own return type to be int.
- 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, and when are 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 while foo's stack frame for that statement is still live. These commands take func() int with a __xgo_loop_- or __xgo_cond_-prefixed body parameter — because it is meaningful and safe for return inside the block to unwind all the way out of foo, and it is meaningful for break/continue inside 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. onStart and onKey are in this category. By the time such a block actually runs, foo may have already returned — there is no meaningful "return from foo" 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 be func() int, and the command's own return type must be int; 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() or func() T for any T; 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
return rewrites to return xgo.Return, relying on the block having already assigned into foo's named results.
return v1, v2, ... rewrites to xgo.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's break outer becomes return xgo.BreakLabel + 0 inside the closure — since outer is the first (and only) label this block breaks to, it is assigned index 0 — and the matching case xgo.BreakLabel + 0: executes the real break outer statement back in foo's own scope, which is the only place that label is actually visible.
cond4's continue outer becomes return xgo.ContinueLabel - 0 — outer is likewise the first (and only) label this block continues to, so it gets index 0 in the separate ContinueLabel numbering — and case xgo.ContinueLabel - 0: executes the real continue outer statement in foo's scope.
cond6 assigns v1, v2, ... directly into foo's named results ret1, ret2, ... from inside the closure (an ordinary captured-variable assignment, not part of the rewriting), and only the trailing bare return is rewritten, to return xgo.Return. The call site's case xgo.Return: then does a bare return, which returns whatever ret1, ret2, ... were just set to.
cond5's return v1, v2, ... still goes through the SetRetVal/RetVal boxing round trip, since it supplies an explicit expression list rather than reusing foo'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 checks ret != 0 and returns it up unexamined — all the differentiated behavior lives in the call-site switch shown above.
Conditional-Type Dispatch: break/continue Target the Enclosing Loop
Inside a conditional-type block, bare break/continue do not refer to anything belonging to the command itself — when has no loop of its own to break or continue. Instead they refer to the nearest real loop construct lexically enclosing the entire conditional-type call — exactly as if the command call were replaced by a native if:
| User writes |
Compiles to |
break |
return xgo.Break |
break label |
return xgo.BreakLabel + N, label names an enclosing for/switch/select |
continue |
return xgo.Continue |
continue label |
return xgo.ContinueLabel - N, label must name an enclosing for |
Syntactically inside the block this looks identical to the loop-type table above — the rewriting inside the closure is the same four rules either way. The difference is entirely in what the call site does with the status code once it comes back out, because the target of a bare break/continue is not the command's own loop but whatever real loop happens to lexically surround the whole statement — which the command itself has no knowledge of or control over.
This target resolution needs no rule of its own. A conditional-type command call is, from the point of view of break/continue target resolution, indistinguishable from a native if statement written at the same source position — that is the entire point of the category. So the compiler resolves a bare break/continue written inside a conditional-type block exactly the way it would resolve a bare break/continue written inside an if at that position: walk outward from the when statement, over the original (pre-rewrite) source, to the nearest enclosing for/switch/select (for break) or nearest enclosing for (for continue). This is standard Go break/continue resolution, mechanically substituting the conditional-type call for the if it behaves like — it is not a new resolution algorithm invented by this proposal.
In particular, this resolution is not restricted to loops: if the nearest enclosing construct is itself a switch or select rather than a for, break targets that switch/select, exactly as it would for a literal if sitting inside a switch case:
switch y {
case 1:
when x == 11 {
break // targets the enclosing "switch y", exactly as "if x == 11 { break }" would
}
}
The only thing this proposal adds on top of ordinary Go resolution is bookkeeping needed purely because of how the block is compiled, not because its target-resolution semantics are special: the compiler's own generated dispatch switch (shown below) sits between the resolved construct and the case xgo.Break arm, and an unlabeled break written literally in that arm would incorrectly break the generated dispatch switch itself rather than the construct that was actually resolved. So, once the target construct is resolved by the ordinary algorithm above, the compiler assigns it a compiler-generated label if it does not already have a user label, purely so the generated code can name it explicitly and see through its own wrapper switch. This labeling is an artifact of code generation, not a different rule for choosing the target.
The call site then dispatches to a real break/continue targeting that (possibly synthetic) label, rather than the no-op/goto used for loop-type:
outer:
for {
switch When(
func() bool { return x == 11 },
func() int {
echo("x = 11")
if verbose {
return xgo.Break
}
return 0
},
) {
case xgo.Break:
break outer // real break of the loop enclosing the `when` statement
case xgo.Continue:
continue outer // real continue of the same loop, had the user written bare `continue`
case xgo.Return:
return
case xgo.ReturnVals:
_xgo_ret := xgo.RetVal().(struct{ ... })
return _xgo_ret.v1, _xgo_ret.v2, ...
}
}
Note that case xgo.Break: break outer uses the real, lexically-correct enclosing loop's label, auto-generated by the compiler if outer was not already user-written — it is not the switch statement's own (which a bare break written literally inside a switch's case would otherwise target, per ordinary Go semantics). This is why the label cannot be omitted here the way it is for loop-type's case xgo.Break: // no-op: a literal, unlabeled break inside this generated switch would just exit the switch itself, silently doing nothing observable — which is wrong. The compiler must always emit an explicit label in the conditional-type case xgo.Break arm, synthesizing one if the resolved enclosing loop is not already labeled by the user. continue, by contrast, is not captured by an enclosing switch in Go — a bare continue written inside the case already reaches the nearest enclosing for correctly — but this proposal has the compiler emit it explicitly labeled anyway, for symmetry and to make the generated code's target unambiguous to read.
If no enclosing for/switch/select lexically contains the when statement at all, a bare break/continue inside when's block is a compile-time error — "break/continue not in a loop" — exactly as Go itself rejects a bare break/continue at the top level of a function. This is the base case of a slightly more general algorithm, covered next, for when the walk outward passes through another control-flow-enabled Auto Lambda block before it ever reaches a real native construct.
Resolving Targets Through Nested Control-Flow-Enabled Blocks
A conditional-type block can itself be nested inside another loop-type or conditional-type block, e.g. a when inside a repeatUntil:
repeatUntil x > 10 {
when cond(x) {
break // does this mean "stop repeatUntil", or something else?
}
x++
}
Here, walking outward from break to find "the nearest enclosing loop, exactly as if when were if" does not immediately reach a real native for/switch/select — it first reaches the boundary of the enclosing repeatUntil block. This is not a special case requiring new machinery: the compiler simply recurses, treating the crossing of that boundary as if the break had been written directly inside repeatUntil's own block at that point, and compiles it using whatever rule already applies there:
-
If the immediately enclosing block is loop-type (as repeatUntil is here), that rule is already "bare break/continue targets the loop-type command's own loop," which compiles to exactly return xgo.Break / return xgo.Continue — the same rewrite when's own dispatch already needs to produce to hand the status back up one level. So resolution terminates immediately: when's call-site dispatch propagates the status by returning it from within repeatUntil's enclosing func() int body, and repeatUntil's own (already-defined) loop-type dispatch takes it from there, exactly as if a bare break had been written directly in repeatUntil's block instead of inside the nested when. No new rule was needed — the existing loop-type rewrite rule already produces the right code once the recursion reaches it.
-
If the immediately enclosing block is conditional-type instead (a when nested inside another when), that rule is "bare break/continue targets whatever lexically encloses that block" — so resolution does not terminate; it recurses one level further outward, from the outer when's own position, applying this same algorithm again. Each recursive step either terminates (real native construct found, or a loop-type block reached) or continues outward by one more level.
Concretely, for the example above, when's call site compiles to:
switch RepeatUntil(
func() bool { return x > 10 },
func() int {
switch When(
func() bool { return cond(x) },
func() int {
return xgo.Break
},
) {
case xgo.Break:
return xgo.Break // propagate: recursion terminated at the enclosing loop-type block
case xgo.Return:
return xgo.Return
case xgo.ReturnVals:
return xgo.ReturnVals
}
x++
return 0
},
) {
case xgo.Break:
// no-op — repeatUntil's own loop-type rule, applied to the propagated status
case xgo.Return:
return
case xgo.ReturnVals:
_xgo_ret := xgo.RetVal().(struct{ ... })
return _xgo_ret.v1, _xgo_ret.v2, ...
}
when's case xgo.Break does not synthesize a label at all here, because the nearest enclosing thing found by the walk is repeatUntil's own block, not a real native loop — so there is nothing to label. It simply re-returns the same status, and it is repeatUntil's own already-specified case xgo.Break: // no-op that gives the status its final meaning. The same reasoning applies unchanged to continue, to labeled break label/continue label (a label lookup that must resolve to a real native construct still walks outward through any number of intervening control-flow-enabled blocks the same way, hopping via return xgo.BreakLabel + N / return xgo.ContinueLabel - N at each crossing until the block whose immediately-enclosing real scope contains the label is reached, where the real labeled break/continue statement is finally emitted), and to conditional-type nested inside conditional-type (the recursive case above), and to loop-type's own break label/continue label when the named label lies outside more than one level of nesting.
func When(__xgo_autoclosure_cond func() bool, __xgo_cond_body func() int) int {
for __xgo_autoclosure_cond() {
return __xgo_cond_body()
}
return 0
}
Note When's own implementation is, like RepeatUntil, agnostic to which status code it received — it just returns whatever __xgo_cond_body() produced. All of the "this actually means break the outer loop, not stop When" logic lives entirely in the call-site dispatch, not in When itself. This mirrors the loop-type design: command implementers write the same trivial pass-through regardless of category, and only the compiler-generated call-site switch differs.
Function-Type: No Dispatch Wrapper
A function-type command's block is compiled as an ordinary closure: no switch wrapper is generated, and break/continue used at the top level of the block are compile-time errors, because there is no native for/switch/select construct enclosing them within the closure itself. return, by contrast, is not rewritten at all for function-type — it keeps its ordinary Go closure meaning: it returns control to whatever invokes the closure (i.e., the command's own implementation, later, not foo), and must supply a value matching the closure's own declared result type (nothing, for func(); a T, for func() T).
Framework authors are free to give a function-type command's body parameter a non-func() shape — e.g. a return type the framework uses purely for its own bookkeeping. That return value is ordinary closure data, entirely private to the command's own implementation, and has nothing to do with XGo's return statement targeting foo; this is precisely why it is absent from the status-code protocol described above, which exists only for loop-type and conditional-type.
onStart(func() {
x := 1
step 1
})
onStart {
step 1
break // compile error: onStart's body parameter is named "cmd", which does not use
// the __xgo_ prefix, so this block is function-type — its block may run after
// foo has already returned, so it cannot participate in break/continue at all.
}
AST-Level Representation
LambdaExpr keeps the AutoLambda boolean introduced by the base proposal (recording spelling only). No new field is required at parse time: category is not a syntactic property of the block, it is derived once during type checking, when the compiler resolves the command's function signature and inspects the body parameter's name. A __xgo_loop_ or __xgo_cond_ prefix determines loop-type or conditional-type respectively, and additionally requires the func() int / int-return consistency described above; a __xgo_-prefixed name matching neither is a compile-time error; any other name determines function-type, independent of the lambda's own signature shape. Implementations may cache the resolved category as an internal flag for the statement-rewriting and call-site-generation passes; this is an implementation detail, not part of the AST's syntactic contract.
Worked Example: All Three Categories Together
Given:
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 { ... }
func OnStart(cmd func()) { ... }
the motivating example:
onStart {
x := 1
repeatUntil x > 10 {
echo "Hi"
x++
}
when x == 11 {
echo "x = 11"
}
forEver {
step 1
}
}
compiles as: onStart's block is an ordinary closure (function-type, since OnStart's body parameter is named cmd, not __xgo_...), containing three call-scoped statements. repeatUntil and forEver each get the loop-type dispatch (case xgo.Break: // no-op, case xgo.Continue: goto ...), since their commands' body parameters use the __xgo_loop_ prefix. when gets the conditional-type dispatch, since When's body parameter uses the __xgo_cond_ prefix — but note that in this particular example, when's block contains no break/continue at all, so no enclosing-loop resolution is even triggered; that machinery only activates once a user actually writes a bare or labeled break/continue inside a conditional-type block. Had the when block instead read:
when x == 11 {
echo "x = 11"
break
}
this particular when statement is not lexically inside any for/switch/select (it sits directly inside onStart's function-type block, which is not a loop), so this break is a compile-time error — there is nothing for it to break out of, exactly as a bare break written directly inside onStart's block (with no when at all) would also be an error.
Compatibility
- Function-type commands are entirely unaffected by this proposal; their Auto Lambda blocks remain ordinary closures, exactly as under the base Auto Lambda proposal.
- Explicit
=> { ... } call sites are entirely unaffected, for every command, regardless of category.
- Any Auto-Lambda-eligible command automatically becomes conditional-type or loop-type the moment its Go implementation's body parameter is renamed to use the
__xgo_cond_ or __xgo_loop_ prefix — the compiler then requires the matching func() int / int-return signature described above. Category is entirely a function of that one name, with nothing else to declare.
- Renaming a command's body parameter into or out of the
__xgo_ namespace, or between the __xgo_cond_ and __xgo_loop_ prefixes, is a behavior-changing, not purely additive, change for existing call sites — a call site using a bare break/continue that previously failed to compile (or compiled with a different target, or was ordinary uninterpreted closure code) will compile with new, different semantics after such a rename, even if the lambda's own signature type does not otherwise change. Framework authors should treat any such rename as a semantic version bump, not a patch-level change.
- Any body parameter name beginning with
__xgo_ that is not covered by this proposal's two recognized sub-prefixes is reserved and rejected at compile time, rather than silently falling back to function-type — this leaves room for future control-flow categories without changing the meaning of existing code.
- The specific numeric values of the
xgo status constants, and the __xgo_loop_/__xgo_cond_ parameter name prefixes themselves, are implementation details of the compilation strategy; ordinary XGo users never write them directly (only framework authors implementing new commands do), and they are not part of any source-level compatibility surface for XGo users (as opposed to framework authors).
Open Questions
- Synthetic label collision and naming. The conditional-type call site may need to synthesize a label on an enclosing
for/switch/select that the user did not label themselves. This proposal assumes such labels can be generated with the same collision-free numbering scheme already used for _xgo_continue_N, but does not specify whether the synthetic label is inserted once (and reused if multiple conditional-type blocks inside the same loop need to target it) or generated freshly per use site.
- Panics and
recover inside a control-flow-enabled block. The status-code protocol only intercepts break/continue/return; a panic propagates as an ordinary Go panic. Left to the runtime-support-package design.
- Should
return v1, v2, ... avoid boxing when foo has named results? Still open, and applies identically to both loop-type and conditional-type.
- Tooling for reserved-prefix errors. When the compiler rejects a body parameter name that starts with
__xgo_ but matches neither recognized sub-prefix, should it suggest the nearest valid prefix, or suggest dropping the __xgo_ prefix entirely to fall back to function-type? Left to implementation/tooling.
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, andreturnare used.Background
Consider a function
foothat usesrepeatUntil, an Auto-Lambda-enabled command, to loop until some condition holds:Auto Lambda lets
repeatUntil x > 10 { ... }be written without=>, but the block passed torepeatUntilis not actually inlined intofoo— it is compiled as an ordinary Go closure and passed as a function value torepeatUntil's implementation. Go'sbreak,continue, andreturnare 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/continuewould simply be illegal (there is no enclosingfor/switch/selectinside the closure), andreturn v1, v2, ...would return from the anonymous closure itself — which discards the values, sincerepeatUntil's implementation does not know what to do with an arbitrary(T1, T2, ...)tuple. Control would fall back to whereverrepeatUntilitself returns to (i.e. back intofoo, at the call site), not out offooentirely as the user'sreturnstatement visually suggests.Certain Auto Lambda commands need
break,continue, andreturninside 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
=> { ... }) any new control-flow behavior, regardless of which command they call. Explicit lambdas always keep ordinary Go closure semantics, exactly as today.break/continue/returninside it changes, for specific classes of signatures, via the mechanism described below.Motivating Example
Four different commands appear here:
onStart,repeatUntil,when, andforEver. 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 treatingwhenthe same way asrepeatUntilgivesbreak/continueinside awhenblock the wrong target: a user writingbreakinsidewhenalmost always means "break the loopwhenis sitting inside," not "stop evaluatingwhenitself" (which isn't even a coherent thing to break out of, sincewhen's body runs at most once). This proposal therefore splits Auto-Lambda-enabled commands into three categories, not two.Three Categories of Commands
break/continuetargetreturntarget__xgo_func()orfunc() Tfor anyTonStart,onKey__xgo_cond_func() intintfoo)when__xgo_loop_func() intintfoo)repeatUntil,times,forEverThe 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 shapedfunc() intis 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 anintfor 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:
__xgo_loop_→ loop-type. Requires the parameter's own type to befunc() intand the command's own return type to beint.__xgo_cond_→ conditional-type. Same signature requirement as loop-type.__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.)__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 tocontinueto, and nothing ofwhen's "own" tobreakout of. Given:a user writing
breakhere is readingwhen { ... }exactly as they would read a nativeif x == 11 { ... }block — and a nativebreakinside a nativeifbreaks the nearest enclosingfor/switch/select, which isouter, not theif.whenshould behave identically. Treatingwhenas loop-type would makebreaka no-op that merely stops evaluatingwhen's own block — which is indistinguishable fromwhen's block simply finishing normally, and is never what the user means. Conditional-type exists to givebreak/continuethe correct target — the loop actually surrounding thewhenstatement — 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, andforEver's blocks all execute synchronously within the current call, so all three takefunc() intwith a reserved-prefix name;repeatUntilandforEveruse__xgo_loop_body, andwhenuses__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 offunc()orfunc() Tfor some result typeT. Regardless of that shape, category is determined entirely by inspecting the body parameter's name:__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.__xgo_cond_→ conditional-type. Same signature requirement as loop-type.__xgo_but matches neither recognized sub-prefix → compile-time error: reserved prefix, category cannot be determined.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
__xgo_func()orfunc() Tfor anyT__xgo_loop_...func() intint__xgo_cond_...func() intint__xgo_loop_...or__xgo_cond_...func() intfunc() intbody parameter__xgo_loop_...or__xgo_cond_...func() intint__xgo_...(neither recognized sub-prefix)Concretely:
Both
RepeatUntilandWhenshare the outerint-returning shape and thefunc() intbody-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 samefunc() int/intshape but naming its body parameter, say,statusHandlerwould 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 returnedintas private data belonging tostatusHandler's own implementation.The guard parameter (
__xgo_autoclosure_condabove, an ordinaryfunc() boolautoclosure 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):A status of
0means "block finished normally, keep going." This part of the protocol — the constants and the meaning of0— 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.returnis rewritten identically for both categories, since in both cases it targets the enclosing XGo function, not the command: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.breakandcontinue, however, are rewritten and dispatched differently depending on category.Loop-Type Dispatch:
break/continueTarget the Command's Own LoopInside a loop-type block:
breakreturn xgo.Breakbreak labelreturn xgo.BreakLabel + N(N= the label's index amongbreak labeluses in this block, starting at 0)continuereturn xgo.Continuecontinue labelreturn xgo.ContinueLabel - N(N= the label's index amongcontinue labeluses in this block, starting at 0)where
labelmust name a real enclosingfor/switch/selectstatement that lexically contains the entire Auto Lambda call.At the call site, the call to the loop-type command is wrapped in a
switchwith agoto-based continue label. Applying the rewrite rules above to a concrete example — a real enclosing labeled loop, a barebreak, a labeledbreak outer, a barecontinue, a labeledcontinue outer, a value-returningreturn, and a named-result barereturn:compiles to:
Every
break/continue/returnin therepeatUntilblock has been rewritten per the table above, and the call toRepeatUntilis wrapped in theswitch/goto _xgo_continue_1dispatch 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
repeatUntilblock — thex := 0initialization, the enclosingouter:loop, and the trailingreturn ...— 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 toBreakLabel + Nindices, as noted above.case xgo.Breakis a no-op, andcase xgo.Continuedoes agoto, because barebreak/continuetarget the command's own (simulated) loop, which has no real Goforconstruct at the call site — the loop lives insideRepeatUntil's own implementation and is simulated at the call site purely through this dispatch.RepeatUntilonly ever checksret != 0and returns it up unexamined — all the differentiated behavior lives in the call-siteswitchshown above.Conditional-Type Dispatch:
break/continueTarget the Enclosing LoopInside a conditional-type block, bare
break/continuedo not refer to anything belonging to the command itself —whenhas no loop of its own to break or continue. Instead they refer to the nearest real loop construct lexically enclosing the entire conditional-type call — exactly as if the command call were replaced by a nativeif:breakreturn xgo.Breakbreak labelreturn xgo.BreakLabel + N,labelnames an enclosingfor/switch/selectcontinuereturn xgo.Continuecontinue labelreturn xgo.ContinueLabel - N,labelmust name an enclosingforSyntactically inside the block this looks identical to the loop-type table above — the rewriting inside the closure is the same four rules either way. The difference is entirely in what the call site does with the status code once it comes back out, because the target of a bare
break/continueis not the command's own loop but whatever real loop happens to lexically surround the whole statement — which the command itself has no knowledge of or control over.This target resolution needs no rule of its own. A conditional-type command call is, from the point of view of
break/continuetarget resolution, indistinguishable from a nativeifstatement written at the same source position — that is the entire point of the category. So the compiler resolves a barebreak/continuewritten inside a conditional-type block exactly the way it would resolve a barebreak/continuewritten inside anifat that position: walk outward from thewhenstatement, over the original (pre-rewrite) source, to the nearest enclosingfor/switch/select(forbreak) or nearest enclosingfor(forcontinue). This is standard Go break/continue resolution, mechanically substituting the conditional-type call for theifit behaves like — it is not a new resolution algorithm invented by this proposal.In particular, this resolution is not restricted to loops: if the nearest enclosing construct is itself a
switchorselectrather than afor,breaktargets thatswitch/select, exactly as it would for a literalifsitting inside aswitchcase:The only thing this proposal adds on top of ordinary Go resolution is bookkeeping needed purely because of how the block is compiled, not because its target-resolution semantics are special: the compiler's own generated dispatch
switch(shown below) sits between the resolved construct and thecase xgo.Breakarm, and an unlabeledbreakwritten literally in that arm would incorrectly break the generated dispatchswitchitself rather than the construct that was actually resolved. So, once the target construct is resolved by the ordinary algorithm above, the compiler assigns it a compiler-generated label if it does not already have a user label, purely so the generated code can name it explicitly and see through its own wrapperswitch. This labeling is an artifact of code generation, not a different rule for choosing the target.The call site then dispatches to a real
break/continuetargeting that (possibly synthetic) label, rather than the no-op/gotoused for loop-type:Note that
case xgo.Break: break outeruses the real, lexically-correct enclosing loop's label, auto-generated by the compiler ifouterwas not already user-written — it is not the switch statement's own (which a barebreakwritten literally inside aswitch'scasewould otherwise target, per ordinary Go semantics). This is why the label cannot be omitted here the way it is for loop-type'scase xgo.Break: // no-op: a literal, unlabeledbreakinside this generatedswitchwould just exit theswitchitself, silently doing nothing observable — which is wrong. The compiler must always emit an explicit label in the conditional-typecase xgo.Breakarm, synthesizing one if the resolved enclosing loop is not already labeled by the user.continue, by contrast, is not captured by an enclosingswitchin Go — a barecontinuewritten inside thecasealready reaches the nearest enclosingforcorrectly — but this proposal has the compiler emit it explicitly labeled anyway, for symmetry and to make the generated code's target unambiguous to read.If no enclosing
for/switch/selectlexically contains thewhenstatement at all, a barebreak/continueinsidewhen's block is a compile-time error — "break/continue not in a loop" — exactly as Go itself rejects a barebreak/continueat the top level of a function. This is the base case of a slightly more general algorithm, covered next, for when the walk outward passes through another control-flow-enabled Auto Lambda block before it ever reaches a real native construct.Resolving Targets Through Nested Control-Flow-Enabled Blocks
A conditional-type block can itself be nested inside another loop-type or conditional-type block, e.g. a
wheninside arepeatUntil:Here, walking outward from
breakto find "the nearest enclosing loop, exactly as ifwhenwereif" does not immediately reach a real nativefor/switch/select— it first reaches the boundary of the enclosingrepeatUntilblock. This is not a special case requiring new machinery: the compiler simply recurses, treating the crossing of that boundary as if thebreakhad been written directly insiderepeatUntil's own block at that point, and compiles it using whatever rule already applies there:If the immediately enclosing block is loop-type (as
repeatUntilis here), that rule is already "barebreak/continuetargets the loop-type command's own loop," which compiles to exactlyreturn xgo.Break/return xgo.Continue— the same rewritewhen's own dispatch already needs to produce to hand the status back up one level. So resolution terminates immediately:when's call-site dispatch propagates the status by returning it from withinrepeatUntil's enclosingfunc() intbody, andrepeatUntil's own (already-defined) loop-type dispatch takes it from there, exactly as if a barebreakhad been written directly inrepeatUntil's block instead of inside the nestedwhen. No new rule was needed — the existing loop-type rewrite rule already produces the right code once the recursion reaches it.If the immediately enclosing block is conditional-type instead (a
whennested inside anotherwhen), that rule is "barebreak/continuetargets whatever lexically encloses that block" — so resolution does not terminate; it recurses one level further outward, from the outerwhen's own position, applying this same algorithm again. Each recursive step either terminates (real native construct found, or a loop-type block reached) or continues outward by one more level.Concretely, for the example above,
when's call site compiles to:when'scase xgo.Breakdoes not synthesize a label at all here, because the nearest enclosing thing found by the walk isrepeatUntil's own block, not a real native loop — so there is nothing to label. It simply re-returns the same status, and it isrepeatUntil's own already-specifiedcase xgo.Break: // no-opthat gives the status its final meaning. The same reasoning applies unchanged tocontinue, to labeledbreak label/continue label(a label lookup that must resolve to a real native construct still walks outward through any number of intervening control-flow-enabled blocks the same way, hopping viareturn xgo.BreakLabel + N/return xgo.ContinueLabel - Nat each crossing until the block whose immediately-enclosing real scope contains the label is reached, where the real labeledbreak/continuestatement is finally emitted), and to conditional-type nested inside conditional-type (the recursive case above), and to loop-type's ownbreak label/continue labelwhen the named label lies outside more than one level of nesting.Note
When's own implementation is, likeRepeatUntil, agnostic to which status code it received — it just returns whatever__xgo_cond_body()produced. All of the "this actually means break the outer loop, not stop When" logic lives entirely in the call-site dispatch, not inWhenitself. This mirrors the loop-type design: command implementers write the same trivial pass-through regardless of category, and only the compiler-generated call-siteswitchdiffers.Function-Type: No Dispatch Wrapper
A function-type command's block is compiled as an ordinary closure: no
switchwrapper is generated, andbreak/continueused at the top level of the block are compile-time errors, because there is no nativefor/switch/selectconstruct enclosing them within the closure itself.return, by contrast, is not rewritten at all for function-type — it keeps its ordinary Go closure meaning: it returns control to whatever invokes the closure (i.e., the command's own implementation, later, notfoo), and must supply a value matching the closure's own declared result type (nothing, forfunc(); aT, forfunc() T).Framework authors are free to give a function-type command's body parameter a non-
func()shape — e.g. a return type the framework uses purely for its own bookkeeping. That return value is ordinary closure data, entirely private to the command's own implementation, and has nothing to do with XGo'sreturnstatement targetingfoo; this is precisely why it is absent from the status-code protocol described above, which exists only for loop-type and conditional-type.AST-Level Representation
LambdaExprkeeps theAutoLambdaboolean introduced by the base proposal (recording spelling only). No new field is required at parse time: category is not a syntactic property of the block, it is derived once during type checking, when the compiler resolves the command's function signature and inspects the body parameter's name. A__xgo_loop_or__xgo_cond_prefix determines loop-type or conditional-type respectively, and additionally requires thefunc() int/int-return consistency described above; a__xgo_-prefixed name matching neither is a compile-time error; any other name determines function-type, independent of the lambda's own signature shape. Implementations may cache the resolved category as an internal flag for the statement-rewriting and call-site-generation passes; this is an implementation detail, not part of the AST's syntactic contract.Worked Example: All Three Categories Together
Given:
the motivating example:
compiles as:
onStart's block is an ordinary closure (function-type, sinceOnStart's body parameter is namedcmd, not__xgo_...), containing three call-scoped statements.repeatUntilandforEvereach get the loop-type dispatch (case xgo.Break: // no-op,case xgo.Continue: goto ...), since their commands' body parameters use the__xgo_loop_prefix.whengets the conditional-type dispatch, sinceWhen's body parameter uses the__xgo_cond_prefix — but note that in this particular example,when's block contains nobreak/continueat all, so no enclosing-loop resolution is even triggered; that machinery only activates once a user actually writes a bare or labeledbreak/continueinside a conditional-type block. Had thewhenblock instead read:this particular
whenstatement is not lexically inside anyfor/switch/select(it sits directly insideonStart's function-type block, which is not a loop), so thisbreakis a compile-time error — there is nothing for it to break out of, exactly as a barebreakwritten directly insideonStart's block (with nowhenat all) would also be an error.Compatibility
=> { ... }call sites are entirely unaffected, for every command, regardless of category.__xgo_cond_or__xgo_loop_prefix — the compiler then requires the matchingfunc() int/int-return signature described above. Category is entirely a function of that one name, with nothing else to declare.__xgo_namespace, or between the__xgo_cond_and__xgo_loop_prefixes, is a behavior-changing, not purely additive, change for existing call sites — a call site using a barebreak/continuethat previously failed to compile (or compiled with a different target, or was ordinary uninterpreted closure code) will compile with new, different semantics after such a rename, even if the lambda's own signature type does not otherwise change. Framework authors should treat any such rename as a semantic version bump, not a patch-level change.__xgo_that is not covered by this proposal's two recognized sub-prefixes is reserved and rejected at compile time, rather than silently falling back to function-type — this leaves room for future control-flow categories without changing the meaning of existing code.xgostatus constants, and the__xgo_loop_/__xgo_cond_parameter name prefixes themselves, are implementation details of the compilation strategy; ordinary XGo users never write them directly (only framework authors implementing new commands do), and they are not part of any source-level compatibility surface for XGo users (as opposed to framework authors).Open Questions
for/switch/selectthat the user did not label themselves. This proposal assumes such labels can be generated with the same collision-free numbering scheme already used for_xgo_continue_N, but does not specify whether the synthetic label is inserted once (and reused if multiple conditional-type blocks inside the same loop need to target it) or generated freshly per use site.recoverinside a control-flow-enabled block. The status-code protocol only interceptsbreak/continue/return; apanicpropagates as an ordinary Go panic. Left to the runtime-support-package design.return v1, v2, ...avoid boxing whenfoohas named results? Still open, and applies identically to both loop-type and conditional-type.__xgo_but matches neither recognized sub-prefix, should it suggest the nearest valid prefix, or suggest dropping the__xgo_prefix entirely to fall back to function-type? Left to implementation/tooling.