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.
|===