GROOVY-12143: lambda hoisting (GEP-27)#2680
Merged
Merged
Conversation
A statically compiled lambda targeting a functional interface currently generates a per-lambda class extending groovy.lang.Closure, even though its runtime value is a LambdaMetafactory-produced functional-interface instance. For a capturing lambda the class also holds the captured state (Reference-wrapped) and is instantiated per lambda. Like the JVM does for Java lambdas, hoist the lambda body onto the enclosing class as a private static method and bootstrap LambdaMetafactory directly against it, so no per-lambda class is generated: - Non-capturing: re-home the already-static doCall onto the enclosing class; the metafactory produces the same zero-allocation singleton. - Read-only capturing: emit a static method taking the captured values as leading parameters and have the metafactory capture those values directly - eliminating the lambda class, its instance, and the Reference wrapping. Captured Reference-field reads in the doCall are repointed to the value parameters (codegen then loads the value directly). Only lambdas sitting directly in a real method are hoisted. Serializable, nested, instance- accessing, and mutated-capture lambdas (which need the shared Reference) are unchanged and keep their generated class. Behaviour and metadata are preserved (verified: Predicate/ BiFunction, multi-capture, mixed capture/SAM types, lambdas in static methods, qualified outer static calls, serializable round-trips, singleton identity for non-capturing, mutated- capture mutation across invocations, parameter type annotations on the hoisted method); stack traces now name a $lambda$ method on the enclosing class (Java-like). Gated by an opt-in system property, groovy.target.lambda.hoist (default off), read per lambda so it can be toggled without a JVM restart, while IDE/tooling compatibility with the changed generated-class shape is verified. Default off keeps existing behaviour unchanged. Testing: - LambdaHoistTest covers the opt-in path (non-capturing and read-only-capturing hoist, singleton identity, multi-capture behaviour) and the fall-back cases (mutated capture, instance-access, serializable, nested, default-off). - Existing tests that assert the pre-hoist generated-class structure (LambdaTest.NonCapturingLambdaOptimizationTest, LambdaTest.NativeLambdaBytecodeTest, LambdaTest.testLambdaClassIsntSynthetic, TypeAnnotationsTest.testTypeAnnotationsForLambda) are annotated @DisabledIfSystemProperty so they skip when the flag is on; the build forwards -P/-Dgroovy.target.lambda.hoist to the test JVM. - The full core test suite passes with the flag on (0 failures; only the pre-hoist bytecode-shape tests skip) and is unchanged with it off. See GEP-27 (Compact Closure and Lambda Compilation) for the broader design.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #2680 +/- ##
==================================================
+ Coverage 68.6965% 68.7123% +0.0158%
- Complexity 34121 34143 +22
==================================================
Files 1536 1536
Lines 128912 129006 +94
Branches 23368 23387 +19
==================================================
+ Hits 88558 88643 +85
- Misses 32523 32527 +4
- Partials 7831 7836 +5
🚀 New features to boost your workflow:
|
blackdrag
approved these changes
Jul 10, 2026
Address review feedback: containsNestedFunction hand-rolled a CodeVisitorSupport
with a boolean[] escape flag; replace it with the read-only fluent query added in
GROOVY-12116, matching the HasRecursiveCalls idiom:
AstQuery.from(code).descendants(ClosureExpression.class).any()
LambdaExpression extends ClosureExpression, so the single descendants() type
covers both nested closures and lambdas. Behaviour is unchanged (a lambda whose
body nests a closure still declines the hoist). The two remaining local visitors
are intentionally left: rebindCapturedFieldsToValueParams mutates the AST (the
query API is read-only) and writesAnyCapture's three-node-type predicate would be
a lateral move, not a simplification.
✅ All tests passed ✅🏷️ Commit: 5abdf5a Learn more about TestLens at testlens.app. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A statically compiled lambda targeting a functional interface currently generates a per-lambda class extending groovy.lang.Closure, even though its runtime value is a LambdaMetafactory-produced functional-interface instance. For a capturing lambda the class also holds the captured state (Reference-wrapped) and is instantiated per lambda.
Like the JVM does for Java lambdas, hoist the lambda body onto the enclosing class as a private static method and bootstrap LambdaMetafactory directly against it, so no per-lambda class is generated:
Only lambdas sitting directly in a real method are hoisted. Serializable, nested, instance- accessing, and mutated-capture lambdas (which need the shared Reference) are unchanged and keep their generated class. Behaviour and metadata are preserved (verified: Predicate/ BiFunction, multi-capture, mixed capture/SAM types, lambdas in static methods, qualified outer static calls, serializable round-trips, singleton identity for non-capturing, mutated- capture mutation across invocations, parameter type annotations on the hoisted method); stack traces now name a$lambda$ method on the enclosing class (Java-like).
Gated by an opt-in system property, groovy.target.lambda.hoist (default off), read per lambda so it can be toggled without a JVM restart, while IDE/tooling compatibility with the changed generated-class shape is verified. Default off keeps existing behaviour unchanged.
Testing:
See GEP-27 (Compact Closure and Lambda Compilation) for the broader design.