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