Paul King created GROOVY-12126:
----------------------------------
Summary: Non-local control flow from closures (return / break /
continue)
Key: GROOVY-12126
URL: https://issues.apache.org/jira/browse/GROOVY-12126
Project: Groovy
Issue Type: Improvement
Reporter: Paul King
Fix For: 7.x
h2. break / continue via a loop-control protocol (proposed, pending team
review)
h3. Motivation
Groovy already ships a half-built version of this: Closure defines DONE=1,
SKIP=2 and a
mutable {{directive}} field. In practice:
* DONE ("break") is honored by only THREE DGM methods
(collect(Iterable,Collection,Closure),
collectNested, times), via a POST-call check.
* SKIP ("continue") is defined but honored NOWHERE — a dead constant.
* The directive is mutable state on the shared, reusable Closure instance, with
no ergonomic
way to set it from inside the closure body.
Rather than add a wholly new mechanism, revive and repair this into a real
loop-control
protocol that carries break/continue sugar.
h3. Two key facts
# {{continue}} ~= "return from the closure body". For value-IGNORING iterators
(each,
eachWithIndex, times, upto, step), continue needs NO protocol at all — it
desugars to a
plain closure-local {{return}} and the iterator proceeds. Universal, zero
cooperation.
# {{break}} inherently requires the loop to cooperate — no closure can stop an
arbitrary
caller's iteration without the caller checking something. This is unavoidable
(also true
of @Inline, which cooperates by inlining).
h3. Design: a stateless return-sentinel (replaces the fragile directive field)
{code:groovy}
enum LoopControl { BREAK, CONTINUE }
{code}
A body may return {{LoopControl.BREAK}} / {{LoopControl.CONTINUE}} or a normal
value.
Iteration methods interpret it. This is stateless (no shared field ->
thread/re-entrancy
safe) and, per spike, ~0 ns/element overhead.
Per-method semantics:
|| Iterator kind || BREAK || CONTINUE ||
| value-ignoring (each, times, upto, step) | stop | proceed (== plain return) |
| value-consuming (collect, findAll, inject) | stop, do NOT contribute current
element | skip contributing current element |
Note the collect fix: because the sentinel IS the return value, BREAK naturally
excludes the
current element — unlike the legacy post-hoc {{directive}} check, which adds
the value first
and so wrongly includes it (spike: legacy -> [10,20,30,999]; protocol ->
[10,20,30]).
h3. Keyword sugar and how it is gated
Desugaring inside a loop body:
* {{continue}} -> {{return LoopControl.CONTINUE}} (or plain {{return}} for
value-ignoring iterators)
* {{break}} -> {{return LoopControl.BREAK}}
SAFETY GATE: break/continue keyword sugar is enabled ONLY when the target
method is statically
known to honor the protocol (annotated {{@SupportsLoopControl}}); otherwise it
is a compile
error. Without the gate, {{break}} on a non-cooperating method would silently
degrade to
"return from closure" (i.e. behave like continue) — a silent correctness bug.
This trades
@Inline's inlinability requirement for a protocol-conformance requirement, but
remains
DYNAMIC-DISPATCH capable and needs no inlining machinery.
h3. SPI / rollout
* Add {{@SupportsLoopControl}} and roll the sentinel checks across DGM
iterators.
* Expose the annotation + a tiny helper so third-party iterators (the issue's
{{sql.forEachRow}}, GPars, custom builders) can opt in.
* Give the previously-dead SKIP a real meaning ("skip contributing") for
value-consuming
iterators, or explicitly scope break/continue to value-ignoring iterators in
v1.
h3. Evidence (spike (b))
* Correctness: break/each stops early; break/collect excludes the breaking
element;
continue/collect skips it.
* Legacy contrast: the stateful directive {{collect}} wrongly includes the
broken element.
* Overhead: below measurement noise (~0 ns/element; two identity comparisons)
vs ~15 ns per
break for the token-exception path.
h3. Relationship to the other two mechanisms (this is NOT an either/or)
* break/continue -> loop-control protocol (this section): cheap, dynamic,
cooperative.
* return-from-method -> return@m token exception (main GEP section): universal,
~15 ns.
* @Inline (GROOVY-8301 / GROOVY-6880) -> optional optimization that LOWERS
either of the
above to zero-cost real jumps where the callee is statically inlinable and
non-escaping.
It is the fast path, not the only path.
h3. Compatibility
* Legacy {{Closure.DONE}} / {{Closure.SKIP}} / {{directive}}: keep honoring on
the current 3
methods for backward compatibility; deprecate in favor of the return-sentinel
protocol.
* No change to bare {{return}} semantics. New sentinel returns are additive;
existing closures
that never return a LoopControl are unaffected (identity check).
h3. Open questions (pending team review)
* Sentinel spelling: enum {{LoopControl.BREAK/CONTINUE}} vs reusing/rehoming
{{Closure.DONE/SKIP}}.
* Should value-ignoring {{continue}} desugar to bare {{return}} (zero protocol)
or always to
the sentinel for uniformity?
* Whether a labeled form ({{break@method}} / {{continue@method}}) is wanted for
nested loops,
mirroring {{return@method}}.
* Escape/async: a body returning a sentinel to a NON-protocol caller is
silently ignored;
the static gate prevents the keyword form, but a hand-written {{return
LoopControl.BREAK}}
could still be misused — document, and consider a best-effort lint.
h2. Updated Scope / Non-goals (supersedes the earlier Scope section)
* In scope: value-returning non-local return ({{return@m}}) AND break/continue
via a
loop-control protocol.
* Out of scope: changing bare {{return}} semantics; making {{break}} work
through an
arbitrary NON-cooperating method (impossible); full closure inlining (that is
the separate
@Inline / GROOVY-6880 track, referenced as the zero-cost optimization).
--
This message was sent by Atlassian Jira
(v8.20.10#820010)