This is an automated email from the ASF dual-hosted git repository. paulk-asert pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/groovy.git
commit 982c75e2127eb4163799c2b7548009b334afbfc5 Author: Paul King <[email protected]> AuthorDate: Thu Jul 9 22:53:37 2026 +1000 GROOVY-12143: lambda hoisting (GEP-27) 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. --- .../main/groovy/org.apache.groovy-tested.gradle | 7 + .../classgen/asm/sc/StaticTypesLambdaWriter.java | 208 +++++++++++++++- .../groovy/transform/stc/LambdaHoistTest.groovy | 265 +++++++++++++++++++++ .../groovy/groovy/transform/stc/LambdaTest.groovy | 7 + .../groovy/classgen/asm/TypeAnnotationsTest.groovy | 3 + 5 files changed, 487 insertions(+), 3 deletions(-) diff --git a/build-logic/src/main/groovy/org.apache.groovy-tested.gradle b/build-logic/src/main/groovy/org.apache.groovy-tested.gradle index 7bd903679b..f8a4c752e5 100644 --- a/build-logic/src/main/groovy/org.apache.groovy-tested.gradle +++ b/build-logic/src/main/groovy/org.apache.groovy-tested.gradle @@ -80,6 +80,13 @@ tasks.withType(Test).configureEach { systemProperty 'http.agent', "Apache-Maven/3.9.14 (Java ${System.getProperty('java.version')}; ${System.getProperty('os.name')} ${System.getProperty('os.version')})" systemProperty 'groovy.force.illegal.access', findProperty('groovy.force.illegal.access') + // Forward the opt-in non-capturing lambda-hoist flag (GEP-27) to the test JVM so that the + // bytecode-shape tests, which assert the pre-hoist generated-class structure, skip under + // -P/-Dgroovy.target.lambda.hoist=true (see LambdaTest). Set only when present. + def lambdaHoist = findProperty('groovy.target.lambda.hoist') ?: System.getProperty('groovy.target.lambda.hoist') + if (lambdaHoist != null) { + systemProperty 'groovy.target.lambda.hoist', lambdaHoist + } def testdb = System.properties['groovy.testdb.props'] if (testdb) { systemProperty 'groovy.testdb.props', testdb diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesLambdaWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesLambdaWriter.java index 001857cf3c..1c2d643053 100644 --- a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesLambdaWriter.java +++ b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesLambdaWriter.java @@ -18,16 +18,25 @@ */ package org.codehaus.groovy.classgen.asm.sc; +import org.apache.groovy.util.SystemUtil; import org.codehaus.groovy.GroovyBugError; import org.codehaus.groovy.ast.ClassHelper; import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.CodeVisitorSupport; import org.codehaus.groovy.ast.ConstructorNode; +import org.codehaus.groovy.ast.FieldNode; import org.codehaus.groovy.ast.InnerClassNode; import org.codehaus.groovy.ast.MethodNode; import org.codehaus.groovy.ast.Parameter; +import org.codehaus.groovy.ast.expr.BinaryExpression; import org.codehaus.groovy.ast.expr.ClosureExpression; +import org.codehaus.groovy.ast.expr.Expression; import org.codehaus.groovy.ast.expr.LambdaExpression; +import org.codehaus.groovy.ast.expr.PostfixExpression; +import org.codehaus.groovy.ast.expr.PrefixExpression; +import org.codehaus.groovy.ast.expr.VariableExpression; import org.codehaus.groovy.ast.stmt.Statement; +import org.codehaus.groovy.syntax.Types; import org.codehaus.groovy.classgen.BytecodeInstruction; import org.codehaus.groovy.classgen.BytecodeSequence; import org.codehaus.groovy.classgen.asm.BytecodeHelper; @@ -39,10 +48,14 @@ import org.codehaus.groovy.classgen.asm.WriterControllerFactory; import org.codehaus.groovy.transform.sc.StaticCompilationMetadataKeys; import org.objectweb.asm.MethodVisitor; +import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import static org.apache.groovy.ast.tools.ClassNodeUtils.addGeneratedMethod; import static org.codehaus.groovy.ast.ClassHelper.CLOSURE_TYPE; import static org.codehaus.groovy.ast.ClassHelper.GENERATED_LAMBDA_TYPE; import static org.codehaus.groovy.ast.ClassHelper.OBJECT_TYPE; @@ -62,6 +75,7 @@ import static org.objectweb.asm.Opcodes.ACC_FINAL; import static org.objectweb.asm.Opcodes.ACC_PRIVATE; import static org.objectweb.asm.Opcodes.ACC_PUBLIC; import static org.objectweb.asm.Opcodes.ACC_STATIC; +import static org.objectweb.asm.Opcodes.ACC_SYNTHETIC; import static org.objectweb.asm.Opcodes.ALOAD; import static org.objectweb.asm.Opcodes.CHECKCAST; import static org.objectweb.asm.Opcodes.DUP; @@ -105,6 +119,11 @@ public class StaticTypesLambdaWriter extends LambdaWriter implements AbstractFun ClassNode[] markers = collectLambdaMarkers(expression, functionalType); GeneratedLambda generatedLambda = getOrAddGeneratedLambda(expression, abstractMethod); + if (generatedLambda.isHoistedCapturing()) { + writeHoistedCapturingLambda(functionalType.redirect(), abstractMethod, generatedLambda, serializable, markers); + return; + } + ensureDeserializeLambdaSupport(expression, functionalType, abstractMethod, generatedLambda, serializable); if (generatedLambda.isCapturing() && !isPreloadedLambdaReceiver(generatedLambda)) { loadLambdaReceiver(generatedLambda); @@ -253,29 +272,206 @@ public class StaticTypesLambdaWriter extends LambdaWriter implements AbstractFun private GeneratedLambda getOrAddGeneratedLambda(final LambdaExpression expression, final MethodNode abstractMethod) { return generatedLambdas.computeIfAbsent(expression, expr -> { + // Build the lambda class first: this resolves capture/instance-member analysis correctly (a + // doCall on the lambda class sees the enclosing class as an outer), and makes doCall static iff + // the lambda is non-capturing. ClassNode lambdaClass = createLambdaClass(expr, ACC_FINAL | ACC_PUBLIC | ACC_STATIC, abstractMethod); + MethodNode lambdaMethod = getLambdaMethod(lambdaClass); + + // Like Java, a non-capturing (static doCall), non-serializable lambda needs no generated class: + // hoist its static impl onto the enclosing class and bootstrap the metafactory directly against it. + // Opt-in (default off) while IDE/tooling compatibility is verified; set -Dgroovy.target.lambda.hoist=true. + // Only hoist lambdas sitting directly in a real method: a lambda inside a closure/lambda would be + // hoisted onto that generated function rather than a user class, so leave those as generated classes. + boolean baseHoistable = isLambdaHoistEnabled() && !controller.isInGeneratedFunction() + && !expr.isSerializable() && !containsNestedFunction(expr.getCode()); + + if (baseHoistable && !requiresLambdaInstance(lambdaMethod)) { + MethodNode implMethod = hoistLambdaMethodToEnclosingClass(lambdaMethod); + return new GeneratedLambda(controller.getClassNode(), implMethod, null, Parameter.EMPTY_ARRAY, true, false, null); + } + + // Capturing but needing no 'this': hoist onto the enclosing class as a static method taking the + // captured values as leading params, with the metafactory capturing them directly (like Java) - + // eliminating the lambda class, its instance, and the Reference wrapping. Read-only captures only. + Parameter[] shared = getStoredLambdaSharedVariables(expr); + if (baseHoistable && shared.length != 0 && !lambdaAnalyzer.accessesInstanceMembers(lambdaMethod) + && !writesAnyCapture(lambdaMethod.getCode(), shared)) { + GeneratedLambda hoisted = hoistCapturingLambda(lambdaClass, lambdaMethod, shared); + if (hoisted != null) return hoisted; + } + controller.getAcg().addInnerClass(lambdaClass); lambdaClass.addInterface(GENERATED_LAMBDA_TYPE); lambdaClass.putNodeMetaData(StaticCompilationMetadataKeys.STATIC_COMPILE_NODE, Boolean.TRUE); lambdaClass.putNodeMetaData(WriterControllerFactory.class, (WriterControllerFactory) x -> controller); - MethodNode lambdaMethod = getLambdaMethod(lambdaClass); return new GeneratedLambda( lambdaClass, lambdaMethod, getGeneratedConstructor(lambdaClass), getStoredLambdaSharedVariables(expr), !requiresLambdaInstance(lambdaMethod), - lambdaAnalyzer.accessesInstanceMembers(lambdaMethod) + lambdaAnalyzer.accessesInstanceMembers(lambdaMethod), + null ); }); } + /** + * Re-homes a non-capturing lambda's already-prepared {@code static doCall} (types resolved, outer static + * references qualified) as a {@code private static} synthetic method on the enclosing class, so no per-lambda + * inner class is generated and the metafactory bootstraps directly against the enclosing class. + */ + private MethodNode hoistLambdaMethodToEnclosingClass(final MethodNode lambdaMethod) { + MethodNode implMethod = new MethodNode(nextLambdaImplMethodName(), + ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, lambdaMethod.getReturnType(), + lambdaMethod.getParameters(), lambdaMethod.getExceptions(), lambdaMethod.getCode()); + implMethod.setSourcePosition(lambdaMethod); + implMethod.putNodeMetaData(StaticCompilationMetadataKeys.STATIC_COMPILE_NODE, Boolean.TRUE); + addGeneratedMethod(controller.getClassNode(), implMethod); + // Added after the ReturnAdder phase (an inner class would get it via full compilation), so wire the + // implicit return for an expression-bodied lambda here (idempotent when returns already present). + new org.codehaus.groovy.classgen.ReturnAdder().visitMethod(implMethod); + return implMethod; + } + + /** + * Hoists a read-only capturing lambda onto the enclosing class as a static method taking the captured + * values as leading parameters. The lambda-class {@code doCall} already reads its captures from Reference + * fields (accessedVariable = FieldNode); repointing those accesses to plain value parameters makes codegen + * load the value directly, and the metafactory then captures the values as factory arguments. + */ + private GeneratedLambda hoistCapturingLambda(final ClassNode lambdaClass, final MethodNode lambdaMethod, final Parameter[] shared) { + Parameter[] samParams = lambdaMethod.getParameters(); + Parameter[] valueParams = new Parameter[shared.length]; + Map<String, Parameter> byName = new HashMap<>(); + for (int i = 0; i < shared.length; i++) { + Parameter p = new Parameter(shared[i].getOriginType(), shared[i].getName()); + valueParams[i] = p; + byName.put(p.getName(), p); + } + Parameter[] implParams = new Parameter[valueParams.length + samParams.length]; + System.arraycopy(valueParams, 0, implParams, 0, valueParams.length); + System.arraycopy(samParams, 0, implParams, valueParams.length, samParams.length); + + Statement body = lambdaMethod.getCode(); + rebindCapturedFieldsToValueParams(body, byName); + + MethodNode implMethod = new MethodNode(nextLambdaImplMethodName(), + ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, lambdaMethod.getReturnType(), + implParams, lambdaMethod.getExceptions(), body); + implMethod.setSourcePosition(lambdaMethod); + implMethod.putNodeMetaData(StaticCompilationMetadataKeys.STATIC_COMPILE_NODE, Boolean.TRUE); + addGeneratedMethod(controller.getClassNode(), implMethod); + new org.codehaus.groovy.classgen.ReturnAdder().visitMethod(implMethod); + + // nonCapturing=true so no lambda instance is loaded; capturedValueParams drives the dedicated factory. + return new GeneratedLambda(controller.getClassNode(), implMethod, null, Parameter.EMPTY_ARRAY, true, false, valueParams); + } + + /** Repoints captured Reference-field accesses in a hoisted body to plain value parameters. */ + private static void rebindCapturedFieldsToValueParams(final Statement body, final Map<String, Parameter> valueParams) { + body.visit(new CodeVisitorSupport() { + @Override public void visitVariableExpression(final VariableExpression ve) { + Parameter p = valueParams.get(ve.getName()); + if (p != null && ve.getAccessedVariable() instanceof FieldNode) { + ve.setAccessedVariable(p); + ve.setClosureSharedVariable(false); + } + } + }); + } + + /** + * True if the body assigns (or increments) a captured variable — such a lambda needs the shared Reference, + * so decline the value-capture hoist. Matched by name (conservative: never value-captures a mutated capture). + */ + private static boolean writesAnyCapture(final Statement body, final Parameter[] shared) { + Set<String> names = new HashSet<>(); + for (Parameter p : shared) names.add(p.getName()); + boolean[] written = {false}; + body.visit(new CodeVisitorSupport() { + @Override public void visitBinaryExpression(final BinaryExpression be) { + if (Types.isAssignment(be.getOperation().getType())) flagIfCaptured(be.getLeftExpression()); + super.visitBinaryExpression(be); + } + @Override public void visitPostfixExpression(final PostfixExpression pe) { flagIfCaptured(pe.getExpression()); super.visitPostfixExpression(pe); } + @Override public void visitPrefixExpression(final PrefixExpression pe) { flagIfCaptured(pe.getExpression()); super.visitPrefixExpression(pe); } + private void flagIfCaptured(final Expression e) { + if (e instanceof VariableExpression && names.contains(((VariableExpression) e).getName())) { + written[0] = true; + } + } + }); + return written[0]; + } + + /** + * Emits the invokedynamic for a hoisted capturing lambda: push the captured values as metafactory factory + * arguments and bootstrap against the static impl method on the enclosing class (H_INVOKESTATIC). + */ + private void writeHoistedCapturingLambda(final ClassNode functionalType, final MethodNode abstractMethod, final GeneratedLambda gl, final boolean serializable, final ClassNode[] markers) { + Parameter[] captured = gl.capturedValueParams(); + for (Parameter p : captured) { + new VariableExpression(p.getName()).visit(controller.getAcg()); // load captured value (unwraps the Reference) + controller.getOperandStack().doGroovyCast(p.getType()); + } + Parameter[] full = gl.lambdaMethod().getParameters(); + Parameter[] samParams = Arrays.copyOfRange(full, captured.length, full.length); + writeFunctionalInterfaceIndy( + controller.getMethodVisitor(), + abstractMethod.getName(), + createFunctionalInterfaceFactoryDescriptor(functionalType, captured), + createMethodDescriptor(abstractMethod), + H_INVOKESTATIC, + controller.getClassNode(), + gl.lambdaMethod(), + samParams, + serializable, + markers); + controller.getOperandStack().replace(functionalType, captured.length); + } + /** {@inheritDoc} */ @Override protected ClassNode createClosureClass(final ClosureExpression expression, final int modifiers) { return staticTypesClosureWriter.createClosureClass(expression, modifiers); } + private static boolean containsNestedFunction(final Statement code) { + if (code == null) return false; + boolean[] found = {false}; + code.visit(new CodeVisitorSupport() { + @Override public void visitClosureExpression(final ClosureExpression e) { found[0] = true; } + @Override public void visitLambdaExpression(final LambdaExpression e) { found[0] = true; } + }); + return found[0]; + } + + /** + * Whether non-capturing SAM lambdas are hoisted onto the enclosing class (no generated lambda class). + * Opt-in via {@code -Dgroovy.target.lambda.hoist=true} while IDE/tooling compatibility is verified; + * the property is read per lambda so it can be toggled without a JVM restart. + */ + private static boolean isLambdaHoistEnabled() { + return SystemUtil.getBooleanSafe("groovy.target.lambda.hoist"); + } + + private int lambdaImplCounter; + + private String nextLambdaImplMethodName() { + ClassNode enclosing = controller.getClassNode(); + MethodNode enclosingMethod = controller.getMethodNode(); + String owner = (enclosingMethod != null) ? enclosingMethod.getName().replace('<', '_').replace('>', '_') : "init"; + String base = "$lambda$" + owner + "$" + (lambdaImplCounter++); + String name = base; + int extra = 0; + while (!enclosing.getMethods(name).isEmpty()) { + name = base + "$" + (extra++); + } + return name; + } + /** * Creates the synthetic inner class that backs a statically compiled lambda expression. */ @@ -419,12 +615,18 @@ public class StaticTypesLambdaWriter extends LambdaWriter implements AbstractFun */ private record GeneratedLambda(ClassNode lambdaClass, MethodNode lambdaMethod, ConstructorNode constructor, Parameter[] sharedVariables, boolean nonCapturing, - boolean accessingInstanceMembers) { + boolean accessingInstanceMembers, Parameter[] capturedValueParams) { private boolean isCapturing() { return !nonCapturing; } + /** Non-null when the lambda body is hoisted onto the enclosing class as a static method that takes the + * captured values as leading parameters, and the metafactory captures those values directly. */ + private boolean isHoistedCapturing() { + return capturedValueParams != null; + } + private int getImplMethodKind() { return nonCapturing ? H_INVOKESTATIC : H_INVOKEVIRTUAL; } diff --git a/src/test/groovy/groovy/transform/stc/LambdaHoistTest.groovy b/src/test/groovy/groovy/transform/stc/LambdaHoistTest.groovy new file mode 100644 index 0000000000..9814ef5dad --- /dev/null +++ b/src/test/groovy/groovy/transform/stc/LambdaHoistTest.groovy @@ -0,0 +1,265 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.transform.stc + +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.CompilerConfiguration +import org.codehaus.groovy.control.Phases +import org.junit.jupiter.api.Test +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.Label +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertFalse +import static org.junit.jupiter.api.Assertions.assertTrue + +/** + * Tests for the opt-in {@code groovy.target.lambda.hoist} optimization: a non-capturing, non-serializable + * lambda targeting a functional interface has its body compiled as a {@code private static} method on the + * enclosing class (with LambdaMetafactory bootstrapped directly against it), so no per-lambda class is + * generated. Capturing, instance-accessing, serializable and nested cases fall back to a generated lambda + * class. The property is read per lambda, so it can be toggled per compilation here. + */ +final class LambdaHoistTest { + + private static final String PROP = 'groovy.target.lambda.hoist' + + /** Compiles the source with hoisting on/off and returns the sorted generated class names. */ + private static List<String> classNames(String src, boolean hoist) { + withHoist(hoist) { + def cu = new CompilationUnit(new CompilerConfiguration()) + cu.addSource('L.groovy', src) + cu.compile(Phases.CLASS_GENERATION) + cu.classes.collect { it.name }.sort() + } + } + + /** Evaluates the source (with a trailing expression) with hoisting on/off. */ + private static Object eval(String src, String tail, boolean hoist) { + withHoist(hoist) { new GroovyShell().evaluate(src + '\n' + tail) } + } + + private static <T> T withHoist(boolean hoist, Closure<T> work) { + String previous = System.getProperty(PROP) + if (hoist) System.setProperty(PROP, 'true') else System.clearProperty(PROP) + try { + work.call() + } finally { + if (previous != null) System.setProperty(PROP, previous) else System.clearProperty(PROP) + } + } + + private static int lambdaClassCount(List<String> names) { + names.count { it.contains('_lambda') } + } + + private static final String SRC = '''import groovy.transform.CompileStatic + import java.util.function.* + @CompileStatic + class L { + Function<Integer,Integer> nonCap() { (Integer x) -> x * 2 } + Function<Integer,Integer> cap(int base) { (Integer x) -> x + base } + static Function<Integer,Integer> inStatic() { (Integer x) -> x * 3 } + Function<Integer,Integer> callsStatic() { (Integer x) -> helper(x) } + static int helper(int n) { n + 1000 } + }''' + + @Test + void samLambdasAreHoistedWhenEnabled() { + def names = classNames(SRC, true) + // Non-capturing and read-only-capturing SAM lambdas are all hoisted onto L: no lambda class remains. + assertEquals(0, lambdaClassCount(names), "expected no lambda classes, got: $names") + // The hoisted impls are private static $lambda$ methods on L (captured values become leading params). + def hoisted = eval(SRC, 'new L()', true).getClass().declaredMethods.findAll { it.name.startsWith('$lambda$') } + assertTrue(hoisted.size() >= 4, "expected hoisted \$lambda\$ methods on L, got: ${hoisted*.name}") + } + + @Test + void hoistedLambdasBehaveIdenticallyAndStaySingletons() { + def l = eval(SRC, 'new L()', true) + assertEquals(42, l.nonCap().apply(21)) + assertEquals(105, l.cap(100).apply(5)) // read-only capturing lambda, hoisted with value captures + assertEquals(12, l.class.inStatic().apply(4)) // lambda in a static method + assertEquals(1005, l.callsStatic().apply(5)) // qualified outer static call + // non-capturing metafactory instance is a zero-alloc singleton + assertTrue(l.nonCap().is(l.nonCap())) + } + + @Test + void readOnlyCapturingLambdaIsHoisted() { + String src = '''import groovy.transform.CompileStatic + import java.util.function.* + @CompileStatic + class C { + Function<Integer,Integer> add(int base) { (Integer x) -> x + base } + BiFunction<Integer,Integer,Integer> muladd(int a, int b) { (Integer x, Integer y) -> x * a + y + b } + Function<String,String> prefix(String p) { (String s) -> p + s } + }''' + assertEquals(0, lambdaClassCount(classNames(src, true)), 'read-only capturing lambdas should be hoisted') + def c = eval(src, 'new C()', true) + assertEquals(105, c.add(100).apply(5)) + assertEquals(24, c.muladd(10, 1).apply(2, 3)) + assertEquals('xy', c.prefix('x').apply('y')) + } + + @Test + void mutatedCaptureLambdaIsNotHoisted() { + String src = '''import groovy.transform.CompileStatic + import java.util.function.Supplier + @CompileStatic + class M { Supplier<Integer> counter() { int c = 0; (Supplier<Integer>) (() -> { c += 1; c }) } }''' + // Mutating a captured variable needs the shared Reference, so the lambda keeps its class. + assertEquals(1, lambdaClassCount(classNames(src, true)), 'mutated-capture lambda must keep its class') + def s = eval(src, 'new M().counter()', true) + assertEquals([1, 2, 3], [s.get(), s.get(), s.get()]) // mutation preserved across invocations + } + + @Test + void disabledByDefaultKeepsTheLambdaClass() { + def names = classNames(SRC, false) + // With hoisting off, every lambda (incl. the non-capturing ones) generates a class, as today. + assertTrue(lambdaClassCount(names) >= 4, "expected lambda classes when disabled, got: $names") + // ...and still behaves correctly. + assertEquals(42, eval(SRC, 'new L().nonCap().apply(21)', false)) + } + + @Test + void instanceAccessingLambdaIsNotHoisted() { + String src = '''import groovy.transform.CompileStatic + import java.util.function.IntSupplier + @CompileStatic + class Counter { int count = 7; IntSupplier supplier() { () -> count } }''' + def names = classNames(src, true) + assertEquals(1, lambdaClassCount(names), "instance-accessing lambda must keep its class: $names") + assertEquals(7, eval(src, 'new Counter().supplier().getAsInt()', true)) + } + + @Test + void serializableNonCapturingLambdaFallsBackAndRoundTrips() { + String src = '''import groovy.transform.CompileStatic + import java.util.function.Function + @CompileStatic + class S { + static Function<Integer,Integer> make() { + (Function<Integer,Integer> & Serializable) (Integer x) -> x * 2 + } + }''' + def names = classNames(src, true) + assertEquals(1, lambdaClassCount(names), "serializable lambda must keep its class: $names") + def roundTripped = eval(src, ''' + def f = S.make() + def out = new ByteArrayOutputStream() + new ObjectOutputStream(out).writeObject(f) + def f2 = new ObjectInputStream(new ByteArrayInputStream(out.toByteArray())).readObject() + ((java.util.function.Function) f2).apply(10) + ''', true) + assertEquals(20, roundTripped) + } + + @Test + void lambdaContainingNestedFunctionIsNotHoisted() { + String src = '''import groovy.transform.CompileStatic + import java.util.function.Supplier + @CompileStatic + class N { + Supplier<List<Integer>> outer() { () -> [1, 2, 3].collect { it * 2 } } + }''' + def names = classNames(src, true) + // The outer lambda nests a closure, so it is not hoisted (kept flat for the first step). + assertFalse(lambdaClassCount(names) == 0, "nested-function case should keep a lambda class: $names") + assertEquals([2, 4, 6], eval(src, 'new N().outer().get()', true)) + } + + @Test + void hoistedLambdaMethodCarriesDebugMetadata() { + // A multi-line, read-only-capturing lambda: locals declared in the body, captured value (base) and + // SAM parameter (x). Tooling (IDE debuggers) needs the hoisted method to keep line numbers per body + // line and named local variables, so step/breakpoint/inspect work as they do on the enclosing method. + String src = '''import groovy.transform.CompileStatic + import java.util.function.Function + @CompileStatic + class D { + Function<Integer,Integer> build(int base) { + return (Integer x) -> { + int a = x + base + int b = a * 2 + return b - 1 + } + } + }''' + byte[] bytes = compiledBytes(src, 'D', true) + def method = hoistedMethod(bytes) + assertTrue(method != null, 'expected a hoisted $lambda$ method on D') + def info = debugInfo(bytes, method) + // one LineNumberTable entry per body statement (the three lines of the block body) + assertTrue(info.lineCount >= 3, "expected >=3 line-number entries, got ${info.lineCount}") + // the captured value, the SAM parameter and the in-body local must all be named for the debugger + assertTrue(info.locals.containsAll(['base', 'x', 'a', 'b']), + "expected named locals base/x/a/b in LocalVariableTable, got ${info.locals}") + assertTrue(info.synthetic, 'hoisted impl should be ACC_SYNTHETIC so tools hide it') + } + + /** Compiles the source with hoisting on/off (debug info on) and returns the named class's bytes. */ + private static byte[] compiledBytes(String src, String className, boolean hoist) { + withHoist(hoist) { + def config = new CompilerConfiguration(debug: true) + def cu = new CompilationUnit(config) + cu.addSource("${className}.groovy", src) + cu.compile(Phases.CLASS_GENERATION) + cu.classes.find { it.name == className }.bytes + } + } + + /** Name of the first hoisted {@code $lambda$} method in the class, or null if none. */ + private static String hoistedMethod(byte[] bytes) { + String found = null + new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] exc) { + if (found == null && name.startsWith('$lambda$')) found = name + return null + } + }, ClassReader.SKIP_CODE) + found + } + + /** LineNumberTable entry count, LocalVariableTable names and the synthetic flag for the given method. */ + private static Map debugInfo(byte[] bytes, String methodName) { + int[] lineCount = [0] + List<String> locals = [] + boolean[] synthetic = [false] + new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] exc) { + if (name != methodName) return null + synthetic[0] = (access & Opcodes.ACC_SYNTHETIC) != 0 + return new MethodVisitor(Opcodes.ASM9) { + @Override + void visitLineNumber(int line, Label start) { lineCount[0]++ } + @Override + void visitLocalVariable(String n, String d, String s, Label st, Label e, int idx) { locals << n } + } + } + }, 0) + [lineCount: lineCount[0], locals: locals, synthetic: synthetic[0]] + } +} diff --git a/src/test/groovy/groovy/transform/stc/LambdaTest.groovy b/src/test/groovy/groovy/transform/stc/LambdaTest.groovy index f3a784ef9a..692d60df9a 100644 --- a/src/test/groovy/groovy/transform/stc/LambdaTest.groovy +++ b/src/test/groovy/groovy/transform/stc/LambdaTest.groovy @@ -21,6 +21,7 @@ package groovy.transform.stc import org.codehaus.groovy.classgen.asm.AbstractBytecodeTestCase import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.DisabledIfSystemProperty import static groovy.test.GroovyAssert.assertScript import static groovy.test.GroovyAssert.shouldFail @@ -1877,6 +1878,8 @@ final class LambdaTest { // GROOVY-9770 @Test + @DisabledIfSystemProperty(named = 'groovy.target.lambda.hoist', matches = 'true', + disabledReason = 'asserts the pre-hoist generated lambda class exists (GEP-27)') void testLambdaClassIsntSynthetic() { assertScript shell, ''' class Foo { @@ -1893,6 +1896,8 @@ final class LambdaTest { // GROOVY-11905 @Nested + @DisabledIfSystemProperty(named = 'groovy.target.lambda.hoist', matches = 'true', + disabledReason = 'asserts the pre-hoist non-capturing lambda bytecode shape (doCall on the generated lambda class); superseded when GEP-27 hoisting is enabled') class NonCapturingLambdaOptimizationTest extends AbstractBytecodeTestCase { @Test void testNonCapturingLambdaWithFunctionInStaticMethod() { @@ -3427,6 +3432,8 @@ final class LambdaTest { } @Nested + @DisabledIfSystemProperty(named = 'groovy.target.lambda.hoist', matches = 'true', + disabledReason = 'asserts the pre-hoist native lambda bytecode shape (doCall on the generated lambda class); superseded when GEP-27 hoisting is enabled') class NativeLambdaBytecodeTest extends AbstractBytecodeTestCase { @Test void testNonCapturingLambdaUsesCaptureFreeInvokeDynamic() { diff --git a/src/test/groovy/org/codehaus/groovy/classgen/asm/TypeAnnotationsTest.groovy b/src/test/groovy/org/codehaus/groovy/classgen/asm/TypeAnnotationsTest.groovy index ad7eb8cab4..13cbf2e3e6 100644 --- a/src/test/groovy/org/codehaus/groovy/classgen/asm/TypeAnnotationsTest.groovy +++ b/src/test/groovy/org/codehaus/groovy/classgen/asm/TypeAnnotationsTest.groovy @@ -19,6 +19,7 @@ package org.codehaus.groovy.classgen.asm import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.DisabledIfSystemProperty final class TypeAnnotationsTest extends AbstractBytecodeTestCase { @@ -288,6 +289,8 @@ final class TypeAnnotationsTest extends AbstractBytecodeTestCase { // GROOVY-11479 @Test + @DisabledIfSystemProperty(named = 'groovy.target.lambda.hoist', matches = 'true', + disabledReason = 'inspects the pre-hoist generated lambda class (Foo$_lambda1); with GEP-27 hoisting the type annotation is carried on the enclosing-class $lambda$ method instead') void testTypeAnnotationsForLambda() { def bytecode = compile(classNamePattern: 'Foo\\$_lambda1', method: 'doCall', imports + ''' @Retention(RUNTIME) @Target(TYPE_USE) @interface TypeAnno0 { }
