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 0a28012  draft "Compact Closure and Lambda Compilation" GEP
0a28012 is described below

commit 0a28012ede43f977dcf5909ec55bce8f22bf437a
Author: Paul King <[email protected]>
AuthorDate: Thu Jul 9 22:38:02 2026 +1000

    draft "Compact Closure and Lambda Compilation" GEP
---
 site/src/site/wiki/GEP-27.adoc | 556 +++++++++++++++++++++++++++++++++++++++++
 1 file changed, 556 insertions(+)

diff --git a/site/src/site/wiki/GEP-27.adoc b/site/src/site/wiki/GEP-27.adoc
new file mode 100644
index 0000000..ceebd39
--- /dev/null
+++ b/site/src/site/wiki/GEP-27.adoc
@@ -0,0 +1,556 @@
+= GEP-27: Compact Closure and Lambda Compilation
+
+:icons: font
+
+.Metadata
+****
+[horizontal,options="compact"]
+*Number*:: GEP-27
+*Title*:: Compact Closure and Lambda Compilation
+*Version*:: 1
+*Type*:: Feature
+*Status*:: Draft
+*Target*:: Groovy 6.0 (potential first step) and Groovy 7.0 (remainder)
+*Comment*:: Reduces the per-closure/per-lambda generated-class explosion by 
hoisting bodies onto the enclosing class. A small, sound-by-construction slice 
potentially targets Groovy 6; the broader closure work targets Groovy 7. 
Strictly additive; gated by a flag for back-out.
+*Leader*:: Paul King
+*Created*:: 2026-07-09
+*Last modification*:: 2026-07-09
+****
+
+[width="80%",align="center"]
+|===
+a| NOTE: _WARNING:_
+Material on this page is still under development!
+We are currently working on Groovy 6.0. A small, self-contained part of this 
proposal targets Groovy 6.0;
+the larger part targets Groovy 7.0.
+The final version of this proposal may differ significantly from the current 
draft,
+but having this draft available allows us to gather early feedback, align 
design
+decisions in Groovy 6 as best we can, and iterate on the design.
+We welcome feedback and discussion, but please keep in mind that the details 
are not yet finalized.
+|===
+
+== Abstract
+
+Every closure literal and (statically compiled) lambda in Groovy is compiled 
to its own
+generated class. A method with a handful of closures produces a handful of 
extra `.class`
+files; deeply nested closures produce classes whose names concatenate without 
bound
+(`Outer$_method_closure1$_closure2$_closure3`). This is invisible at the 
source level but
+real at the bytecode level: more class files to load, verify, JIT, and hold in 
metaspace.
+
+This GEP proposes compiling eligible closures and lambdas *more compactly* by 
**hoisting the
+body into a method on the enclosing class** instead of generating a class per 
closure. This is
+exactly how the JVM compiles Java lambdas, and it unifies two today-separate 
code paths behind
+one primitive. The value expressed as an object is then chosen to fit what the 
callsite
+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`,
+  `trampoline`, and iteration continue to work);
+* where a value neither escapes nor needs closure identity, the object can be 
elided entirely.
+
+The proposal is deliberately phased. A **small, sound-by-construction slice — 
eliminating the
+generated class for non-capturing SAM lambdas — targets Groovy 6.0**. The 
**broader closure work,
+which needs a soundness analysis and a performance path, targets Groovy 7.0**. 
The change is
+strictly additive, defaults can be flipped via a flag for back-out, and it is 
gated so that any
+tooling that reflects on generated-class names has an escape hatch.
+
+== Motivation
+
+=== The class explosion
+
+Groovy compiles each closure literal to a distinct generated inner class 
extending
+`groovy.lang.Closure`. That class carries a `doCall` method, a 
`serialVersionUID`, its own
+metaclass initialiser, a constructor taking `(owner, thisObject, 
...captured)`, and one
+`Reference`-boxed field per captured mutable local. Nesting concatenates names:
+
+[source,groovy]
+----
+class Deep {
+    def render(Map m) {
+        m.each { k, v ->
+            [1, 2].each { i ->
+                [3, 4].each { j -> "$k$v$i$j".toString() }
+            }
+        }
+    }
+}
+----
+
+compiles to four classes — `Deep`, `Deep$_render_closure1`,
+`Deep$_render_closure1$_closure2`, and 
`Deep$_render_closure1$_closure2$_closure3` — one per
+nesting level, names growing with depth.
+
+The cost is not correctness but *packaging*: class-file bytes, classloading, 
bytecode
+verification, JIT profiling, and metaspace, all multiplied by the number of 
closures. For
+closure-heavy code — GSP page compilation, builders, DSL-shaped code — this is 
a measurable
+tax paid on code that reads as a one-liner.
+
+=== Lambdas are heavier than Java's
+
+Even for a statically compiled lambda that targets a functional interface, 
Groovy generates a
+full `groovy.lang.Closure` subclass:
+
+[source,groovy]
+----
+@CompileStatic
+class L {
+    Function<Integer,Integer> nonCap()      { (Integer x) -> x * 2 }        // 
-> L$_nonCap_lambda1 (extends Closure)
+    Function<Integer,Integer> cap(int base) { (Integer x) -> x + base }     // 
-> L$_cap_lambda2   (extends Closure)
+}
+----
+
+Both generated classes extend `Closure` and implement `GeneratedLambda`, each 
with its own
+metaclass machinery. Groovy already uses `invokedynamic`/`LambdaMetafactory` 
(so the runtime
+_value_ is a JDK metafactory `Function`, and the non-capturing case is already 
a zero-allocation
+singleton) — but it still drags along a per-lambda class whose only live part 
is a `static
+doCall`. The equivalent Java lambda emits *no* class per lambda: the body is a 
`private static
+synthetic` method on the enclosing class and the metafactory bootstraps 
directly against it.
+
+The primitive that closes this gap for lambdas — "put the body on the 
enclosing class" — is the
+_same_ primitive that fixes the closure explosion. That shared primitive is 
the heart of this GEP.
+
+=== Design principles
+
+* *Compile the body once, choose the wrapper to fit.* A single backend hoists 
a closure/lambda
+  body to a method on the enclosing class. The object exposed at the callsite 
(metafactory SAM,
+  shared `Closure` adapter, or nothing at all) is chosen by what the value 
must actually satisfy.
+
+* *Never change observable behaviour.* An optimised closure/lambda computes 
the same result,
+  keeps its per-instance identity where it has one, and preserves the 
`Closure` API where that
+  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.
+
+* *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
+  shared backend before the broader closure work builds on it.
+
+* *Additive and reversible.* Nothing changes for un-optimised code. Each 
landing is gated by a
+  flag so it can be disabled without recompiling application code.
+
+== Background: how closures and lambdas compile today
+
+A closure `{ params -> body }`:
+
+. becomes a generated `InnerClassNode` (`ClosureWriter.getOrAddClosureClass`) 
— one per literal;
+. that class extends `groovy.lang.Closure implements GeneratedClosure`, with a 
`doCall` holding
+  the body, captured mutable locals threaded as `groovy.lang.Reference` 
constructor
+  arguments/fields, and standard `Closure` state (`owner`, `thisObject`, 
`delegate`,
+  `resolveStrategy`, ...);
+. at the callsite, `new Outer$_m_closure1(this, this, ref...)` is instantiated.
+
+A statically compiled lambda `(params) -> body` targeting a functional 
interface
+(`StaticTypesLambdaWriter`):
+
+. still generates a lambda `InnerClassNode` extending `Closure` with a 
`doCall` (made `static`
+  when non-capturing);
+. emits `invokedynamic` bootstrapped through `LambdaMetafactory.metafactory`, 
whose
+  implementation-method handle points at that `doCall`;
+. so the runtime value is a JDK-generated functional-interface instance, and 
the generated
+  lambda class exists only to _hold_ the implementation method.
+
+A dynamically compiled lambda is compiled exactly like a closure (no 
metafactory).
+
+Two facts make hoisting natural. First, methods can already be added to the 
enclosing class
+_during_ code generation and get compiled — Groovy's own serializable-lambda 
deserialisation
+helpers do this. Second, for statically compiled code the type checker has 
already run, so
+the inferred types needed to type a hoisted method and to prove an 
optimisation safe are
+available on the AST.
+
+== The design space
+
+The combinations one could enumerate — dynamic/static, closure/lambda, 
annotated/not,
+capturing/not, needs-identity/not, SAM/not — collapse to a small number of 
axes that actually
+drive the compilation strategy.
+
+=== The one that matters: required capability
+
+The question that selects the representation is *what must this value be able 
to do at its use
+sites?* Crucially, this is a **short** list. A shared `Closure` adapter that 
dispatches to a
+hoisted method is still a real `Closure`, so it preserves almost everything: 
it is callable, it
+has per-instance identity, and `curry`/`rcurry`/`ncurry`, `memoize*`, 
`trampoline`,
+`compose`, and iteration all continue to work (they operate through `call()`).
+
+A hoisted representation loses exactly two things relative to a normal 
generated closure:
+
+* *Externally-set `delegate`/`resolveStrategy`.* A hoisted body binds free 
names at compile
+  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 method-handle/dispatch-backed adapter is not portably 
serializable the way
+  a generated closure class is.
+
+So "needs closure identity" is not a broad lattice; it is essentially the 
predicate *"does
+externally-set delegate/resolveStrategy or serialization reach this value?"*
+
+=== The one that flips the difficulty: static vs dynamic
+
+Compilation mode is not a strategy selector; it is an *information and 
dispatch* modifier that
+flips which half of the problem is hard:
+
+[cols="1,3,3"]
+|===
+| |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`
+|===
+
+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_.
+
+=== The rest are inputs and cost dials
+
+* *Lambda vs closure syntax* is a capability _declaration_, not a separate 
axis. Arrow syntax
+  already routes a SAM-targeted expression to the lighter (metafactory) 
writer; it is the
+  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.
+* *An opt-in annotation* (working name `@Packed`) 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).
+* *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
+  chosen, not a selector.
+
+=== The strategy lattice
+
+Given the required capability, the compiler picks the lightest representation 
that satisfies it:
+
+[cols="1,3,3"]
+|===
+|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
+|*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
+|===
+
+Capturing modulates each (non-capturing enables the singleton/static-method 
form); static mode
+enables typed dispatch of the hoisted body and the S2 path.
+
+== Architecture
+
+One backend, one capability front-end, a wrapper choice:
+
+----
+closure / lambda body
+        |
+        v
+ hoist to a method on the enclosing class      <-- shared backend
+        |
+   capability inference  (target type; arrow/@Packed declarations;
+        |                  static-only escape + @DelegatesTo + serialization 
analysis)
+        v
+   +----+----+----------------------+---------------------+
+   |         |                      |                     |
+  S3        S2                     S1                    S0
+ elide   metafactory SAM     PackedClosure adapter   generated class
+ (no obj) (no class)         (no per-closure class)   (unchanged)
+----
+
+The elegant consequence is that the closure writer and the lambda writer stop 
being two
+separate mechanisms. They share the hoist backend and differ only in which 
wrapper the
+capability front-end selects. The existing `StaticTypesLambdaWriter` becomes a 
special case
+of the general design.
+
+== The soundness boundary
+
+The single behaviour a hoisted representation cannot reproduce is *delegate 
resolution set by
+the caller*. This is not hypothetical:
+
+[source,groovy]
+----
+class Mk { Closure c() { return { -> who() } }; def who() { 'OWNER' } }
+def c = new Mk().c()
+c.delegate = [who: { -> 'DELEGATE' }]
+c.resolveStrategy = Closure.DELEGATE_FIRST
+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.
+
+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`.
+
+*Why lambdas are the safe corner.* A functional-interface target _cannot 
express_ delegate
+resolution — a `Function` has no `delegate`. And a SAM-typed lambda is, by its 
type, used as
+that SAM. So the entire soundness cliff is defined away for the SAM case, 
which is precisely why
+it can land first.
+
+== Groovy 6.0 deliverables (pending discussion and findings)
+
+The Groovy 6 target is the sound-by-construction slice. One piece is proposed 
as the primary
+deliverable, with a second offered as an option.
+
+=== Piece 1 (primary): eliminate the class for non-capturing SAM lambdas
+
+For a statically compiled, *non-capturing*, non-serializable lambda targeting 
a functional
+interface, do what Java does: emit the body as a `private static` synthetic 
method on the
+enclosing class and bootstrap `LambdaMetafactory` directly against it — 
generating no lambda
+class.
+
+This is sound by construction: the target is a SAM (no delegate possible), the 
runtime value is
+already a metafactory instance (so the lambda class was never the value — it 
was a dead
+impl-holder), and non-capturing means no capture threading. Measured on a 
proof-of-concept:
+
+[cols="3,1,1"]
+|===
+|Case |Before |After
+
+|`L` with `nonCap`, `cap`, `runnable` |`L` + 3 lambda classes |`L` + 1 (only 
the capturing one)
+|`Handlers` with 5 non-capturing + 1 capturing lambda |`Handlers` + 6 lambda 
classes |`Handlers` + 1 — *5 classes eliminated*, 5 `$lambda$` static methods 
on `Handlers`
+|===
+
+The metafactory singleton is preserved (`nonCap().is(nonCap()) == true`), 
stack traces name a
+`$lambda$...` method on the enclosing class (Java-like), and behaviour is 
unchanged across
+`Predicate`/`BiFunction`, lambdas in `static` methods, lambdas calling static 
methods, and
+serializable non-capturing round-trips (which fall back to the class-based 
path). A lambda that
+reads an instance field (`() -> count`) is correctly _not_ hoisted and keeps 
working.
+
+The change is localised to `StaticTypesLambdaWriter` (the existing `doCall` 
construction already
+does the correct non-capturing/instance-member analysis; the hoist re-homes 
the resulting static
+method onto the enclosing class). It requires no change to the 
`invokedynamic`/metafactory
+bootstrap other than the implementation method's owner class, and does not 
require the
+class-visitor changes the closure work needs.
+
+=== Piece 2 (option): capturing SAM lambdas
+
+A capturing SAM lambda today instantiates its lambda class to hold the 
captured values (Groovy
+even `Reference`-wraps them). The Java-style form uses a `static` 
implementation method taking
+the captured values as leading arguments, with the metafactory capturing them 
directly — which
+_also_ removes the `Closure` allocation and the `Reference` wrapping. This is 
a larger change
+than Piece 1 (the impl signature and bootstrap arguments change), so it is 
offered as a Groovy 6
+*stretch* or an early Groovy 7 item rather than a commitment.
+
+== Groovy 7.0 deliverables
+
+The broader, higher-value work — where the soundness analysis and the 
performance story live.
+
+=== Closure packing (S1) with a soundness analysis
+
+A shared `Closure` adapter (`PackedClosure`) that dispatches to a hoisted 
body, applied to
+closures rather than lambdas. A proof-of-concept in the code generator already 
demonstrates the
+mechanism across 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`. What remains for a real feature is the 
*capability/escape analysis*
+described above, so packing is chosen only when it is provably sound 
(automatic under
+`@CompileStatic`; opt-in via `@Packed` under dynamic), falling back to S0 
otherwise.
+
+=== Typed static dispatch (making it fast, not just small)
+
+In the proof-of-concept the hoisted body is compiled dynamically, so a packed 
`@CompileStatic`
+closure is smaller but dispatched dynamically. A production form must type the 
hoisted method
+from the inferred types the type checker already computed and dispatch the 
body statically (with
+an `invokedynamic`/`MethodHandle` callsite), so that packing under 
`@CompileStatic` is a speed
+win and not merely a class-count win.
+
+=== Object elision (S3)
+
+For closures/lambdas that neither escape nor need an object — for example 
immediately-invoked
+bodies — elide the object entirely. This is the most aggressive step and 
depends on escape
+proof and, for iteration intrinsics, knowledge of the callee.
+
+=== Unify the two writers
+
+Fold the lambda and closure code paths behind the single hoist backend, so the 
SAM and
+`Closure` cases are one mechanism selecting different wrappers.
+
+== Configuration and flags
+
+Each landing is gated so it can be disabled without recompiling application 
code, following
+Groovy's existing `SystemUtil.getBooleanSafe(...)`/`static final` convention 
(as used for
+`groovy.target.indy` and similar). The gate wraps the single decision branch, 
so *disabled*
+means byte-for-byte today's behaviour.
+
+[cols="2,2,2"]
+|===
+|Concern |Flag (proposed) |Default
+
+|Non-capturing SAM lambda hoisting (Groovy 6) 
|`groovy.target.lambda.hoist.disabled` |off (i.e. hoisting *on*), opt-out
+|Closure packing under `@CompileStatic` (Groovy 7) 
|`groovy.target.closure.pack` (+ per-scope `@Packed`) |to be decided per release
+|Closure packing under dynamic compilation |`@Packed` opt-in only |off unless 
annotated
+|===
+
+The default *direction* is a policy decision independent of the mechanism: a 
feature can ship
+*on* with an opt-out flag (deliver the win immediately, keep a back-out 
valve), or *off* with an
+opt-in flag and be flipped a release later (conservative). The same one-line 
gate serves either
+way; only the boolean's default changes.
+
+[NOTE]
+====
+The flag also eases test migration. Groovy's current tests assert the _old_ 
bytecode shape (a
+`doCall` on a lambda class); with hoisting disabled those assertions stay 
valid, while
+new-structure tests run with it enabled — so the codegen change and the test 
rewrite need not
+land in the same commit.
+====
+
+If per-compilation (rather than JVM-wide) control is wanted, the same toggle 
can be promoted to
+a `CompilerConfiguration` option, mirroring how `indy` is both a system 
property and a config
+option. The system property is proposed as the initial back-out mechanism.
+
+== IDE and tooling considerations
+
+Because this is a *bytecode-shape* change, behaviour is preserved but the 
generated structure a
+tool might reflect on changes. This surface needs an explicit compatibility 
pass with IDEs,
+debuggers, and other tooling before the defaults are turned on broadly:
+
+* *Generated class names.* Tools that assume `Outer$_method_closure1` / 
`Outer$_m_lambda1`
+  classes exist — by name, by `Class.forName`, by scanning class files, or by 
pattern — will not
+  find them for hoisted closures/lambdas. This includes coverage tools, 
decompilers, bytecode
+  viewers, and any reflection keyed on the generated naming scheme.
+* *Marker types.* `GeneratedClosure`/`GeneratedLambda` are not present on a 
metafactory SAM
+  value (they were never on the runtime value for SAM lambdas, but tooling 
that inspects the
+  _generated class_ is affected).
+* *Debuggers and stepping.* The body now lives in a 
`$lambda$...`/`$packed$...` method on the
+  enclosing class rather than a `doCall` on a nested class. Stack traces 
improve (they name the
+  enclosing class), but "step into closure", breakpoints on a closure body, 
and lambda/closure
+  display in variable views should be checked in IntelliJ IDEA and Eclipse, 
and against JDWP
+  expectations.
+* *Serialization.* Serializable closures/lambdas continue on the class-based 
path in the first
+  step; any future extension to hoist them must carry the 
`$deserializeLambda$` machinery to the
+  enclosing class (as Java does) and be validated for round-trip and 
versioning.
+* *Stub generation / joint compilation and Groovydoc.* Synthetic hoisted 
methods must remain
+  invisible to public API views (they are `private synthetic`), and stub 
generation must not
+  surface them.
+
+Concretely, before enabling by default we should: build the reference IDE 
integrations against a
+snapshot; run the debugger step/breakpoint scenarios for both closures and 
lambdas; check the
+common coverage and decompiler tools; and document the flag as the back-out 
for any tool not yet
+updated. This tooling verification is a first-class deliverable of the GEP, 
not an afterthought.
+
+== Reference implementation
+
+A proof-of-concept exists as a spike in the code generator, exercised through 
this proposal's
+development:
+
+* *Lambda first step (Groovy 6 candidate).* Implemented in 
`StaticTypesLambdaWriter`:
+  non-capturing SAM lambda classes eliminated, impl hoisted to `private 
static` methods on the
+  enclosing class, metafactory singleton and behaviour verified, class-count 
reductions measured
+  as above. The existing lambda tests that assert the old bytecode shape need 
updating to the new
+  structure (behaviour is unchanged); that test migration is the bulk of the 
remaining first-step
+  work.
+* *Closure packing (Groovy 7 direction).* A `ClosureWriter`-based spike (with 
a `PackedClosure`
+  runtime adapter) demonstrates hoisting closures — captures, arbitrary-depth 
nesting, written
+  captures via `Reference`, implicit `it` — under both compilation modes, with 
correct declines
+  for delegate/owner/super. It confirms the writer is the right home (no 
`@CompileStatic`
+  boundary, unlike an AST-transform approach) and empirically establishes the 
soundness cliff and
+  what is preserved. It does *not* yet include the capability/escape analysis 
or typed dispatch;
+  those are the Groovy 7 work.
+
+One supporting change surfaced by the closure spike is worth noting: hoisting 
nested closures
+requires the class visitor to visit methods added _during_ code generation to 
a fixpoint (the
+current two-round `visitMethods` misses methods added while compiling a method 
that was itself
+added during compilation). That change is general and low-risk but must be 
validated against the
+full suite before it lands.
+
+=== Phased delivery
+
+[cols="1,3,4,1"]
+|===
+|Phase |What ships |What works |Target
+
+|1 |Non-capturing SAM lambda class elimination + flag + test migration + 
IDE/tooling check |Java-parity lambda codegen for the common SAM case; back-out 
flag |Groovy 6.0
+|2 |Capturing SAM lambdas (metafactory-captured static impl) |Removes the 
class, `Closure` alloc, and `Reference` wrapping for capturing SAM lambdas 
|Groovy 6.0 stretch / 7.0
+|3 |Closure packing (S1) + capability/escape analysis + `@Packed` |Sound 
closure packing: automatic under `@CompileStatic`, opt-in under dynamic |Groovy 
7.0
+|4 |Typed static dispatch of hoisted bodies |Packed `@CompileStatic` closures 
are faster, not just smaller |Groovy 7.0
+|5 |Object elision (S3) + unify the writers |No object for non-escaping 
call-once bodies; one backend for closures and lambdas |Groovy 7.0
+|===
+
+Phase 1 stands alone and delivers value with no dependency on the later 
phases. Phases 3–5 build
+on the shared backend proven in phases 1–2.
+
+== Excluded and deferred features
+
+[cols="2,1,3"]
+|===
+|Feature |Status |Rationale
+
+|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 |Kept on the class-based path 
initially; hoisting them requires carrying the deserialisation machinery to the 
enclosing class.
+|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 `@Packed` only.
+|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 `@Packed`.
+|===
+
+== Compatibility and impact
+
+=== Backwards compatibility
+
+The feature is additive and reversible:
+
+* Un-optimised code is unchanged. Ineligible closures/lambdas (and everything 
when the flag is
+  off) compile byte-for-byte as today.
+* Observable behaviour of optimised closures/lambdas is preserved, including 
per-instance
+  identity and the reachable `Closure` API (`curry`/`memoize`/`trampoline`).
+* The one behavioural difference a compact representation _cannot_ provide — 
caller-set delegate
+  resolution — is exactly the case the analysis declines to optimise, falling 
back to a normal
+  generated closure.
+
+=== Binary compatibility
+
+Generated-class _names_ and the presence of per-closure/per-lambda classes are 
not part of any
+public API, but tooling may depend on them (see _IDE and tooling 
considerations_). The flag is
+the compatibility valve while tools are updated.
+
+=== Performance
+
+* Fewer generated classes: less classloading, verification, JIT profiling, and 
metaspace.
+* Non-capturing SAM lambdas keep their zero-allocation metafactory singleton 
(the class removed
+  is dead weight, not an allocation).
+* Closure packing under `@CompileStatic` is a class-count win immediately and 
a dispatch-speed
+  win once typed dispatch (phase 4) lands; without it, a packed 
`@CompileStatic` closure trades a
+  class for dynamic dispatch, so typed dispatch is a prerequisite for turning 
it on for hot code.
+
+=== Security
+
+No new trust boundary. The optimisation is a local codegen transform; it 
introduces no new
+runtime input handling.
+
+== Alternatives considered
+
+* *Do it as an AST transformation rather than in the code generator.* Rejected 
for the closure
+  case: a phase sweep shows there is no AST-transform window that both packs 
closures _and_
+  survives `@CompileStatic` — early enough to control codegen means the type 
checker sees the
+  rewritten (untyped) form and fails; late enough to be post-type-check means 
it is too late to
+  suppress class emission. The code generator is the only place with both the 
inferred types and
+  control over class emission.
+* *Ship closure packing without the escape analysis.* Rejected: without it, 
packing silently
+  mis-resolves a closure whose delegate is set by the caller. Soundness is a 
prerequisite, not a
+  follow-up.
+* *One aggressive representation for everything.* Rejected: no single 
representation satisfies the
+  full `Closure` contract cheaply. The lattice picks the lightest _sound_ 
option per closure.
+* *Lambda-style (metafactory) for closures too.* Rejected as the general 
answer:
+  `LambdaMetafactory` targets interfaces, and `groovy.lang.Closure` is an 
abstract class with
+  rich mutable state, so closures need the shared adapter (S1) rather than the 
metafactory (S2).
+  S2 applies only where the target genuinely is a functional interface.
+* *Leave lambdas as they are.* Rejected: Groovy lambdas carry a per-lambda 
class that Java does
+  not, and the fix is the same primitive the closure work needs. The SAM 
corner is the cheapest,
+  safest place to introduce that primitive.
+* *No flag.* Rejected: a bytecode-shape change should carry a back-out for 
tooling that has not
+  yet been updated. The optimisation's all-or-nothing branch makes a single 
toggle clean.
+
+== References
+
+* link:GEP-26.html[GEP-26: GINQ SQL Backend] — companion GEP in the same series
+* 
https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/invoke/LambdaMetafactory.html[`java.lang.invoke.LambdaMetafactory`]
 — the JVM lambda bootstrap this design reuses for the SAM case
+* https://openjdk.org/jeps/126[JEP 126: Lambda Expressions] — Java's "body as 
a method on the enclosing class + metafactory" model, the prior art for the 
shared primitive
+* `org.codehaus.groovy.classgen.asm.ClosureWriter`, 
`org.codehaus.groovy.classgen.asm.sc.StaticTypesLambdaWriter` — the 
code-generation writers this GEP targets
+* `groovy.lang.Closure` — the closure contract whose narrow, reachable subset 
(`curry`/`memoize`/`trampoline`) the shared adapter preserves

Reply via email to