This is an automated email from the ASF dual-hosted git repository.

paulk-asert pushed a commit to branch asf-site
in repository https://gitbox.apache.org/repos/asf/groovy-website.git


The following commit(s) were added to refs/heads/asf-site by this push:
     new 84e29fd  updated with recent changes for GROOVY-12151 (try it on 
groovy-macro)
84e29fd is described below

commit 84e29fd802c9c589a712bd39cf98f1697685fe20
Author: Paul King <[email protected]>
AuthorDate: Wed Jul 15 17:03:36 2026 +1000

    updated with recent changes for GROOVY-12151 (try it on groovy-macro)
---
 site/src/site/wiki/GEP-27.adoc | 92 +++++++++++++++++++++++++++++++-----------
 1 file changed, 68 insertions(+), 24 deletions(-)

diff --git a/site/src/site/wiki/GEP-27.adoc b/site/src/site/wiki/GEP-27.adoc
index cf825e9..87d4d98 100644
--- a/site/src/site/wiki/GEP-27.adoc
+++ b/site/src/site/wiki/GEP-27.adoc
@@ -127,9 +127,15 @@ _same_ primitive that fixes the closure explosion. That 
shared primitive is the
   API is reachable. Anything a compact representation cannot provide is a 
reason to _decline_
   the optimisation for that closure, not to change its meaning.
 
-* *Sound by analysis under static, sound by declaration under dynamic.* Static 
compilation
-  supplies the types needed to prove an optimisation safe and to make the 
hoisted body fast;
-  dynamic compilation cannot prove it, so there the optimisation is opt-in.
+* *Sound by syntax where possible, by analysis under static, by declaration 
otherwise.* The one
+  behaviour packing cannot reproduce is delegate resolution of a _free name_ — 
an unqualified
+  (implicit-this) call or a name the runtime resolves through the 
owner/delegate chain. A closure
+  that contains no such name (only parameters, locals, captures, constants and 
explicit/parameter
+  receivers) cannot be affected by any delegate, so it is delegate-independent 
by syntax alone, no
+  types needed. For closures that _do_ contain a free name, static 
compilation's type checker can
+  prove whether it resolves to the owner (safe) or a delegate (unsafe); 
dynamic compilation cannot
+  prove that, so there the free-name subset is opt-in (declared via 
`@PackedClosures`, backed by a
+  runtime guard). Static compilation additionally makes the hoisted body fast 
(typed dispatch).
 
 * *Ship the safe corner first.* The functional-interface (SAM) case is sound 
by construction —
   a SAM target cannot express the dangerous closure behaviours. It lands first 
and proves the
@@ -206,13 +212,15 @@ flips which half of the problem is hard:
 | |Dynamic |`@CompileStatic`
 
 |*Mechanism* |Trivial — uniform runtime dispatch, no type story |Harder — the 
hoisted body must be typed and the adapter must present the right type
-|*Soundness* |Hard — no types, so the dangerous delegate case cannot be 
detected; must be trusted |Feasible — types make escape analysis and 
`@DelegatesTo` detection possible
-|*Performance upside* |~None beyond class count (dispatch was dynamic anyway) 
|Real — the hoisted body can be statically dispatched, and SAM targets reach 
`LambdaMetafactory`
+|*Soundness* |Split: a closure with no free name is provably safe by syntax; 
one with a free name cannot be checked without types and is trusted 
(`@PackedClosures` + runtime guard) |Feasible for all: types prove 
owner-vs-delegate resolution of every free name, plus escape and `@DelegatesTo` 
detection
+|*Performance upside* |Class count (and, for capturing closures, faster 
dispatch — see _Performance_) |Also real: the hoisted body can be statically 
dispatched, and SAM targets reach `LambdaMetafactory`
 |===
 
-The consequence is that the "easy" dynamic case is the _low-value_ one 
(unsound without a
-declaration, no speed gain), and the "hard" static case is where the 
optimisation is both
-_provably safe_ and _valuable_.
+So the difficulty is not "dynamic vs static" but "free name vs not". The 
no-free-name subset — a
+large and common class (comparators, mappers, predicates over their own 
parameters, e.g.
+`{ x -> x + 1 }` or `{ a, b -> a <=> b }`) — is provably safe in _either_ mode 
with no types. The
+free-name subset is where static compilation's proof earns its keep, and where 
dynamic compilation
+must fall back to the trust declaration.
 
 === The rest are inputs and cost dials
 
@@ -221,9 +229,10 @@ _provably safe_ and _valuable_.
   programmer saying "treat me as a function." A brace closure and an arrow 
lambda targeting the
   _same_ functional interface compile differently today for exactly this 
reason.
 * *The `@PackedClosures` annotation* is the same kind of thing: a programmer 
assertion that a
-  closure needs no external delegate, supplied where the compiler cannot prove 
it (i.e. under
-  dynamic compilation). It is named for what it packs — closures; a dynamic 
arrow-lambda is itself
-  a closure, and there is no separate dynamic-lambda packing, so the 
closures-specific name is exact.
+  closure needs no external delegate, supplied where the compiler cannot prove 
it — i.e. under
+  dynamic compilation, for a closure that resolves a free name (the case a 
delegate could affect;
+  the no-free-name subset needs no assertion, being provably safe by syntax). 
It is named for what
+  it packs — closures; a dynamic arrow-lambda is itself a closure, so the same 
paths apply to it.
 * *SAM vs not* is the target-type _upper bound_ on capability: a SAM target 
permits the
   object-free metafactory path; a `Closure`/`Object` target caps at the 
adapter.
 * *Capturing vs not* is a _cost dial_ (allocation vs singleton) within 
whatever strategy is
@@ -286,13 +295,20 @@ c()    // a normal closure resolves who() against the 
delegate -> 'DELEGATE'
        // a hoisted body binds who() to the owner at compile time -> 'OWNER'
 ----
 
-An eligibility check that only looks _inside_ a closure for `delegate`/`owner` 
references does
-not catch this, because the delegate is set by the _caller_ after the closure 
escapes. Making
-S1 sound therefore requires an *escape analysis*: pack a closure only when it 
can be proven not
-to reach a delegate-setting context (and is not serialized). Under 
`@CompileStatic` this is
-tractable — closures handed to `@DelegatesTo`-annotated parameters are the 
detectable danger
-signal, and the target types are known. Under dynamic compilation it cannot be 
proven, which is
-why the closure optimisation is opt-in there.
+The danger has two halves, and they are worth separating. First, the closure 
must actually
+_resolve a free name_ that a delegate could intercept — `who()` above is an 
unqualified call, so it
+can. A closure that resolves no free name (only its parameters, locals, 
captures and constants —
+`{ x -> x + 1 }`) has nothing for any delegate to intercept and is sound 
regardless. Second, for a
+closure that _does_ resolve a free name, one must know whether that name binds 
to the owner (safe to
+hoist) or could reach a caller-set delegate — and, because the delegate is set 
by the _caller_ after
+the closure escapes, an eligibility check that only looks _inside_ the closure 
for `delegate`/`owner`
+references does not catch it. Under `@CompileStatic` both are tractable: the 
type checker resolves
+every free name (owner vs delegate), and closures handed to 
`@DelegatesTo`-annotated parameters are
+the detectable escape signal. Under dynamic compilation the free-name 
resolution cannot be proven,
+so a free-name closure there is opt-in (`@PackedClosures` + runtime guard) — 
but the no-free-name
+subset is provably safe by syntax and can be packed automatically even 
dynamically (see _Automatic
+dynamic packing of the syntactic subset_). Either way a conservative *escape 
analysis* keeps a
+visibly-escaping closure as a class, so the runtime guard's surface stays 
small.
 
 By contrast, everything else survives — verified: `curry`, `memoize`, and 
`trampoline` all
 continue to work on an adapter-backed closure, because the adapter _is_ a real 
`Closure`.
@@ -356,10 +372,37 @@ serialization-bound — cast or coerced to a `Serializable` 
type, or passed dire
 routes left to the adapter's fail-fast `writeObject`, exactly as transitive 
escapes are left to the
 delegate guard.
 Because syntactic escape analysis cannot prove that, say, a custom `each` does 
not set a delegate,
-the runtime guard remains the essential backstop under dynamic compilation. (A 
closure that is
-fully self-contained — every free name binds to the owner or its own 
parameters — is
-delegate-independent and safe to pack even where it escapes; exploiting that 
for nested closures
-needs owner-correct retargeting, see _Reference implementation_.)
+the runtime guard remains the essential backstop under dynamic compilation.
+
+=== Automatic dynamic packing of the syntactic subset
+
+The delegate concern only arises for a closure that resolves a _free name_. A 
closure whose body
+contains none — no implicit-this method call, no unqualified/dynamic variable, 
only its parameters,
+locals, captures, constants and explicit/parameter receivers — cannot be 
reached by any delegate,
+so it is delegate-independent *by construction, with no type information at 
all*. This is a large
+and everyday class: `{ x -> x + 1 }`, `{ it * 2 }`, `{ a, b -> a <=> b }`, `{ 
it.name }`,
+`{ x -> x + captured }`. Such closures are sound to pack under _dynamic_ 
compilation too, without
+`@PackedClosures` and without a runtime guard (a caller-set delegate is a 
harmless no-op, exactly as
+on a normal closure), because there is nothing to prove — a purely syntactic 
check of the body
+decides it.
+
+This is implemented: with the flag on and no annotation, the closures above 
auto-pack under
+dynamic compilation and behave identically (including a set delegate being 
ignored, not rejected),
+while a closure with a free name (`{ owned() + x }`, an implicit-this call, or 
`{ x + missing }`, a
+dynamic variable) correctly stays a class. Because a dynamically-compiled 
lambda is itself compiled
+as a closure, the same syntactic path also body-hoists the equivalent dynamic 
lambda subset (e.g.
+`(x) -> x + 1`) for free — it just does not reach the `LambdaMetafactory` 
singleton form, which
+remains `@CompileStatic`-only for the mechanical reason that the metafactory 
needs the target
+functional-interface type.
+
+This makes the default-on story broader than "the `@CompileStatic`-proven 
set": a syntactic
+delegate-independence check widens automatic packing to the provably-safe 
dynamic subset as well.
+Free-name dynamic closures remain opt-in (`@PackedClosures` + guard), and 
nested closures still await
+owner-correct retargeting (Groovy 7) — but the trivial subset need not wait 
for either.
+
+(A closure that is fully self-contained is also safe to pack even where it 
escapes — an escaping
+`{ x -> x + 1 }` cannot misbehave — but exploiting that for nested closures 
needs owner-correct
+retargeting, see _Reference implementation_.)
 
 == Groovy 6.0 deliverables
 
@@ -528,7 +571,8 @@ means byte-for-byte today's behaviour.
 
 |SAM lambda hoisting (Groovy 6, merged) |`groovy.target.lambda.hoist` |off 
(opt-in)
 |Closure packing under `@CompileStatic` (Groovy 6 stretch, GROOVY-12151) 
|`groovy.target.closure.pack` (+ per-scope `@PackedClosures`) |off (opt-in); 
default-on is Groovy 7
-|Closure packing under dynamic compilation |`@PackedClosures` opt-in only |off 
unless annotated
+|Closure packing under dynamic compilation, free-name closures 
|`@PackedClosures` opt-in only |off unless annotated
+|Closure packing under dynamic compilation, no-free-name subset 
|`groovy.target.closure.pack` (syntactic proof) |off (opt-in)
 |===
 
 `@PackedClosures` carries a `mode` element (`LENIENT` | `WARN` | `STRICT` | 
`DISABLED`, default
@@ -793,7 +837,7 @@ stretch goal) builds on the shared backend proven in phases 
1–2; phases 4–5
 |Serializable closure/lambda hoisting |Deferred |Serializable lambdas and 
_visibly_ serialization-bound closures (cast/coerced to a `Serializable` type, 
or passed directly to `writeObject`) are kept on the class-based path; a packed 
closure reached by a transitive serialization route fails fast at runtime with 
an actionable message. Hoisting the serializable cases would require carrying 
the deserialisation machinery to the enclosing class.
 |Packing default-parameter (multi-arity) closures |Deferred |A single hoisted 
method cannot reproduce the arity-overloaded dispatch a default parameter 
generates, so these decline to S0 today. A future slice would hoist one method 
per arity (reusing the existing default-fill codegen) and claim one 
dispatch-table id per arity — the per-arity tables the shipped adapter already 
uses, and the same primitive the _MethodClosure_ alignment above would share.
 |Object elision (S3) |Deferred (Groovy 7) |The most aggressive step; needs 
escape proof and, for iteration, callee knowledge.
-|Dynamic-mode automatic packing |Not planned |Cannot be proven sound without 
types; dynamic packing is opt-in via `@PackedClosures` only.
+|Dynamic-mode automatic packing of _free-name_ closures |Not planned |A 
closure that resolves a free name cannot be proven delegate-safe without types; 
that subset stays opt-in via `@PackedClosures` (+ runtime guard) under dynamic 
compilation.
 |A user-visible switch between S1/S2/S3 per closure |Not planned |The compiler 
picks the lightest sound representation; users influence it only via target 
type, arrow syntax, and `@PackedClosures` — including opting a scope out 
entirely with `@PackedClosures(mode = DISABLED)`, but never selecting *which* 
packed representation is used.
 |===
 

Reply via email to