Skip to content

GROOVY-12143: lambda hoisting (GEP-27)#2680

Merged
paulk-asert merged 2 commits into
apache:masterfrom
paulk-asert:feat/lambda-hoist
Jul 11, 2026
Merged

GROOVY-12143: lambda hoisting (GEP-27)#2680
paulk-asert merged 2 commits into
apache:masterfrom
paulk-asert:feat/lambda-hoist

Conversation

@paulk-asert

Copy link
Copy Markdown
Contributor

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.

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-commenter

codecov-commenter commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.55556% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.7123%. Comparing base (07adcb3) to head (5abdf5a).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
...roovy/classgen/asm/sc/StaticTypesLambdaWriter.java 85.5556% 5 Missing and 8 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                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     
Files with missing lines Coverage Δ
...roovy/classgen/asm/sc/StaticTypesLambdaWriter.java 90.2256% <85.5556%> (-2.4711%) ⬇️

... and 4 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesLambdaWriter.java Outdated
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.
@testlens-app

testlens-app Bot commented Jul 10, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 5abdf5a
▶️ Tests: 94451 executed
⚪️ Checks: 31/31 completed


Learn more about TestLens at testlens.app.

@paulk-asert paulk-asert merged commit f3e3bff into apache:master Jul 11, 2026
32 checks passed
@paulk-asert paulk-asert deleted the feat/lambda-hoist branch July 11, 2026 04:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants