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 c56c304 updated with recent changes for GROOVY-12151
c56c304 is described below
commit c56c304f84c848606c9e7642549653841dea065a
Author: Paul King <[email protected]>
AuthorDate: Wed Jul 15 23:42:01 2026 +1000
updated with recent changes for GROOVY-12151
---
site/src/site/wiki/GEP-27.adoc | 99 +++++++++++++++++++++++++++++-------------
1 file changed, 70 insertions(+), 29 deletions(-)
diff --git a/site/src/site/wiki/GEP-27.adoc b/site/src/site/wiki/GEP-27.adoc
index 87d4d98..e7dff31 100644
--- a/site/src/site/wiki/GEP-27.adoc
+++ b/site/src/site/wiki/GEP-27.adoc
@@ -14,7 +14,7 @@
*Comment*:: Reduces the per-closure/per-lambda generated-class explosion by
hoisting bodies onto the enclosing class. The sound-by-construction SAM-lambda
slice is merged for Groovy 6; closure packing (S1) is implemented and proposed
as a Groovy 6 stretch goal (GROOVY-12151); object elision and writer
unification remain for Groovy 7. Strictly additive; gated by a flag for
back-out.
*Leader*:: Paul King
*Created*:: 2026-07-09
-*Last modification*:: 2026-07-15
+*Last modification*:: 2026-07-16
****
[width="80%",align="center"]
@@ -47,8 +47,8 @@ actually needs:
* a *SAM-targeted lambda* becomes a `LambdaMetafactory` functional-interface
instance over the
hoisted method — no generated class at all, and a zero-allocation singleton
when
non-capturing (Java parity);
-* a *closure* becomes one shared `Closure` adapter that dispatches to the
hoisted method — no
- per-closure class, while remaining a real `groovy.lang.Closure` (so `curry`,
`memoize`,
+* a *closure* becomes a shared `Closure` adapter (a small fixed-arity family)
that dispatches to
+ the hoisted method — no per-closure class, while remaining a real
`groovy.lang.Closure` (so `curry`, `memoize`,
`trampoline`, and iteration continue to work);
* where a value neither escapes nor needs closure identity, the object can be
elided entirely.
@@ -193,11 +193,15 @@ A hoisted representation loses exactly three things
relative to a normal generat
time against the owner; a normal closure resolves free names against a
`delegate` set at
runtime. This is the one true soundness boundary (see _The soundness
boundary_).
* *Serialization.* A dispatch-table-backed adapter is not portably
serializable the way a
- generated closure class is.
-* *Per-literal class identity.* All packed closures share one adapter class, so
- `closure.getClass()` no longer distinguishes literals, and class-level
metaclass changes on
- the adapter are global to packed closures (per-instance `setMetaClass` is
fully honoured —
- see _MOP transparency_).
+ generated closure class is. (`dehydrate()`/`rehydrate()` themselves keep
working — dispatch
+ holds its own receiver, so a dehydrated packed closure stays callable — but
the adapter cannot
+ be written to an object stream.)
+* *Per-literal class identity.* Packed closures share a small *fixed-arity
adapter family*
+ (`PackedClosure$Fixed0..4`, `$FixedIt` for implicit-parameter literals,
`$FixedN` for higher
+ and vararg arities), so `closure.getClass()` distinguishes arity — enough
for class-level
+ introspection such as SAM-overload selection — but not individual literals,
and class-level
+ metaclass changes on a family member affect every packed closure of that
arity (per-instance
+ `setMetaClass` is fully honoured — see _MOP transparency_).
So "needs closure identity" is not a broad lattice; it is essentially the
predicate *"does
externally-set delegate/resolveStrategy or serialization reach this value?"*
@@ -247,7 +251,7 @@ Given the required capability, the compiler picks the
lightest representation th
|Strategy |When |Result
|*S0* full generated class |needs external delegate / serialization, or
capability unprovable |today's behaviour — one class per closure/lambda
-|*S1* shared `Closure` adapter |closure-shaped, no external
delegate/serialization |hoisted body + one shared adapter; no per-closure
class; `curry`/`memoize` preserved
+|*S1* shared `Closure` adapter |closure-shaped, no external
delegate/serialization |hoisted body + a fixed-arity adapter family; no
per-closure class; `curry`/`memoize` preserved
|*S2* metafactory SAM |functional-interface target, no closure identity needed
|hoisted body + `LambdaMetafactory`; **no class**; zero-alloc singleton when
non-capturing; static-only
|*S3* elide / inline |value neither escapes nor needs an object (e.g.
call-once) |no object at all
|===
@@ -363,14 +367,18 @@ modes:
A conservative *escape gate* shrinks the runtime surface further and is
implemented in the S1 slice: a
closure literal that visibly escapes its method — stored into a
field/property/index (the Grails
-`static constraints = { … }` shape), returned, appended with `<<`, or placed
in a collection literal
-— is declined at compile time and kept as a class. This moves the clearest
delegate-risk cases from
+`static constraints = { … }` shape), *initialising a field* (`def action = { …
}` at class level,
+which compiles outside the enclosing method's visible code and is checked
against the class's
+fields directly), returned, appended with `<<`, or placed in a collection
literal — is declined at
+compile time and kept as a class. This moves the clearest delegate-risk cases
from
a runtime throw to a compile-time non-event; those declines are also what
`WARN`/`STRICT` report.
A sibling gate applies the same discipline to the serialization boundary: a
literal that is visibly
-serialization-bound — cast or coerced to a `Serializable` type, or passed
directly to a
-`writeObject` call — declines and keeps its class (so it serializes as
before), with transitive
-routes left to the adapter's fail-fast `writeObject`, exactly as transitive
escapes are left to the
-delegate guard.
+serialization-bound — cast or coerced to a `Serializable` type, passed
directly to a
+`writeObject` call, or held in a *local* that is (the canonical
+`def c = { … }; out.writeObject(c)` shape, tracked one def-use hop within the
method) — declines
+and keeps its class (so it serializes as before), with genuinely transitive
routes (helper methods,
+constructor-mediated graphs) 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.
@@ -458,9 +466,11 @@ behind the same flag; only mutated-capture lambdas remain
on the class-based pat
=== Stretch goal (implemented): closure packing (S1)
-A shared `Closure` adapter (`PackedClosure`) that dispatches to a hoisted
body, applied to
-closures rather than lambdas — now implemented in the code generator
(GROOVY-12151) and proposed
-for Groovy 6.0 as a stretch goal. It covers the hard cases: no-capture and
captured closures (by
+A shared `Closure` adapter family (`PackedClosure`, instantiated as the
fixed-arity member
+matching the literal — `Fixed0..Fixed4`, `FixedIt` for implicit-parameter
literals, `FixedN` for
+higher and vararg arities) that dispatches to a hoisted body, applied to
closures rather than
+lambdas — now implemented in the code generator (GROOVY-12151) and proposed
for Groovy 6.0 as a
+stretch goal. It covers the hard cases: no-capture and captured closures (by
value; written captures via shared `Reference`, including `+=`/`++`),
*arbitrary-depth nested
closures*, implicit `it`, under both `@CompileStatic` and dynamic compilation,
correctly declining
closures that use `delegate`/`owner`/`super`, or that carry default parameter
values (multi-arity —
@@ -492,12 +502,22 @@ diagnostics. The two headline capabilities are done:
instance's metaclass is stock and no category is active (one latched-boolean
check, the same
conditions the classic call-site caches test), and anything MOP-relevant
routes through
`invokeMethod`. Interception via a per-instance metaclass is honoured
identically on the dynamic
- and Java/GDK paths, matching generated closure classes. The adapter has its
own registered stock
- metaclass, `PackedClosureMetaClass` — the analog of `ClosureMetaClass` for
generated closures —
- giving reflection-free `call`/`doCall` dispatch on the MOP route,
`respondsTo` answers faithful to
- the instance's declared parameter types, and the same category stance as
`ClosureMetaClass`. (The
- `GeneratedClosure` marker is deliberately not used: its metaclass machinery
assumes an inner class
- of the owner, which a shared top-level adapter is not.)
+ and Java/GDK paths, matching generated closure classes. The adapter family
has its own registered
+ stock metaclass, `PackedClosureMetaClass` — the analog of `ClosureMetaClass`
for generated
+ closures — giving reflection-free `call`/`doCall` dispatch on the MOP route,
`respondsTo` answers
+ faithful to the instance's declared parameter types, and the same category
stance as
+ `ClosureMetaClass`. Each fixed-arity member carries the `GeneratedClosure`
marker and declares
+ exactly the `doCall` signature(s) its generated-class counterpart would
(including the fuzzy
+ "0 or 1" pair for implicit-parameter literals), so *class-level*
introspection — SAM-overload
+ selection by closure arity, `ClosureMetaMethod` construction,
`MetaClassImpl` method selection
+ under a replaced metaclass — behaves exactly as with generated classes; the
general dispatch
+ entry is deliberately not named `doCall`, so no varargs signature exists for
MOP selection to
+ mistake a single `Object[]` argument for an argument list. Argument
adaptation replicates
+ metaclass dispatch bit for bit (pinned empirically against classed
closures): arity mismatches
+ raise `MissingMethodException`, a single `List` destructures across a
non-one-parameter
+ signature, trailing-array parameters vararg-collect, and per-parameter
coercion follows the
+ metaclass compatibility matrix (`BigDecimal`→`double` yes,
`String`→`Integer` no,
+ `Closure`→SAM yes).
The API surface (`@PackedClosures`, `PackMode`) is marked `@Incubating`. What
keeps this a stretch
goal rather than a committed 6.0 deliverable is that it is opt-in only (the
automatic path is not
@@ -553,10 +573,31 @@ a goal; this is tracked as a separate initiative.
Flip closure packing from experimental (opt-in `groovy.target.closure.pack`)
to default-on. This is
a policy decision gated on the remaining human-only tooling verification (see
_IDE and tooling
-considerations_) and on migrating the tests that assert the old bytecode shape
— not on further
-mechanism work: the dispatch and MOP-transparency mechanisms are complete and
verified (interception,
-categories, and introspection behave identically to generated closure classes
on every path), and the
-remaining documented residuals are those listed under _The one that matters:
required capability_.
+considerations_) — not on further mechanism work: the dispatch and
MOP-transparency mechanisms are
+complete and verified (interception, categories, and introspection behave
identically to generated
+closure classes on every path), and the remaining documented residuals are
those listed under
+_The one that matters: required capability_.
+
+==== Hardening under maximal packing (done)
+
+The strongest evidence for the flip comes from running *Groovy's entire own
test suite with the
+flag forced globally on* — core, the test corpus, and everything the tests
compile and evaluate at
+runtime — deliberately packing far beyond any real configuration to find where
theory and MOP
+reality diverge. The campaign started at **39 failing test classes (~140
failures)** and ended at
+**one** (a single context-dependent case that passes in every standalone
reproduction). Everything
+in between was resolved by root-cause fixes that *widened* packed coverage
rather than narrowing
+it: the fixed-arity adapter family (restoring class-level arity introspection
for SAM-overload
+selection), argument-adaptation parity pinned empirically against classed
closures, holder
+threading for captures re-captured by nested closures, flow-inferred capture
typing, two escape-
+and serialization-gate extensions (field initialisers; one-hop locals into
`writeObject`), the
+mixed-mode dynamic-island decline, and closure-context contracts re-homed with
the body (dynamic
+property fallback for cross-class private access, `Map`-owner entry semantics,
inferred return
+coercion). Tests that assert the *pre-pack generated-class bytecode shape* are
conditionally
+skipped under the flag with named reasons (they run unchanged by default), and
tests whose
+*subject* is closure-class serialization declare `@PackedClosures(mode =
DISABLED)` fixtures —
+every behavioural test in the suite runs and passes packed. The methodology
(forced-global flag,
+per-class JVM isolation, compile-time bytecode verification harnesses, and
pinning classed
+behaviour empirically before replicating it) is repeatable for future slices.
== Configuration and flags
@@ -834,7 +875,7 @@ stretch goal) builds on the shared backend proven in phases
1–2; phases 4–5
|Changing closure/lambda _semantics_ |Not planned |This is a codegen/packaging
optimisation only. Eligible closures compute identical results and keep their
reachable `Closure` API; ineligible ones are compiled exactly as today.
|Packing closures that use externally-set `delegate`/`resolveStrategy` |Not
planned |These require a real per-closure representation; they are detected
(statically) or declined and fall back to S0.
-|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.
+|Serializable closure/lambda hoisting |Deferred |Serializable lambdas and
_visibly_ serialization-bound closures (cast/coerced to a `Serializable` type,
passed directly to `writeObject`, held in a local that is, or initialising a
field) are kept on the class-based path; a packed closure reached by a
genuinely transitive serialization route fails fast at runtime with an
actionable message. Hoisting the serializable cases would require carrying the
deserialisation machinery to the enclosin [...]
|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 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.