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 f8a4c752e5d..14c506d0f4b 100644 --- a/build-logic/src/main/groovy/org.apache.groovy-tested.gradle +++ b/build-logic/src/main/groovy/org.apache.groovy-tested.gradle @@ -87,6 +87,11 @@ tasks.withType(Test).configureEach { if (lambdaHoist != null) { systemProperty 'groovy.target.lambda.hoist', lambdaHoist } + // Forward the automatic @CompileStatic closure-packing flag (GEP-27) to the test JVM. Set only when present. + def closurePack = findProperty('groovy.target.closure.pack') ?: System.getProperty('groovy.target.closure.pack') + if (closurePack != null) { + systemProperty 'groovy.target.closure.pack', closurePack + } def testdb = System.properties['groovy.testdb.props'] if (testdb) { systemProperty 'groovy.testdb.props', testdb @@ -251,6 +256,13 @@ tasks.withType(GroovyCompile).configureEach { } } } + // Forward the opt-in closure-packing flag (GEP-27) to the forked Groovy compiler: packing happens + // at compile time, so test sources are only packed when the compiler JVM sees the property (the + // Test-task systemProperty above covers runtime assertScript compilation). Set only when present. + def closurePack = findProperty('groovy.target.closure.pack') ?: System.getProperty('groovy.target.closure.pack') + if (closurePack != null) { + groovyOptions.forkOptions.jvmArgs += ["-Dgroovy.target.closure.pack=${closurePack}" as String] + } } class TestCommandLineArgumentProvider implements CommandLineArgumentProvider { diff --git a/src/main/java/groovy/lang/Closure.java b/src/main/java/groovy/lang/Closure.java index 3845f661b60..bb7f50010d9 100644 --- a/src/main/java/groovy/lang/Closure.java +++ b/src/main/java/groovy/lang/Closure.java @@ -33,6 +33,8 @@ import org.codehaus.groovy.runtime.memoize.ConcurrentSoftCache; import org.codehaus.groovy.runtime.memoize.LRUCache; import org.codehaus.groovy.runtime.memoize.Memoize; +import org.codehaus.groovy.runtime.metaclass.ClosureMetaClass; +import org.codehaus.groovy.runtime.metaclass.PackedClosureMetaClass; import java.io.IOException; import java.io.InvalidObjectException; @@ -604,7 +606,8 @@ public V call(final Object... arguments) { private boolean nonStockMetaClass() { MetaClass mc = super.getMetaClass(); Class mcClass = (mc == null) ? null : mc.getClass(); - return mcClass != org.codehaus.groovy.runtime.metaclass.ClosureMetaClass.class && mcClass != MetaClassImpl.class; + return mcClass != ClosureMetaClass.class && mcClass != MetaClassImpl.class + && mcClass != PackedClosureMetaClass.class; } @Override diff --git a/src/main/java/groovy/lang/MetaClassRegistry.java b/src/main/java/groovy/lang/MetaClassRegistry.java index 5721d9c9ef8..f5574b1c850 100644 --- a/src/main/java/groovy/lang/MetaClassRegistry.java +++ b/src/main/java/groovy/lang/MetaClassRegistry.java @@ -19,7 +19,9 @@ package groovy.lang; import org.codehaus.groovy.runtime.GeneratedClosure; +import org.codehaus.groovy.runtime.PackedClosure; import org.codehaus.groovy.runtime.metaclass.ClosureMetaClass; +import org.codehaus.groovy.runtime.metaclass.PackedClosureMetaClass; import java.lang.reflect.Constructor; import java.util.Iterator; @@ -167,7 +169,14 @@ private MetaClass createWithCustomLookup(Class theClass, MetaClassRegistry regis * @return the default meta class implementation for the type */ protected MetaClass createNormalMetaClass(Class theClass,MetaClassRegistry registry) { - if (GeneratedClosure.class.isAssignableFrom(theClass)) { + if (PackedClosure.class.isAssignableFrom(theClass)) { + // the packed adapter family shares its dispatch machinery, so it gets its own + // metaclass with reflection-free call/doCall dispatch and instance-faithful + // introspection. Checked before GeneratedClosure: the fixed-arity members carry + // that marker (for class-level arity introspection) but must not fall into + // ClosureMetaClass, whose reflective dispatch assumes per-literal classes. + return new PackedClosureMetaClass(registry, theClass); + } else if (GeneratedClosure.class.isAssignableFrom(theClass)) { return new ClosureMetaClass(registry,theClass); } else { return new MetaClassImpl(registry, theClass); diff --git a/src/main/java/groovy/transform/PackedClosures.java b/src/main/java/groovy/transform/PackedClosures.java new file mode 100644 index 00000000000..cb7e7fb5ba3 --- /dev/null +++ b/src/main/java/groovy/transform/PackedClosures.java @@ -0,0 +1,126 @@ +/* + * 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; + +import org.apache.groovy.lang.annotation.Incubating; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Opt-in for compact closure compilation: within the annotated class or method, an eligible closure + * literal's body is hoisted into a synthetic method on the enclosing class and the literal is replaced + * by a shared {@link org.codehaus.groovy.runtime.PackedClosure} adapter, instead of generating one + * inner class per closure. The value stays a real {@code groovy.lang.Closure}, so {@code curry}, + * {@code memoize}, {@code trampoline} and iteration keep working; captured values are threaded by + * value (read-only) or via a shared {@code groovy.lang.Reference} (when written), so a packed closure + * behaves identically to the class-based form. + *

+ * Packing is best-effort: a closure that cannot be packed is compiled exactly as today (a generated + * closure class), so the annotation is always safe to add. A closure is kept as a class when it: + *

+ * Under dynamic compilation the annotation is a trust assertion the compiler cannot verify; the + * {@code PackedClosure} adapter then fails fast if a delegate, or a delegate-consulting + * {@code resolveStrategy}, is later set on a packed closure. + * Automatic packing of provably-safe {@code @CompileStatic} closures — without this annotation — is + * available behind the {@code groovy.target.closure.pack} flag. Use {@link #mode()} to have declines + * reported (or to opt a scope out); on the un-annotated flag path, + * {@code groovy.target.closure.pack.report=true} reports every decline with its reason as a + * compiler warning — the operational way to see where the packability boundary falls in a codebase. + *

+ * Because every packed closure is an instance of a small fixed-arity adapter family + * ({@code PackedClosure$Fixed0..4}, {@code $FixedIt}, {@code $FixedN}) rather than its own class, + * three differences from the class-based form remain that cannot, in general, be detected at + * compile time (the visibly serialization-bound cases above are the detectable exception): + *

+ * + * @see PackMode + */ +@Incubating +@Retention(RetentionPolicy.SOURCE) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface PackedClosures { + /** + * The packing behaviour of this scope: {@link PackMode#LENIENT} (pack, silent on declines, the + * default), {@link PackMode#WARN} (pack, a compiler warning per decline with the reason), + * {@link PackMode#STRICT} (pack, a compiler error on any decline), or {@link PackMode#DISABLED} + * (do not pack). The most-specific declaration wins, so a {@code DISABLED} method opts out of a + * packed class (and {@code DISABLED} also overrides the automatic {@code groovy.target.closure.pack} + * flag); the WARN/STRICT diagnostics apply only to the annotated scope, never to the flag. + */ + PackMode mode() default PackMode.LENIENT; + + /** + * The packing behaviour of a {@link PackedClosures} scope. {@link #LENIENT}, {@link #WARN} and + * {@link #STRICT} all pack, differing only in how they report a closure that could not be + * packed (declines are otherwise silent). {@link #DISABLED} is the opposite: it opts the scope out + * of packing entirely, so the most-specific declaration wins — a {@code DISABLED} method inside a + * packed class, or vice versa, and it overrides the automatic {@code groovy.target.closure.pack} + * flag as well. + */ + @Incubating + enum PackMode { + + /** Do not pack any closure in this scope; overrides an enclosing opt-in and the automatic flag. */ + DISABLED, + + /** Silently fall back to a generated closure class for any closure that cannot be packed (default). */ + LENIENT, + + /** Emit a compiler warning, naming the closure and why it declined, for each that cannot be packed. */ + WARN, + + /** Emit a compiler error for any closure in the scope that cannot be packed. */ + STRICT + } +} diff --git a/src/main/java/org/codehaus/groovy/ast/ClassNode.java b/src/main/java/org/codehaus/groovy/ast/ClassNode.java index 5ed4541825a..02f652bd0d3 100644 --- a/src/main/java/org/codehaus/groovy/ast/ClassNode.java +++ b/src/main/java/org/codehaus/groovy/ast/ClassNode.java @@ -1820,23 +1820,25 @@ public void visitContents(GroovyClassVisitor visitor) { } private void visitMethods(GroovyClassVisitor visitor) { - // create snapshot of the method list to avoid ConcurrentModificationException - List methodList = new ArrayList<>(getMethods()); - for (MethodNode mn : methodList) { - visitor.visitMethod(mn); - } - - // visit the method nodes added while iterating, - // e.g. synthetic method for constructor reference - final List newMethodList = getMethods(); - if (newMethodList.size() > methodList.size()) { // if the newly added method nodes found, visit them - List changedMethodList = new ArrayList<>(newMethodList); - boolean changed = changedMethodList.removeAll(methodList); - if (changed) { - for (MethodNode mn : changedMethodList) { + // Visit each method exactly once, including methods added WHILE visiting -- a synthetic method + // for a constructor reference, a lambda deserialization hook, or a hoisted closure body that in + // turn hoists a nested one. Track already-visited methods by identity (MethodNode uses the + // default equals) in a hash set for O(1) membership, and re-scan getMethods() after each round + // for the newly added ones, until none remain. + Set visited = Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + List pending = new ArrayList<>(getMethods()); // snapshot avoids ConcurrentModificationException + while (!pending.isEmpty()) { + for (MethodNode mn : pending) { + if (visited.add(mn)) { visitor.visitMethod(mn); } } + pending = new ArrayList<>(); + for (MethodNode mn : getMethods()) { + if (!visited.contains(mn)) { + pending.add(mn); + } + } } } diff --git a/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java b/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java index 46bea1fb5d3..71dc444c52f 100644 --- a/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java +++ b/src/main/java/org/codehaus/groovy/classgen/AsmClassGenerator.java @@ -435,6 +435,9 @@ public void visitClass(final ClassNode classNode) { MopWriter mopWriter = mopWriterFactory.create(controller); mopWriter.createMopMethods(); controller.getCallSiteWriter().generateCallSiteArray(); + // GROOVY-12151: after every method (including hoisted bodies added during the + // visit) has claimed its id, render the class's packed-closure dispatch table + controller.getClosureWriter().writePackedDispatcher(); createSyntheticStaticFields(); } } diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/ClosureWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/ClosureWriter.java index 6f89a136e82..0554d8f1c0a 100644 --- a/src/main/java/org/codehaus/groovy/classgen/asm/ClosureWriter.java +++ b/src/main/java/org/codehaus/groovy/classgen/asm/ClosureWriter.java @@ -18,12 +18,18 @@ */ package org.codehaus.groovy.classgen.asm; +import groovy.transform.PackedClosures.PackMode; import org.codehaus.groovy.GroovyBugError; +import org.codehaus.groovy.ast.AnnotationNode; +import org.apache.groovy.util.SystemUtil; import org.codehaus.groovy.ast.ClassHelper; +import org.codehaus.groovy.control.CompilerConfiguration; import org.codehaus.groovy.ast.ClassNode; import org.codehaus.groovy.ast.CodeVisitorSupport; +import org.codehaus.groovy.ast.DynamicVariable; import org.codehaus.groovy.ast.ConstructorNode; import org.codehaus.groovy.ast.FieldNode; +import org.codehaus.groovy.ast.PropertyNode; import org.codehaus.groovy.ast.InnerClassNode; import org.codehaus.groovy.ast.MethodNode; import org.codehaus.groovy.ast.Parameter; @@ -32,42 +38,82 @@ import org.codehaus.groovy.ast.expr.ClassExpression; import org.codehaus.groovy.ast.expr.ClosureExpression; import org.codehaus.groovy.ast.expr.ConstructorCallExpression; +import org.codehaus.groovy.ast.expr.ArrayExpression; +import org.codehaus.groovy.ast.expr.BinaryExpression; +import org.codehaus.groovy.ast.expr.CastExpression; +import org.codehaus.groovy.ast.expr.DeclarationExpression; import org.codehaus.groovy.ast.expr.Expression; import org.codehaus.groovy.ast.expr.FieldExpression; +import org.codehaus.groovy.ast.expr.ListExpression; +import org.codehaus.groovy.ast.expr.MapEntryExpression; +import org.codehaus.groovy.ast.expr.MethodCallExpression; +import org.codehaus.groovy.ast.expr.PropertyExpression; +import org.codehaus.groovy.ast.expr.PostfixExpression; +import org.codehaus.groovy.ast.expr.PrefixExpression; import org.codehaus.groovy.ast.expr.TupleExpression; import org.codehaus.groovy.ast.expr.VariableExpression; import org.codehaus.groovy.ast.stmt.BlockStatement; +import org.codehaus.groovy.ast.stmt.ExpressionStatement; +import org.codehaus.groovy.ast.stmt.ReturnStatement; +import org.codehaus.groovy.ast.stmt.Statement; import org.codehaus.groovy.ast.tools.GenericsUtils; import org.codehaus.groovy.classgen.AsmClassGenerator; +import org.codehaus.groovy.classgen.ReturnAdder; +import org.codehaus.groovy.control.messages.WarningMessage; +import org.codehaus.groovy.runtime.GeneratedDispatcher; +import org.codehaus.groovy.syntax.Types; +import org.codehaus.groovy.transform.stc.StaticTypesMarker; +import org.codehaus.groovy.transform.trait.Traits; import org.objectweb.asm.MethodVisitor; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; import java.util.Iterator; 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.tools.GeneralUtils.args; import static org.codehaus.groovy.ast.tools.GeneralUtils.callThisX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.callX; import static org.codehaus.groovy.ast.tools.GeneralUtils.constX; import static org.codehaus.groovy.ast.tools.GeneralUtils.ctorSuperX; import static org.codehaus.groovy.ast.tools.GeneralUtils.nullX; import static org.codehaus.groovy.ast.tools.GeneralUtils.returnS; import static org.codehaus.groovy.ast.tools.GeneralUtils.stmt; import static org.codehaus.groovy.ast.tools.GeneralUtils.varX; +import static org.codehaus.groovy.transform.sc.StaticCompilationMetadataKeys.STATIC_COMPILE_NODE; import static org.codehaus.groovy.transform.stc.StaticTypesMarker.INFERRED_RETURN_TYPE; +import org.objectweb.asm.ConstantDynamic; +import org.objectweb.asm.Handle; 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.AALOAD; +import static org.objectweb.asm.Opcodes.AASTORE; import static org.objectweb.asm.Opcodes.ACC_SYNTHETIC; +import static org.objectweb.asm.Opcodes.ACONST_NULL; import static org.objectweb.asm.Opcodes.ALOAD; +import static org.objectweb.asm.Opcodes.ANEWARRAY; +import static org.objectweb.asm.Opcodes.ARETURN; +import static org.objectweb.asm.Opcodes.ATHROW; +import static org.objectweb.asm.Opcodes.CHECKCAST; import static org.objectweb.asm.Opcodes.DUP; import static org.objectweb.asm.Opcodes.GETFIELD; +import static org.objectweb.asm.Opcodes.H_INVOKESTATIC; +import static org.objectweb.asm.Opcodes.ICONST_0; +import static org.objectweb.asm.Opcodes.ILOAD; +import static org.objectweb.asm.Opcodes.ISHR; import static org.objectweb.asm.Opcodes.INVOKESPECIAL; +import static org.objectweb.asm.Opcodes.INVOKESTATIC; import static org.objectweb.asm.Opcodes.INVOKEVIRTUAL; import static org.objectweb.asm.Opcodes.NEW; @@ -94,6 +140,53 @@ protected interface UseExistingReference { /** The controller coordinating all bytecode writers for the current class. */ protected final WriterController controller; private final Map closureClasses = new HashMap<>(); + // Closures found in a syntactic position that lets them outlive the method are cached per method. + private final Map> escapingClosuresByMethod = new HashMap<>(); + // Closure literals visibly bound for serialization (cast/coerced to Serializable, or passed + // directly to writeObject), cached per method like the escape gate above. + private final Map> serializationBoundByMethod = new HashMap<>(); + // Names assigned anywhere in a method (excluding declarations), cached per method: a capture must + // be Reference-threaded if it is written ANYWHERE in the enclosing method, not just inside the + // closure -- e.g. `def fib; fib = { n -> ... fib(n-1) ... }` writes fib after the closure is + // constructed, so a by-value capture would see the stale (null) value. + private final Map> writtenNamesByMethod = new HashMap<>(); + + /** Name prefix of the synthetic methods holding hoisted closure bodies. */ + /** Name prefix of hoisted packed-closure bodies (also consulted by the static call-site writer). */ + public static final String PACKED_METHOD_PREFIX = "$packed$closure$"; + + // The class's registered dispatch targets (hoisted closure bodies), in id order. Kept as node + // metadata on the CLASS, not on this writer: in a class mixing dynamic and @CompileStatic methods + // the controller routes closures to two different ClosureWriter instances (see + // StaticTypesWriterController#getClosureWriter), and both must share one id space. The end-of-class + // hook (writePackedDispatcher) turns the list into the class's GeneratedDispatcher table. + private static final String PACKED_TARGETS = "org.codehaus.groovy.classgen.asm.ClosureWriter.packedTargets"; + private static final String DISPATCHER_TYPE = "org/codehaus/groovy/runtime/GeneratedDispatcher"; + private static final String BUNDLE_TYPE = DISPATCHER_TYPE + "$Bundle"; + // The general table covers every target via a packed argument array; the per-arity tables + // cover the hot one/two-value shapes with plain parameters (no array to allocate or escape). + private static final String DISPATCH_METHOD = GeneratedDispatcher.TABLE_METHOD; + private static final String DISPATCH_DESC = "(I[Ljava/lang/Object;)Ljava/lang/Object;"; + private static final String DISPATCH1_METHOD = GeneratedDispatcher.TABLE1_METHOD; + private static final String DISPATCH1_DESC = "(ILjava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"; + private static final String DISPATCH2_METHOD = GeneratedDispatcher.TABLE2_METHOD; + private static final String DISPATCH2_DESC = "(ILjava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"; + // The accessor's single invokedynamic site links all three tables through + // GeneratedDispatcher.bootstrap into one constant Bundle, lazily on first adapter creation. + private static final String DISPATCHERS_GETTER = "$getPackedDispatchers$"; + private static final String DISPATCHERS_GETTER_DESC = "()L" + BUNDLE_TYPE + ";"; + // Max tableswitch cases per dispatch method (power of two: the two-level entry method selects a + // chunk with a shift); sized so a full chunk stays well under the JIT's 325-byte inlining budget. + private static final int DISPATCH_CHUNK = 8; + + private static List packedTargets(final ClassNode classNode) { + List targets = classNode.getNodeMetaData(PACKED_TARGETS); + if (targets == null) { + targets = new ArrayList<>(); + classNode.putNodeMetaData(PACKED_TARGETS, targets); + } + return targets; + } /** * Creates a closure writer with the given controller. @@ -110,6 +203,17 @@ public ClosureWriter(final WriterController controller) { * @param expression the closure expression */ public void writeClosure(final ClosureExpression expression) { + // @PackedClosures spike: for an eligible top-level closure in a @PackedClosures scope, hoist the + // body to a method on the enclosing class and emit a shared PackedClosure adapter, instead of + // generating a per-closure inner class. Captured variables are threaded by value. Closures nested + // inside another closure are left alone (their enclosing context is a generated function, not the + // owner class), as are closures needing real Closure semantics. + if (chooseStrategy(expression) == PackStrategy.PACKED_ADAPTER) { + writePackedClosure(expression); + return; + } + reportUnpacked(expression); // @PackedClosures(mode = WARN | STRICT) diagnostics for declines + CompileStack compileStack = controller.getCompileStack(); MethodVisitor mv = controller.getMethodVisitor(); ClassNode classNode = controller.getClassNode(); @@ -158,6 +262,1038 @@ public void writeClosure(final ClosureExpression expression) { controller.getOperandStack().replace(ClassHelper.CLOSURE_TYPE, localVariableParams.length); } + /** + * GEP-27 capability analysis: choose the compilation strategy for a closure literal. + *

+ * A closure is packed (S1, {@link PackStrategy#PACKED_ADAPTER}) when it is triggered — + * either the enclosing scope opts in via {@code @PackedClosures} (the dynamic trust path), or the + * capability analysis proves it delegate-independent under {@code @CompileStatic} (the + * automatic path, see {@link #isDelegateIndependent}) — and it is in a packable position: + * it needs no real {@code Closure} semantics we cannot reproduce ({@link #isPackable}), it is + * written directly in a method rather than nested in another closure (owner-retargeting for + * nested closures is later work), and it does not visibly escape its method. Otherwise it stays a + * full generated class (S0, {@link PackStrategy#FULL_CLASS}) exactly as today. + */ + private PackStrategy chooseStrategy(final ClosureExpression expression) { + // The complete packability decision, in order (GEP-27 "The packability decision procedure"; + // any decline keeps the closure a generated class, exactly as today): + // 1. needs real Closure semantics (owner/delegate/thisObject/resolveStrategy/metaClass/ + // super, default parameter values, anonymous inner class)? + if (!isPackable(expression)) return PackStrategy.FULL_CLASS; + // 2. scope opted out? The most-specific @PackedClosures mode wins (method over class), and + // DISABLED beats an enclosing opt-in AND the flag. + PackMode mode = annotatedMode(); + if (mode == PackMode.DISABLED) return PackStrategy.FULL_CLASS; + boolean annotated = (mode != null); // LENIENT/WARN/STRICT all opt in (DISABLED handled above) + // 3. triggered and sound? annotation (dynamic trust), flag + syntactic no-free-name proof + // (dynamic), or flag/annotation + the type checker's delegate-independence proof (CS, + // which also declines mixed-mode dynamic islands and Map-owner property semantics). + if (!isPackTriggered(expression, annotated)) return PackStrategy.FULL_CLASS; + // 4. structural position: directly in a method of the owner class (nested closures await + // owner-retargeting)? + if (controller.isInGeneratedFunction()) return PackStrategy.FULL_CLASS; + // 5. visibly escapes (field/property/index store, return, <<, collection literal)? + if (escapesEnclosingMethod(expression)) return PackStrategy.FULL_CLASS; + // 6. visibly serialization-bound (Serializable cast/coercion, writeObject directly or via + // a literal-holding local)? + if (serializationBound(expression)) return PackStrategy.FULL_CLASS; + // 7. a field initializer (a field store the escape walk cannot see)? + if (isFieldInitializer(expression)) return PackStrategy.FULL_CLASS; + // 8.-10. a context the adapter cannot inhabit: intersection cast (per-literal marker + // interfaces), trait body ($Trait$Helper's synthetic receiver), or a this(...)/super(...) + // argument (uninitializedThis cannot be the dispatch receiver)? + if (isIntersectionTyped(expression)) return PackStrategy.FULL_CLASS; + if (isInTraitContext()) return PackStrategy.FULL_CLASS; + if (controller.getCompileStack().isInSpecialConstructorCall()) return PackStrategy.FULL_CLASS; + return PackStrategy.PACKED_ADAPTER; + } + + /** + * A closure in a field initializer ({@code def action = { ... }} at class level) is stored + * into a field by definition — the same escape the escape gate declines for in-method stores — + * but field initializers compile outside the enclosing method's visible code, so the escape + * walk cannot see them. Detected directly against the class's fields (identity match). + */ + private boolean isFieldInitializer(final ClosureExpression expression) { + for (FieldNode fn : controller.getClassNode().getFields()) { + if (fn.getInitialValueExpression() == expression) return true; + } + return false; + } + + /** + * A closure literal cast to an intersection type (e.g. {@code (Runnable & Serializable) { }}) + * needs its generated class to declare the marker interfaces (see + * {@code StaticTypesClosureWriter#addIntersectionMarkers}), which the one shared + * {@code PackedClosure} adapter cannot do per-literal — so keep it a class. The marker list is + * recorded by the type checker; a non-empty one is the signal. + */ + private static boolean isIntersectionTyped(final ClosureExpression expression) { + Object markers = expression.getNodeMetaData(StaticTypesMarker.LAMBDA_MARKERS); + return markers instanceof List && !((List) markers).isEmpty(); + } + + /** + * Whether the closure is being compiled inside a trait. A trait's method bodies (and their + * closures) are moved into a static {@code $Trait$Helper} nested class whose {@code this} is a + * synthetic {@code $self} parameter, not an instance receiver — a shape the packed dispatch + * codegen does not reproduce (it produced invalid bytecode). Decline and keep the closure as a + * class until the trait context is supported. Detected by walking the enclosing class's outer + * chain to the {@code @Trait}-annotated interface. + */ + private boolean isInTraitContext() { + for (ClassNode c = controller.getClassNode(); c != null; c = c.getOuterClass()) { + if (Traits.isTrait(c)) return true; + } + return false; + } + + /** + * Whether a packable, non-escaping closure literal should actually be packed. Dynamic compilation + * has no proof of delegate-independence, so the only trigger is the {@code @PackedClosures} trust + * assertion; {@code StaticTypesClosureWriter} overrides this to add the + * {@code groovy.target.closure.pack} flag and the delegate-independence proof. + * + * @param annotated whether a non-DISABLED {@code @PackedClosures} is in scope + */ + protected boolean isPackTriggered(final ClosureExpression expression, final boolean annotated) { + if (annotated) return true; + // Dynamic compilation has no delegate-independence proof from the type checker, so the + // annotation is normally the only trigger. But a closure with NO free names -- no + // implicit-this call, no unqualified/dynamic variable -- cannot be affected by any + // caller-set delegate at all, so it is delegate-independent by syntax alone, no types + // needed. The flag auto-packs exactly that provably-safe subset (GROOVY-12151 dynamic + // syntactic path); a set delegate on such a closure is a harmless no-op, so it needs no + // strict runtime guard (marked here for writePackedClosure). + if (SystemUtil.getBooleanSafe(CompilerConfiguration.CLOSURE_PACKING) && isSyntacticallyDelegateIndependent(expression)) { + expression.putNodeMetaData(SYNTACTIC_PACK, Boolean.TRUE); + return true; + } + return false; + } + + /** Metadata marking a closure packed because it is syntactically delegate-independent (no strict guard). */ + private static final String SYNTACTIC_PACK = "org.codehaus.groovy.classgen.asm.ClosureWriter.syntacticPack"; + + /** + * Whether the closure's body contains no name a caller-set delegate could intercept — no + * implicit-this method call or property access, and no unqualified/dynamic variable (a free + * name the runtime would resolve through the owner/delegate chain). Only parameters, locals, + * captured variables, constants and explicit/parameter receivers remain, none of which a + * delegate can touch, so such a closure is delegate-independent by construction regardless of + * types. Nested closures are walked too (a free name anywhere carries the delegate chain). + * Conservative: any doubt returns {@code false} (keep the closure as a class). + */ + private boolean isSyntacticallyDelegateIndependent(final ClosureExpression expression) { + Statement code = expression.getCode(); + if (code == null) return false; + boolean[] dependent = {false}; + code.visit(new CodeVisitorSupport() { + @Override public void visitMethodCallExpression(final MethodCallExpression call) { + if (call.isImplicitThis()) dependent[0] = true; // foo() -> could be a delegate method + super.visitMethodCallExpression(call); + } + @Override public void visitPropertyExpression(final PropertyExpression pexp) { + // implicit-this (bar) could be a delegate property; explicit this.bar inside a + // dynamic closure routes through the MOP (getProperty -- where Map-owner entry + // semantics and metaclass interception live), which a hoisted body's direct + // field shortcut would bypass + Expression obj = pexp.getObjectExpression(); + if (pexp.isImplicitThis() + || (obj instanceof VariableExpression && ((VariableExpression) obj).isThisExpression())) { + dependent[0] = true; + } + super.visitPropertyExpression(pexp); + } + @Override public void visitVariableExpression(final VariableExpression ve) { + // only a parameter/local binding is safe: a bare name bound to an owner FIELD or + // PROPERTY by VariableScopeVisitor is still a free name at runtime -- a dynamic + // closure resolves it through the delegate chain first under DELEGATE_FIRST, and + // through the MOP (e.g. an ExpandoMetaClass property) -- as is a DynamicVariable + Variable av = ve.getAccessedVariable(); + if (av instanceof DynamicVariable || av instanceof FieldNode || av instanceof PropertyNode) { + dependent[0] = true; + } + super.visitVariableExpression(ve); + } + }); + return !dependent[0]; + } + + /** + * A trigger-specific decline reason for {@link #declineReason}, or {@code null} if the trigger did + * not decline this closure. Only the static writer has one (a delegate-resolved body); the dynamic + * path trusts the annotation and never declines on trigger grounds. + */ + protected String triggerDeclineReason(final ClosureExpression expression) { + return null; + } + + /** + * The declared type of a read-only (by-value) capture parameter on the hoisted method. The + * dynamic writer types it as {@code Object}; the static writer overrides this to the capture's + * declared/flow-inferred type so the body compiles with static dispatch. This is the seam that + * keeps @CompileStatic-specific typing out of the general (dynamic) emitter. + */ + protected ClassNode readOnlyCaptureType(final String name, final ClassNode declaredType, final Variable variable) { + return ClassHelper.OBJECT_TYPE; + } + + /** Marks the hoisted body's compilation mode; the static writer overrides this to compile it statically. */ + protected void markHoistedBody(final MethodNode hoisted) { + hoisted.putNodeMetaData(STATIC_COMPILE_NODE, Boolean.FALSE); + } + + /** + * Whether the packed adapter installs the runtime delegate guard. The dynamic trust path needs it + * (an unverifiable assertion must fail fast on misuse); the static writer proved independence, so + * it overrides this to {@code false} and a caller-set delegate is then stored and ignored. + */ + protected boolean packedClosureUsesDelegateGuard() { + return true; + } + + /** The closure's inferred return type, normalised the same way {@code createClosureClass} does for doCall. */ + private static ClassNode inferredClosureReturnType(final ClosureExpression expression) { + ClassNode returnType = expression.getNodeMetaData(INFERRED_RETURN_TYPE); + if (returnType == null) returnType = ClassHelper.OBJECT_TYPE; + else if (returnType.isPrimaryClassNode()) returnType = returnType.getPlainNodeReference(); + else if (ClassHelper.isPrimitiveType(returnType)) returnType = ClassHelper.getWrapper(returnType); + else if (GenericsUtils.hasUnresolvedGenerics(returnType)) returnType = GenericsUtils.nonGeneric(returnType); + return returnType; + } + + /** + * The erasure of a captured variable's type, safe to declare on the hoisted method: generic + * placeholders are replaced by their bound (the hoisted method does not declare the enclosing + * method's type variables) and type arguments are dropped ({@code List} → {@code List}). + */ + protected static ClassNode erasedType(final ClassNode type) { + ClassNode t = type; + if (t == null) return ClassHelper.OBJECT_TYPE; + if (t.isGenericsPlaceHolder()) t = t.redirect(); + return t.getPlainNodeReference(); + } + + /** + * Reports a top-level closure that was NOT packed, per the {@code mode} of the enclosing + * {@code @PackedClosures} annotation (WARN => compiler warning, STRICT => compiler error). The + * automatic {@code groovy.target.closure.pack} path is lenient by default, but setting + * {@code groovy.target.closure.pack.report=true} alongside it surfaces every decline (with its + * reason) as a warning — the operational way to see where the packability boundary falls in a + * real codebase before opting scopes in or out. A nested closure is a structural decline (its + * enclosing context is a generated function, not the owner class), so it is not reported. + */ + private void reportUnpacked(final ClosureExpression expression) { + if (controller.isInGeneratedFunction()) return; + PackMode mode = annotatedMode(); + if (mode == null) { + if (SystemUtil.getBooleanSafe(CompilerConfiguration.CLOSURE_PACKING) + && SystemUtil.getBooleanSafe(CompilerConfiguration.CLOSURE_PACKING_REPORT)) { + controller.getSourceUnit().addWarning(WarningMessage.LIKELY_ERRORS, + "closure packing: closure was not packed -- " + declineReason(expression), expression); + } + return; + } + // LENIENT/DISABLED are both silent -- DISABLED asked NOT to pack, so a decline is expected + if (mode == PackMode.LENIENT || mode == PackMode.DISABLED) return; + String msg = "@PackedClosures: closure was not packed -- " + declineReason(expression); + if (mode == PackMode.STRICT) { + controller.getSourceUnit().getErrorCollector() + .addErrorAndContinue(msg, expression, controller.getSourceUnit()); + } else { + // WARN is opt-in, so emit at LIKELY_ERRORS to show at the default warning level (the plain + // addWarning(text, node) uses POSSIBLE_ERRORS, which the default level suppresses). groovyc + // surfaces collected warnings on success since GROOVY-12132; Gradle/IDEs already do. + controller.getSourceUnit().addWarning(WarningMessage.LIKELY_ERRORS, msg, expression); + } + } + + /** The most specific {@code @PackedClosures} mode in scope (method wins over class), or null if not annotated. */ + private PackMode annotatedMode() { + MethodNode m = controller.getMethodNode(); + PackMode mode = (m != null) ? packModeOf(m.getAnnotations()) : null; + if (mode != null) return mode; + for (ClassNode c = controller.getClassNode(); c != null; c = c.getOuterClass()) { + mode = packModeOf(c.getAnnotations()); + if (mode != null) return mode; + } + return null; + } + + /** The mode of a {@code @PackedClosures} in the given set (LENIENT if present without an explicit mode), or null. */ + private static PackMode packModeOf(final List annotations) { + for (AnnotationNode a : annotations) { + if (a.getClassNode() != null && "groovy.transform.PackedClosures".equals(a.getClassNode().getName())) { + Expression member = a.getMember("mode"); + if (member instanceof PropertyExpression) { + try { + return PackMode.valueOf(((PropertyExpression) member).getPropertyAsString()); + } catch (IllegalArgumentException ignore) { + // malformed member; treat as the default + } + } + return PackMode.LENIENT; + } + } + return null; + } + + /** Why {@link #chooseStrategy} declined this (packable, top-level) closure -- the message WARN/STRICT report. */ + private String declineReason(final ClosureExpression expression) { + if (!isPackable(expression)) { + return "it needs a real Closure (uses owner/delegate/thisObject/resolveStrategy/super or metaClass, " + + "has default parameter values, or contains an anonymous inner class)"; + } + String triggerReason = triggerDeclineReason(expression); + if (triggerReason != null) return triggerReason; + if (escapesEnclosingMethod(expression)) { + return "it escapes its method (returned, stored to a field/property/index, appended, or in a collection literal)"; + } + if (serializationBound(expression)) { + return "it is visibly serialization-bound (cast or coerced to a Serializable type, or passed directly " + + "to writeObject) and packed closures are not serializable"; + } + if (isFieldInitializer(expression)) { + return "it initialises a field (a visible escape: the closure outlives its construction context)"; + } + if (isIntersectionTyped(expression)) { + return "it is cast to an intersection type, whose marker interfaces the shared adapter cannot declare"; + } + if (isInTraitContext()) { + return "it is declared inside a trait, whose helper-class context packed dispatch does not yet support"; + } + if (controller.getCompileStack().isInSpecialConstructorCall()) { + return "it is an argument to a this(...)/super(...) constructor call, where the enclosing instance " + + "is not yet initialised, so it cannot be the packed adapter's owner"; + } + return "it is not eligible for packing in this scope"; + } + + /** + * Conservative compile-time escape gate. A packed closure is bound to the owner and backed by a + * shared adapter that fails fast if a delegate is later set on it. To avoid that surprise for the + * clearest cases, decline (keep a real closure class for) a closure literal that visibly escapes + * the method it is written in: stored into a field/property/index (e.g. {@code attrs.optionValue = + * { ... }}), returned, appended with {@code <<}, or placed in a list/map literal. Transitive escapes + * (via a local that is later returned) are intentionally not chased here; the runtime guard remains + * the backstop for those. + */ + private boolean escapesEnclosingMethod(final ClosureExpression expression) { + MethodNode m = controller.getMethodNode(); + if (m == null || m.getCode() == null) return false; + return escapingClosuresByMethod + .computeIfAbsent(m, k -> collectEscapingClosures(k.getCode())) + .contains(expression); + } + + private static Set collectEscapingClosures(final Statement code) { + Set escaping = Collections.newSetFromMap(new IdentityHashMap<>()); + code.visit(new CodeVisitorSupport() { + private void mark(final Expression e) { + if (e instanceof ClosureExpression) escaping.add((ClosureExpression) e); + } + @Override public void visitReturnStatement(final ReturnStatement statement) { + mark(statement.getExpression()); + super.visitReturnStatement(statement); + } + @Override public void visitBinaryExpression(final BinaryExpression be) { + int op = be.getOperation().getType(); + if (op == Types.EQUAL && !isLocalTarget(be.getLeftExpression())) { + mark(be.getRightExpression()); // assigned to a field/property/index + } else if (op == Types.LEFT_SHIFT) { + mark(be.getRightExpression()); // appended, e.g. list << { ... } + } + super.visitBinaryExpression(be); + } + @Override public void visitListExpression(final ListExpression le) { + le.getExpressions().forEach(this::mark); + super.visitListExpression(le); + } + @Override public void visitMapEntryExpression(final MapEntryExpression me) { + mark(me.getValueExpression()); + super.visitMapEntryExpression(me); + } + }); + return escaping; + } + + /** True when an assignment target is a plain local variable (not a field/property that escapes). */ + private static boolean isLocalTarget(final Expression lhs) { + if (!(lhs instanceof VariableExpression)) return false; // property/field/index target + Variable v = ((VariableExpression) lhs).getAccessedVariable(); + return !(v instanceof FieldNode) && !(v instanceof PropertyNode); + } + + /** + * Conservative compile-time serialization gate, the same shape as the escape gate: a packed + * closure is not serializable, so decline (keep a real closure class for) a closure literal + * that is visibly serialization-bound — cast or coerced ({@code as}) to a type that is + * or implements {@code Serializable} (including serializable SAM coercions, whose proxy carries + * the closure), or passed directly as an argument to a {@code writeObject} call. Transitive + * routes (a local later serialized, a callee that serializes its argument) are intentionally + * not chased; the adapter's fail-fast {@code writeObject} remains the backstop for those. + * Declines are reported by {@code @PackedClosures(mode = WARN | STRICT)} like any other. + */ + private boolean serializationBound(final ClosureExpression expression) { + MethodNode m = controller.getMethodNode(); + if (m == null || m.getCode() == null) return false; + return serializationBoundByMethod + .computeIfAbsent(m, k -> collectSerializationBoundClosures(k.getCode())) + .contains(expression); + } + + private static Set collectSerializationBoundClosures(final Statement code) { + Set bound = Collections.newSetFromMap(new IdentityHashMap<>()); + // pass 1: locals directly assigned a closure literal, so pass 2 can follow the canonical + // one-hop shape `def c = { ... }; out.writeObject(c)` (a local reassigned a second literal + // maps to both -- conservative: either literal declines) + Map> literalsByLocal = new HashMap<>(); + code.visit(new CodeVisitorSupport() { + @Override public void visitBinaryExpression(final BinaryExpression be) { + if (Types.isAssignment(be.getOperation().getType()) + && be.getLeftExpression() instanceof VariableExpression + && isLocalTarget(be.getLeftExpression()) + && be.getRightExpression() instanceof ClosureExpression) { + literalsByLocal.computeIfAbsent(((VariableExpression) be.getLeftExpression()).getName(), + k -> new ArrayList<>()).add((ClosureExpression) be.getRightExpression()); + } + super.visitBinaryExpression(be); + } + }); + code.visit(new CodeVisitorSupport() { + private void mark(final Expression e) { + if (e instanceof ClosureExpression) { + bound.add((ClosureExpression) e); + } else if (e instanceof VariableExpression) { + List held = literalsByLocal.get(((VariableExpression) e).getName()); + if (held != null) bound.addAll(held); + } + } + @Override public void visitCastExpression(final CastExpression ce) { + ClassNode target = ce.getType(); + if (ClassHelper.SERIALIZABLE_TYPE.equals(target) || target.implementsInterface(ClassHelper.SERIALIZABLE_TYPE)) { + mark(ce.getExpression()); + } + super.visitCastExpression(ce); + } + @Override public void visitMethodCallExpression(final MethodCallExpression mce) { + if ("writeObject".equals(mce.getMethodAsString()) && mce.getArguments() instanceof TupleExpression) { + ((TupleExpression) mce.getArguments()).getExpressions().forEach(this::mark); + } + super.visitMethodCallExpression(mce); + } + }); + return bound; + } + + /** + * Spike packability gate: pack read-only closures (including nested and captures). Captured-variable + * writes are supported via Reference threading for a flat closure only (declined if the body also + * contains a nested closure, or uses an unsupported compound-assignment operator). Anything needing a + * real Closure — delegate/owner/thisObject/resolveStrategy or {@code super} — is declined. + */ + private static boolean isPackable(final ClosureExpression expression) { + Statement code = expression.getCode(); + if (code == null) return false; + // Default parameter values generate arity-overloaded doCall methods on a closure class; + // the single hoisted method cannot reproduce that dispatch, so decline. + if (expression.isParameterSpecified()) { + for (Parameter p : expression.getParameters()) { + if (p.hasInitialExpression()) return false; + } + } + boolean[] ok = {true}; + code.visit(new CodeVisitorSupport() { + @Override public void visitClosureExpression(final ClosureExpression e) { + // A nested closure constructed inside the hoisted method gets the enclosing INSTANCE + // as its owner instead of the (packed) outer closure object. That is observationally + // equivalent for owner-chain resolution, but not for a nested body that names + // owner/delegate/thisObject explicitly -- so the outer packs only if every nested + // closure is itself packable. + if (!isPackable(e)) ok[0] = false; + } + @Override public void visitVariableExpression(final VariableExpression ve) { + if (ve.isSuperExpression() || FORBIDDEN_CLOSURE_NAMES.contains(ve.getName())) ok[0] = false; + } + @Override public void visitMethodCallExpression(final MethodCallExpression call) { + // implicit-this accessor/mutator forms of the same pseudo-properties, e.g. getDelegate() + if (call.isImplicitThis() && FORBIDDEN_CLOSURE_CALLS.contains(call.getMethodAsString())) ok[0] = false; + super.visitMethodCallExpression(call); + } + @Override public void visitConstructorCallExpression(final ConstructorCallExpression cce) { + // an anonymous inner class in the body is generated against the enclosing closure + // class (its outer instance); hoisting would hand it the wrong enclosing instance + if (cce.isUsingAnonymousInnerClass()) ok[0] = false; + super.visitConstructorCallExpression(cce); + } + }); + // All write forms to captured variables are supported: the shared Reference is passed as a + // holder parameter, so the ASM generator emits the implicit get()/set() exactly as it does + // for a closure class -- no operator restrictions. + return ok[0]; + } + + private static Set capturedNames(final ClosureExpression expression) { + Set captured = new HashSet<>(); + VariableScope vs = expression.getVariableScope(); + if (vs != null) { + for (Iterator it = vs.getReferencedLocalVariablesIterator(); it.hasNext(); ) { + captured.add(it.next().getName()); + } + } + return captured; + } + + /** + * Captured variables that must be threaded as the shared {@code groovy.lang.Reference} (holder) + * rather than by value: those written (reassigned) anywhere in the enclosing method, and those a + * nested closure inside the body also captures. The nested case is a bytecode-shape + * requirement, not a mutation one: a nested closure that compiles as a class takes the shared + * Reference in its constructor (every captured local is Reference-boxed in the class world), so a + * by-value slot would fail verification there; a nested closure that itself packs simply + * dereferences the holder. The enclosing frame always has the Reference to pass — any local + * captured by a closure is boxed in its declaring frame. + */ + private Set holderCaptureNames(final ClosureExpression expression) { + Set captured = capturedNames(expression); + Set holder = new HashSet<>(collectWrittenNames(expression.getCode())); + MethodNode m = controller.getMethodNode(); + if (m != null && m.getCode() != null) { + holder.addAll(writtenNamesByMethod.computeIfAbsent(m, k -> collectWrittenNames(k.getCode()))); + } + Statement code = expression.getCode(); + if (code != null) { + code.visit(new CodeVisitorSupport() { + @Override public void visitClosureExpression(final ClosureExpression nested) { + VariableScope nvs = nested.getVariableScope(); + if (nvs != null) { + for (Iterator it = nvs.getReferencedLocalVariablesIterator(); it.hasNext(); ) { + holder.add(it.next().getName()); + } + } + super.visitClosureExpression(nested); + } + }); + } + holder.retainAll(captured); + return holder; + } + + /** Names assigned or incremented in the given code (declarations with initializers excluded). */ + private static Set collectWrittenNames(final Statement code) { + Set written = new HashSet<>(); + if (code == null) return written; + code.visit(new CodeVisitorSupport() { // descends into nested closures + @Override public void visitBinaryExpression(final BinaryExpression be) { + if (Types.isAssignment(be.getOperation().getType()) + && !(be instanceof DeclarationExpression) + && be.getLeftExpression() instanceof VariableExpression) { + written.add(((VariableExpression) be.getLeftExpression()).getName()); + } + super.visitBinaryExpression(be); + } + @Override public void visitPostfixExpression(final PostfixExpression pe) { + recordIncrement(pe.getExpression()); + super.visitPostfixExpression(pe); + } + @Override public void visitPrefixExpression(final PrefixExpression pe) { + recordIncrement(pe.getExpression()); + super.visitPrefixExpression(pe); + } + private void recordIncrement(final Expression operand) { + if (operand instanceof VariableExpression) { + written.add(((VariableExpression) operand).getName()); + } + } + }); + return written; + } + + // NB: metaClass resolves against the closure object ITSELF (per-closure-class identity), which + // the shared adapter cannot reproduce, so it declines like the other closure pseudo-properties. + private static final Set FORBIDDEN_CLOSURE_NAMES = + new HashSet<>(java.util.Arrays.asList("owner", "delegate", "thisObject", "directive", + "resolveStrategy", "metaClass")); + + /** Accessor/mutator forms of the same pseudo-properties, called with implicit this. */ + private static final Set FORBIDDEN_CLOSURE_CALLS = + new HashSet<>(java.util.Arrays.asList("getOwner", "getDelegate", "getThisObject", "getDirective", + "getResolveStrategy", "setDelegate", "setDirective", "setResolveStrategy", + "getMaximumNumberOfParameters", "getParameterTypes", "getMetaClass", "setMetaClass", + "invokeMethod", "getProperty", "setProperty")); + + /** + * Rebinds captured-variable references in the moved body to the hoisted method's parameters, + * matched by the accessed {@link Variable}'s identity rather than by name. Groovy forbids + * shadowing an in-scope local/parameter, so a name match would be correct today; keying on identity + * keeps it correct even if that scoping rule ever relaxed, and never rewrites an unrelated binding + * that happens to share a captured name. + */ + private static void rebindCaptured(final Statement body, final Map capturedParams) { + body.visit(new CodeVisitorSupport() { + @Override public void visitVariableExpression(final VariableExpression ve) { + Parameter p = capturedParams.get(ve.getAccessedVariable()); + if (p != null) { + ve.setAccessedVariable(p); + // plain read-only captures become by-value params; holder-threaded captures + // (written, or re-captured by a nested closure) stay closure-shared so the + // ASM generator routes them through the Reference + ve.setClosureSharedVariable(p.isClosureSharedVariable()); + } + } + }); + } + + /** + * Emits a packed closure: hoists the body to a synthetic method on the enclosing class (captured + * variables become leading, by-value parameters) and constructs a shared {@code PackedClosure} + * adapter that dispatches to it — no inner class. + */ + private void writePackedClosure(final ClosureExpression expression) { + ClassNode enclosing = controller.getClassNode(); + MethodVisitor mv = controller.getMethodVisitor(); + AsmClassGenerator acg = controller.getAcg(); + OperandStack os = controller.getOperandStack(); + + List captured = new ArrayList<>(); + Map capturedTypes = new HashMap<>(); + Map capturedVars = new HashMap<>(); // name -> the original captured variable + VariableScope vs = expression.getVariableScope(); + if (vs != null) { + for (Iterator it = vs.getReferencedLocalVariablesIterator(); it.hasNext(); ) { + Variable v = it.next(); + captured.add(v.getName()); + capturedTypes.put(v.getName(), erasedType(v.getOriginType())); + capturedVars.put(v.getName(), v); + } + } + + // An EMPTY parameter array is the implicit-it literal ({ ... }); null is the explicit + // zero-parameter form ({ -> ... }), which must report arity 0 (GString's writer-vs-call + // branch and SAM matching key on it), exactly as its generated class would. + Parameter[] declared = expression.getParameters(); + // ClosureExpression#getParameters() distinguishes two forms that must NOT be conflated: + // null => no parameter list was written: { -> body } -- an explicit ZERO-parameter + // closure (arity 0, no implicit "it"). + // length == 0 => an empty list was written: { body } -- the implicit-parameter closure, + // which gets the synthetic "it" (fuzzy 0-or-1 arity). + // Merging them (declared == null || length == 0) would give { -> } a spurious "it" and + // break the arity a generated closure class presents to GString's writer-vs-call branch + // and to SAM-overload selection. The generated-class path below applies the same rule. + boolean implicitParam = (declared != null && declared.length == 0); + Parameter[] closureParams = implicitParam + ? new Parameter[]{new Parameter(ClassHelper.OBJECT_TYPE, "it")} + : (declared != null ? declared : Parameter.EMPTY_ARRAY); + int arity = closureParams.length; + + Set holderThreaded = holderCaptureNames(expression); + Parameter[] methodParams = new Parameter[captured.size() + closureParams.length]; + Map capturedByVar = new IdentityHashMap<>(); // original variable -> hoisted param + for (int i = 0; i < captured.size(); i++) { + String cn = captured.get(i); + boolean isWritten = holderThreaded.contains(cn); + Parameter p; + if (isWritten) { + // A written capture arrives as the SHARED groovy.lang.Reference itself: declare the + // parameter exactly like a closure-class constructor does (originType = value type, + // descriptor type = Reference, closure-shared + use-existing-reference), so + // CompileStack.init registers it as a holder and the ASM generator emits the implicit + // get()/set() on every read/write -- no AST rewriting, and the untouched body keeps + // the STC metadata that lets it compile statically. + ClassNode vt = capturedTypes.get(cn); + if (ClassHelper.isPrimitiveType(vt)) vt = ClassHelper.getWrapper(vt); + p = new Parameter(vt, cn); + p.setType(ClassHelper.makeReference()); + p.setClosureSharedVariable(true); + p.putNodeMetaData(UseExistingReference.class, Boolean.TRUE); + // the static writer's type chooser must see the VALUE type: the holder load already + // dereferences, so resolving this variable as Reference would add a bogus checkcast + p.putNodeMetaData(StaticTypesMarker.INFERRED_TYPE, vt); + } else { + // A read-only capture is an Object-typed by-value parameter for a dynamic body; the + // static writer overrides readOnlyCaptureType to declare it by its (possibly + // flow-inferred) type so the dispatch table's checkcast lands the type the body's use + // sites were compiled against. + p = new Parameter(readOnlyCaptureType(cn, capturedTypes.get(cn), capturedVars.get(cn)), cn); + } + methodParams[i] = p; + capturedByVar.put(capturedVars.get(cn), p); + } + System.arraycopy(closureParams, 0, methodParams, captured.size(), closureParams.length); + + Statement body = expression.getCode(); + rebindCaptured(body, capturedByVar); // read-only -> plain param; written -> holder param + + // In a static method there is no `this`: the owner is the enclosing class and the hoisted + // method must be static (loaded/dispatched against the class, not local slot 0). + boolean staticContext = controller.isStaticMethod(); + List targets = packedTargets(enclosing); + int id = targets.size(); // ids are dense per class: the dispatcher emits a tableswitch over them + String name = PACKED_METHOD_PREFIX + id; + // PRIVATE, not public: the body is reached only through the class's $packedDispatch$ table + // (a tableswitch case invoking it directly via INVOKESPECIAL/INVOKESTATIC), and a private + // method is invoked exactly (no virtual dispatch), so an inherited packed closure never + // dispatches to a same-named hoisted method a subclass happens to declare (GROOVY-12151 review). + int mods = ACC_PRIVATE | ACC_SYNTHETIC | (staticContext ? ACC_STATIC : 0); + // Same return type a generated closure class would give doCall, so implicit return-value + // coercion (e.g. GString -> String for a Closure) happens identically. This uses + // whatever the type checker recorded (@CompileStatic AND @TypeChecked -- the dynamically + // compiled body coerces at return like any declared-return dynamic method), falling back + // to Object when no inference ran. + ClassNode hoistedReturnType = inferredClosureReturnType(expression); + MethodNode hoisted = new MethodNode(name, mods, hoistedReturnType, + methodParams, ClassNode.EMPTY_ARRAY, body); + // Added after the ReturnAdder phase, so run it ourselves: it wires implicit returns through + // every statement shape (trailing expressions, if/else branches, try/catch, ...). + new ReturnAdder().visitMethod(hoisted); + // Typed static dispatch (GEP-27 phase 4): the original body expressions were already visited + // by StaticCompilationVisitor as part of the enclosing method, so they carry the metadata the + // static writer needs (DIRECT_METHOD_CALL_TARGET, inferred types) -- including types that were + // only inferred (@ClosureParams, implicit it), which arrive for free via checkcasts. Written + // captures ride the holder machinery, so their bodies compile statically too. + markHoistedBody(hoisted); // base: mark dynamic; the static writer marks it for static compilation + addGeneratedMethod(enclosing, hoisted); + targets.add(hoisted); // claim the id; writePackedDispatcher renders the table at end of class + // GROOVY-12150 visibility: a packed closure has no generated class, so mark it with the hoisted + // method it became. The AST browser then renders it via the same path it uses for the generated + // closure class of an unpacked closure; the trailing () distinguishes a method from a class. + expression.putNodeMetaData(GENERATED_CLOSURE_CLASS, enclosing.getName() + "." + name + "()"); + + // Instantiate the fixed-arity family member matching the literal's declared parameter count + // (FixedN, with a varargs doCall, serves arities above four; the base class is abstract): + // the arity is then visible to class-level introspection — notably MetaClassHelper's + // SAM-overload disambiguation, which reflects the argument class's declared doCall — + // exactly as on a generated closure class. An implicit-parameter literal gets FixedIt + // (doCall() + doCall(Object)), matching the fuzzy "0 or 1" arity a generated class + // declares for it. A vararg-shaped literal (trailing array parameter, e.g. { ...z -> }) + // also gets FixedN: its generated class would declare a VARARG doCall, whose selection + // flexibility (zero args collect to an empty array where a fixed one-param doCall would + // null-fill) only a varargs declaration reproduces. + // A vararg-shaped literal (trailing array parameter, e.g. { ...z -> }) needs the varargs + // doCall that only FixedIt/FixedN declare (its generated class declares a vararg doCall, + // whose zero-args-collect-to-empty-array selection a fixed one-param doCall cannot mimic). + // The family member is chosen at runtime by PackedClosure.create from (implicit, vararg, + // arity), so the emitted bytecode references only the factory, not the Fixed* classes. + boolean varargShape = !implicitParam && arity > 0 && closureParams[arity - 1].getType().isArray(); + if (staticContext) { + new ClassExpression(enclosing).visit(acg); // owner = the enclosing class (static dispatch) + } else { + mv.visitVarInsn(ALOAD, 0); + os.push(ClassHelper.OBJECT_TYPE); // owner = this + } + // The class's shared dispatch table plus this body's compile-time id: dispatch is a plain + // interface call into a tableswitch (JIT-friendly, unlike a per-instance MethodHandle, which + // cannot be constant-folded), and the id binds the exact method so an inherited packed closure + // cannot misdispatch to a subclass's same-named method. + mv.visitMethodInsn(INVOKESTATIC, BytecodeHelper.getClassInternalName(enclosing), DISPATCHERS_GETTER, DISPATCHERS_GETTER_DESC, false); + os.push(ClassHelper.make(GeneratedDispatcher.Bundle.class)); + BytecodeHelper.pushConstant(mv, id); + os.push(ClassHelper.int_TYPE); + mv.visitLdcInsn(name); + os.push(ClassHelper.STRING_TYPE); // method name (diagnostics only) + if (captured.isEmpty()) { + mv.visitInsn(ACONST_NULL); + os.push(ClassHelper.OBJECT_TYPE.makeArray()); + } else { + // Build Object[] of captured values: written vars pass their shared Reference (so writes + // propagate), read-only vars pass their value. + BytecodeHelper.pushConstant(mv, captured.size()); + os.push(ClassHelper.int_TYPE); + mv.visitTypeInsn(ANEWARRAY, "java/lang/Object"); + os.replace(ClassHelper.OBJECT_TYPE.makeArray()); + for (int i = 0; i < captured.size(); i++) { + String cn = captured.get(i); + // DUP + index are raw stack shuffling the operand-stack model need not track; only + // the value load goes through it (loadReference/acg both leave one entry), so a + // single remove(1) after AASTORE keeps the model consistent. + mv.visitInsn(DUP); + BytecodeHelper.pushConstant(mv, i); + if (holderThreaded.contains(cn)) { + loadReference(cn, controller); // shared Reference holder + } else { + new VariableExpression(cn).visit(acg); // value + os.box(); + } + mv.visitInsn(AASTORE); + os.remove(1); + } + } + // The closure's declared parameter types, so getParameterTypes()-keyed caller behaviour + // (DGM arity decisions, vararg collection) matches a generated closure class exactly. + // A condy constant (resolved once per literal site, shared by every adapter created + // there — as a closure class shares its cached parameter-type array), NOT a fresh array: + // building one per creation measurably taxes creation-heavy code. The types travel as a + // method descriptor because primitive parameter types have no Class constant-pool form. + StringBuilder typesDescriptor = new StringBuilder("("); + for (int i = 0; i < arity; i++) { + typesDescriptor.append(BytecodeHelper.getTypeDescription(erasedType(closureParams[i].getType()))); + } + typesDescriptor.append(")V"); + mv.visitLdcInsn(new ConstantDynamic("paramTypes", "[Ljava/lang/Class;", + new Handle(H_INVOKESTATIC, DISPATCHER_TYPE, "paramTypes", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;Ljava/lang/String;)[Ljava/lang/Class;", + true), // the bootstrap is a static interface method + typesDescriptor.toString())); + os.push(ClassHelper.CLASS_Type.makeArray()); + // strict guard only for the dynamic trust path; a PROVEN delegate-independent closure -- + // statically (StaticTypesClosureWriter) or syntactically (SYNTACTIC_PACK, dynamic) -- + // stores-and-ignores a caller-set delegate, like a @CompileStatic closure class. + boolean strict = packedClosureUsesDelegateGuard() && expression.getNodeMetaData(SYNTACTIC_PACK) == null; + mv.visitInsn(strict ? org.objectweb.asm.Opcodes.ICONST_1 : org.objectweb.asm.Opcodes.ICONST_0); + os.push(ClassHelper.boolean_TYPE); + mv.visitInsn(implicitParam ? org.objectweb.asm.Opcodes.ICONST_1 : org.objectweb.asm.Opcodes.ICONST_0); + os.push(ClassHelper.boolean_TYPE); + mv.visitInsn(varargShape ? org.objectweb.asm.Opcodes.ICONST_1 : org.objectweb.asm.Opcodes.ICONST_0); + os.push(ClassHelper.boolean_TYPE); + // one factory call, not a hard-wired NEW of the arity-specific subclass (GROOVY-12151 review): + // the Fixed* family is chosen inside create, keeping it off the emitted-bytecode surface. + mv.visitMethodInsn(INVOKESTATIC, "org/codehaus/groovy/runtime/PackedClosure", "create", + "(Ljava/lang/Object;L" + BUNDLE_TYPE + ";ILjava/lang/String;[Ljava/lang/Object;[Ljava/lang/Class;ZZZ)Lorg/codehaus/groovy/runtime/PackedClosure;", false); + os.replace(ClassHelper.CLOSURE_TYPE, 9); // owner, dispatchers, id, name, captured[], paramTypes[], strict, implicit, vararg -> Closure + } + + /** + * Emits the class's packed-closure dispatch machinery, once per class at the end of class + * generation (so every hoisted body — including one a hoisted body itself hoisted — has claimed + * its id): the {@code $packedDispatch$(int, Object[])} table, whose case {@code k} casts the + * packed argument array ({@code [owner, captured..., args...]}) to target {@code k}'s declared + * parameter types and invokes it directly; the array-free per-arity tables + * ({@code $packedDispatch1$}/{@code $packedDispatch2$}) doing the same from plain parameter + * slots for the hot one/two-value shapes; and the {@code $getPackedDispatchers$()} accessor, + * whose single {@code invokedynamic} site links all three through + * {@link GeneratedDispatcher#bootstrap} into one constant + * {@code Bundle}, lazily on first adapter creation. A no-op for classes that packed nothing. + */ + public void writePackedDispatcher() { + ClassNode enclosing = controller.getClassNode(); + List targets = enclosing.getNodeMetaData(PACKED_TARGETS); + if (targets == null || targets.isEmpty()) return; + enclosing.removeNodeMetaData(PACKED_TARGETS); + String internal = BytecodeHelper.getClassInternalName(enclosing); + org.objectweb.asm.ClassVisitor cv = controller.getClassVisitor(); + + // the accessor: return INDY packedDispatchers()Bundle — GeneratedDispatcher.bootstrap links + // the class's three dispatch tables (through LambdaMetafactory, with this class's lookup) + // once, on first adapter creation, and every later call returns the constant bundle, so + // the accessor is also the cache + MethodVisitor mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, DISPATCHERS_GETTER, DISPATCHERS_GETTER_DESC, null, null); + mv.visitCode(); + Handle bootstrap = new Handle( + H_INVOKESTATIC, DISPATCHER_TYPE, "bootstrap", + "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;", + true); // the bootstrap is a static interface method + mv.visitInvokeDynamicInsn("packedDispatchers", DISPATCHERS_GETTER_DESC, bootstrap); + mv.visitInsn(ARETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + + // the array-free per-arity tables, over the targets whose captured-plus-argument count + // matches (membership is sparse over the id space, so cases use lookupswitch); routing is + // the adapter's responsibility, so any other id landing here is a compiler bug + writeArityTable(cv, internal, enclosing, targets, 1, DISPATCH1_METHOD, DISPATCH1_DESC); + writeArityTable(cv, internal, enclosing, targets, 2, DISPATCH2_METHOD, DISPATCH2_DESC); + + // The table. It must stay under the JIT's inlining budget (FreqInlineSize, 325 bytecode + // bytes) or a hot caller cannot fold a constant id through to a direct call of the target + // (and the packed argument array then escapes instead of being scalar-replaced). A switch + // case costs ~20 bytes, so past DISPATCH_CHUNK targets the table goes two-level: the entry + // method shifts the id down to a chunk index and forwards -- small enough to always inline + // -- and each chunk method switches over its slice of at most DISPATCH_CHUNK cases. + int n = targets.size(); + if (n <= DISPATCH_CHUNK) { + mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, DISPATCH_METHOD, DISPATCH_DESC, null, null); + mv.visitCode(); + writeDispatchSwitch(mv, internal, enclosing, targets, 0, n); + mv.visitMaxs(0, 0); + mv.visitEnd(); + } else { + int chunks = (n + DISPATCH_CHUNK - 1) / DISPATCH_CHUNK; + mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, DISPATCH_METHOD, DISPATCH_DESC, null, null); + mv.visitCode(); + org.objectweb.asm.Label dflt = new org.objectweb.asm.Label(); + org.objectweb.asm.Label[] labels = new org.objectweb.asm.Label[chunks]; + for (int c = 0; c < chunks; c += 1) labels[c] = new org.objectweb.asm.Label(); + mv.visitVarInsn(ILOAD, 0); + BytecodeHelper.pushConstant(mv, Integer.numberOfTrailingZeros(DISPATCH_CHUNK)); + mv.visitInsn(ISHR); + mv.visitTableSwitchInsn(0, chunks - 1, dflt, labels); + for (int c = 0; c < chunks; c += 1) { + mv.visitLabel(labels[c]); + mv.visitVarInsn(ILOAD, 0); + mv.visitVarInsn(ALOAD, 1); + mv.visitMethodInsn(INVOKESTATIC, internal, DISPATCH_METHOD + c, DISPATCH_DESC, false); + mv.visitInsn(ARETURN); + } + mv.visitLabel(dflt); + writeDispatchDefault(mv, enclosing); + mv.visitMaxs(0, 0); + mv.visitEnd(); + for (int c = 0; c < chunks; c += 1) { + mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, DISPATCH_METHOD + c, DISPATCH_DESC, null, null); + mv.visitCode(); + writeDispatchSwitch(mv, internal, enclosing, targets, c * DISPATCH_CHUNK, Math.min(n, (c + 1) * DISPATCH_CHUNK)); + mv.visitMaxs(0, 0); + mv.visitEnd(); + } + } + } + + /** A dispatch table slice: a tableswitch over ids {@code [lo, hi)} into direct target invocations. */ + private static void writeDispatchSwitch(final MethodVisitor mv, final String internal, final ClassNode enclosing, final List targets, final int lo, final int hi) { + org.objectweb.asm.Label dflt = new org.objectweb.asm.Label(); + org.objectweb.asm.Label[] labels = new org.objectweb.asm.Label[hi - lo]; + for (int i = 0; i < hi - lo; i += 1) labels[i] = new org.objectweb.asm.Label(); + mv.visitVarInsn(ILOAD, 0); + mv.visitTableSwitchInsn(lo, hi - 1, dflt, labels); + for (int k = lo; k < hi; k += 1) { + mv.visitLabel(labels[k - lo]); + MethodNode target = targets.get(k); + boolean staticTarget = target.isStatic(); + if (!staticTarget) { + mv.visitVarInsn(ALOAD, 1); + mv.visitInsn(ICONST_0); + mv.visitInsn(AALOAD); + mv.visitTypeInsn(CHECKCAST, internal); + } + Parameter[] params = target.getParameters(); + for (int i = 0; i < params.length; i += 1) { + mv.visitVarInsn(ALOAD, 1); + BytecodeHelper.pushConstant(mv, i + 1); // [0] is the receiver slot, params start at [1] + mv.visitInsn(AALOAD); + BytecodeHelper.doCast(mv, params[i].getType()); // checkcast, or unbox for a primitive + } + String desc = BytecodeHelper.getMethodDescriptor(target.getReturnType(), params); + // a hoisted body is private, so INVOKESPECIAL dispatches it exactly (never virtually) + mv.visitMethodInsn(staticTarget ? INVOKESTATIC : INVOKESPECIAL, internal, target.getName(), desc, false); + // hoisted return types are reference types by construction (see inferredClosureReturnType); + // box defensively so a future primitive-returning target cannot emit invalid bytecode + ClassNode returnType = target.getReturnType(); + if (ClassHelper.isPrimitiveVoid(returnType)) { + mv.visitInsn(ACONST_NULL); + } else if (ClassHelper.isPrimitiveType(returnType)) { + BytecodeHelper.doCastToWrappedType(mv, returnType, ClassHelper.getWrapper(returnType)); + } + mv.visitInsn(ARETURN); + } + mv.visitLabel(dflt); + writeDispatchDefault(mv, enclosing); + } + + /** + * An array-free dispatch table for the targets whose captured-plus-argument count is exactly + * {@code paramCount}: each case loads the receiver and arguments from parameter slots — no + * packed array — and invokes the target directly. Membership is sparse over the id space, so + * cases switch with {@code lookupswitch}; ids of other arities land on the default (routing is + * the adapter's responsibility, so reaching it is a compiler bug). Past {@link #DISPATCH_CHUNK} + * members the table goes two-level under the same inline-budget discipline as the array table: + * the entry method shifts the id down to a chunk index and forwards, and each chunk method + * switches over its id-range's members (at most {@code DISPATCH_CHUNK}, since a range spans + * {@code DISPATCH_CHUNK} consecutive ids). + */ + private static void writeArityTable(final org.objectweb.asm.ClassVisitor cv, final String internal, + final ClassNode enclosing, final List targets, final int paramCount, + final String tableMethod, final String tableDesc) { + List memberIds = new ArrayList<>(); + for (int k = 0; k < targets.size(); k += 1) { + if (targets.get(k).getParameters().length == paramCount) memberIds.add(k); + } + MethodVisitor mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, tableMethod, tableDesc, null, null); + mv.visitCode(); + if (memberIds.size() <= DISPATCH_CHUNK) { + writeAritySwitch(mv, internal, enclosing, targets, memberIds, paramCount); + mv.visitMaxs(0, 0); + mv.visitEnd(); + return; + } + int shift = Integer.numberOfTrailingZeros(DISPATCH_CHUNK); + int chunks = ((targets.size() - 1) >> shift) + 1; + List> byChunk = new ArrayList<>(chunks); + for (int c = 0; c < chunks; c += 1) byChunk.add(new ArrayList<>()); + for (int memberId : memberIds) byChunk.get(memberId >> shift).add(memberId); + org.objectweb.asm.Label dflt = new org.objectweb.asm.Label(); + org.objectweb.asm.Label[] labels = new org.objectweb.asm.Label[chunks]; + for (int c = 0; c < chunks; c += 1) { + // an id-range with no members of this arity shares the default (compiler-bug) label + labels[c] = byChunk.get(c).isEmpty() ? dflt : new org.objectweb.asm.Label(); + } + mv.visitVarInsn(ILOAD, 0); + BytecodeHelper.pushConstant(mv, shift); + mv.visitInsn(ISHR); + mv.visitTableSwitchInsn(0, chunks - 1, dflt, labels); + for (int c = 0; c < chunks; c += 1) { + if (byChunk.get(c).isEmpty()) continue; + mv.visitLabel(labels[c]); + mv.visitVarInsn(ILOAD, 0); + for (int p = 0; p <= paramCount; p += 1) { + mv.visitVarInsn(ALOAD, 1 + p); // the receiver, then the value parameters + } + mv.visitMethodInsn(INVOKESTATIC, internal, tableMethod + c, tableDesc, false); + mv.visitInsn(ARETURN); + } + mv.visitLabel(dflt); + writeDispatchDefault(mv, enclosing); + mv.visitMaxs(0, 0); + mv.visitEnd(); + for (int c = 0; c < chunks; c += 1) { + if (byChunk.get(c).isEmpty()) continue; + mv = cv.visitMethod(ACC_PRIVATE | ACC_STATIC | ACC_SYNTHETIC, tableMethod + c, tableDesc, null, null); + mv.visitCode(); + writeAritySwitch(mv, internal, enclosing, targets, byChunk.get(c), paramCount); + mv.visitMaxs(0, 0); + mv.visitEnd(); + } + } + + /** An arity-table slice: a lookupswitch over the given member ids into direct target invocations. */ + private static void writeAritySwitch(final MethodVisitor mv, final String internal, + final ClassNode enclosing, final List targets, final List memberIds, final int paramCount) { + org.objectweb.asm.Label dflt = new org.objectweb.asm.Label(); + int members = memberIds.size(); + int[] keys = new int[members]; + org.objectweb.asm.Label[] labels = new org.objectweb.asm.Label[members]; + for (int i = 0; i < members; i += 1) { + keys[i] = memberIds.get(i); // ids are claimed in ascending order, as lookupswitch requires + labels[i] = new org.objectweb.asm.Label(); + } + mv.visitVarInsn(ILOAD, 0); + mv.visitLookupSwitchInsn(dflt, keys, labels); + for (int i = 0; i < members; i += 1) { + mv.visitLabel(labels[i]); + MethodNode target = targets.get(keys[i]); + boolean staticTarget = target.isStatic(); + if (!staticTarget) { + mv.visitVarInsn(ALOAD, 1); // the receiver parameter + mv.visitTypeInsn(CHECKCAST, internal); + } + Parameter[] params = target.getParameters(); + for (int p = 0; p < paramCount; p += 1) { + mv.visitVarInsn(ALOAD, 2 + p); // value parameters follow (id, receiver) + BytecodeHelper.doCast(mv, params[p].getType()); // checkcast, or unbox for a primitive + } + String desc = BytecodeHelper.getMethodDescriptor(target.getReturnType(), params); + // a hoisted body is private, so INVOKESPECIAL dispatches it exactly (never virtually) + mv.visitMethodInsn(staticTarget ? INVOKESTATIC : INVOKESPECIAL, internal, target.getName(), desc, false); + ClassNode returnType = target.getReturnType(); + if (ClassHelper.isPrimitiveVoid(returnType)) { + mv.visitInsn(ACONST_NULL); + } else if (ClassHelper.isPrimitiveType(returnType)) { + BytecodeHelper.doCastToWrappedType(mv, returnType, ClassHelper.getWrapper(returnType)); + } + mv.visitInsn(ARETURN); + } + mv.visitLabel(dflt); + writeDispatchDefault(mv, enclosing); + } + + private static void writeDispatchDefault(final MethodVisitor mv, final ClassNode enclosing) { + mv.visitTypeInsn(NEW, "org/codehaus/groovy/GroovyBugError"); + mv.visitInsn(DUP); + mv.visitLdcInsn("Invalid packed closure dispatch id in " + enclosing.getName()); + mv.visitMethodInsn(INVOKESPECIAL, "org/codehaus/groovy/GroovyBugError", "", "(Ljava/lang/String;)V", false); + mv.visitInsn(ATHROW); + } + /** * Loads a closure-shared variable reference onto the operand stack, looking it up * as a field, local variable, or outer closure field as appropriate. diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/PackStrategy.java b/src/main/java/org/codehaus/groovy/classgen/asm/PackStrategy.java new file mode 100644 index 00000000000..6550d19ba1e --- /dev/null +++ b/src/main/java/org/codehaus/groovy/classgen/asm/PackStrategy.java @@ -0,0 +1,35 @@ +/* + * 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 org.codehaus.groovy.classgen.asm; + +/** + * The compilation strategy the GEP-27 capability analysis selects for a closure literal: keep today's + * per-closure class, or pack the closure by hoisting its body to a method on the enclosing class behind + * a shared {@code PackedClosure} adapter. The GEP-27 lattice also foresees a metafactory strategy + * (functional-interface targets) and object elision, but those are separate, later work and are not + * represented here yet. + */ +public enum PackStrategy { + + /** S0 — generate the per-closure class (today's behaviour); preserves all {@code Closure} capabilities. */ + FULL_CLASS, + + /** S1 — hoist the body to a method on the enclosing class behind a shared {@code PackedClosure} adapter. */ + PACKED_ADAPTER +} diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesCallSiteWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesCallSiteWriter.java index 292f5c787e9..45bd5e68e00 100644 --- a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesCallSiteWriter.java +++ b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesCallSiteWriter.java @@ -37,6 +37,7 @@ import org.codehaus.groovy.classgen.BytecodeExpression; import org.codehaus.groovy.classgen.asm.BytecodeHelper; import org.codehaus.groovy.classgen.asm.CallSiteWriter; +import org.codehaus.groovy.classgen.asm.ClosureWriter; import org.codehaus.groovy.classgen.asm.CompileStack; import org.codehaus.groovy.classgen.asm.MethodCallerMultiAdapter; import org.codehaus.groovy.classgen.asm.OperandStack; @@ -380,7 +381,12 @@ && getField(receiverType, propertyName, FieldNode::isPublic) == null } boolean isScriptVariable = (receiverType.isScript() && receiver instanceof VariableExpression && ((VariableExpression) receiver).getAccessedVariable() == null); - if (!isScriptVariable && controller.getClassNode().getOuterClass() == null) { // inner class still needs dynamic property sequence + // a packed closure's hoisted body keeps the closure-context contract: like the inner + // closure class it replaces, it falls through to the dynamic property sequence (e.g. + // another class's private field, reachable via the MOP) instead of a compile error + boolean inPackedBody = controller.getMethodNode() != null + && controller.getMethodNode().getName().startsWith(ClosureWriter.PACKED_METHOD_PREFIX); + if (!isScriptVariable && !inPackedBody && controller.getClassNode().getOuterClass() == null) { // inner class still needs dynamic property sequence addPropertyAccessError(receiver, propertyName, receiverType); } diff --git a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java index 1b42910b7a4..6631ebb5695 100644 --- a/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java +++ b/src/main/java/org/codehaus/groovy/classgen/asm/sc/StaticTypesClosureWriter.java @@ -20,14 +20,25 @@ import org.codehaus.groovy.GroovyBugError; import org.codehaus.groovy.ast.ClassCodeVisitorSupport; +import org.apache.groovy.util.SystemUtil; import org.codehaus.groovy.ast.ClassHelper; +import org.codehaus.groovy.control.CompilerConfiguration; import org.codehaus.groovy.ast.ClassNode; +import org.codehaus.groovy.ast.CodeVisitorSupport; import org.codehaus.groovy.ast.MethodNode; import org.codehaus.groovy.ast.Parameter; +import org.codehaus.groovy.ast.Variable; +import org.codehaus.groovy.ast.ASTNode; +import org.codehaus.groovy.ast.DynamicVariable; +import org.codehaus.groovy.ast.FieldNode; +import org.codehaus.groovy.ast.PropertyNode; import org.codehaus.groovy.ast.expr.ArrayExpression; import org.codehaus.groovy.ast.expr.ClosureExpression; import org.codehaus.groovy.ast.expr.Expression; import org.codehaus.groovy.ast.expr.MethodCallExpression; +import org.codehaus.groovy.ast.expr.PropertyExpression; +import org.codehaus.groovy.ast.expr.VariableExpression; +import org.codehaus.groovy.ast.stmt.Statement; import org.codehaus.groovy.classgen.asm.ClosureWriter; import org.codehaus.groovy.classgen.asm.WriterController; import org.codehaus.groovy.control.SourceUnit; @@ -41,9 +52,14 @@ import static org.codehaus.groovy.ast.tools.GeneralUtils.args; import static org.codehaus.groovy.ast.tools.GeneralUtils.callX; import static org.codehaus.groovy.ast.tools.GeneralUtils.constX; +import static org.codehaus.groovy.ast.tools.GeneralUtils.isOrImplements; import static org.codehaus.groovy.ast.tools.GeneralUtils.nullX; import static org.codehaus.groovy.ast.tools.GeneralUtils.returnS; import static org.codehaus.groovy.ast.tools.GeneralUtils.varX; +import static org.codehaus.groovy.transform.stc.StaticTypesMarker.DIRECT_METHOD_CALL_TARGET; +import static org.codehaus.groovy.transform.stc.StaticTypesMarker.DYNAMIC_RESOLUTION; +import static org.codehaus.groovy.transform.stc.StaticTypesMarker.IMPLICIT_RECEIVER; +import static org.codehaus.groovy.transform.stc.StaticTypesMarker.INFERRED_TYPE; import static org.codehaus.groovy.transform.stc.StaticTypesMarker.LAMBDA_MARKERS; /** @@ -57,6 +73,142 @@ public StaticTypesClosureWriter(WriterController wc) { super(wc); } + // Under @CompileStatic the type checker's owner-vs-delegate resolution is authoritative, so the + // capability analysis can prove a closure delegate-independent and pack it automatically. + @Override + protected boolean isPackTriggered(final ClosureExpression expression, final boolean annotated) { + // Even the @PackedClosures opt-in needs the proof here: a delegate-resolved body compiles its + // IMPLICIT_RECEIVER expressions against Closure.getDelegate(), which a hoisted method cannot honour. + return (annotated || SystemUtil.getBooleanSafe(CompilerConfiguration.CLOSURE_PACKING)) + && isDelegateIndependent(expression) + && !touchesMapOwnerProperties(expression); + } + + @Override + protected String triggerDeclineReason(final ClosureExpression expression) { + if (!isDelegateIndependent(expression)) { + return "it resolves a name against a delegate (e.g. via @DelegatesTo or with{})"; + } + if (touchesMapOwnerProperties(expression)) { + return "it accesses properties of a Map owner, whose closure-context resolution " + + "(map entry for members not visible to the closure class) a hoisted body cannot reproduce"; + } + return null; + } + + /** + * Inside a closure, {@code this.x} (and a bare field-bound {@code x}) on an owner that implements + * {@code Map} deliberately resolves through the property MOP: a member not visible to the closure + * class falls through to the map entry (the map-property contract pinned by + * {@code FieldsAndPropertiesSTCTest}). A hoisted body is a real method of the owner, where the + * same expression legally reaches the field directly — a different answer. Decline packing for + * closures in Map-implementing owners that touch {@code this}-properties or field-bound names. + */ + private boolean touchesMapOwnerProperties(final ClosureExpression expression) { + ClassNode enclosing = controller.getClassNode(); + if (!isOrImplements(enclosing, ClassHelper.MAP_TYPE)) return false; + Statement code = expression.getCode(); + if (code == null) return false; + boolean[] touches = {false}; + code.visit(new CodeVisitorSupport() { + @Override public void visitPropertyExpression(final PropertyExpression pexp) { + Expression obj = pexp.getObjectExpression(); + if (obj instanceof VariableExpression && ((VariableExpression) obj).isThisExpression()) { + touches[0] = true; + } + super.visitPropertyExpression(pexp); + } + @Override public void visitVariableExpression(final VariableExpression ve) { + Object av = ve.getAccessedVariable(); + if (av instanceof FieldNode + || av instanceof PropertyNode) { + touches[0] = true; + } + super.visitVariableExpression(ve); + } + }); + return touches[0]; + } + + // The static writer types read-only captures (declared or flow-inferred) so the hoisted body + // compiles with static dispatch, and marks the body for static compilation -- the two seams the + // base (dynamic) emitter leaves as Object / dynamic. + @Override + protected ClassNode readOnlyCaptureType(final String name, final ClassNode declaredType, final Variable variable) { + if (variable instanceof ASTNode) { + Object inferred = ((ASTNode) variable).getNodeMetaData(INFERRED_TYPE); + if (inferred instanceof ClassNode) return erasedType((ClassNode) inferred); + } + return declaredType; + } + + @Override + protected void markHoistedBody(final MethodNode hoisted) { + hoisted.putNodeMetaData(StaticCompilationMetadataKeys.STATIC_COMPILE_NODE, Boolean.TRUE); + } + + @Override + protected boolean packedClosureUsesDelegateGuard() { + return false; // the type checker proved delegate-independence, so no runtime guard is needed + } + + /** + * True when the type checker resolved every implicitly-received name in the closure (and its + * nested closures) against the owner/parameters rather than a delegate — the delegate-independence + * proof of the capability analysis. Under {@code @CompileStatic} STC records each name's resolution + * path as {@code IMPLICIT_RECEIVER}: a path ending in {@code "owner"} was resolved against the + * owner (safe), anything else (e.g. {@code "delegate"}, from {@code @DelegatesTo} or {@code with}) + * went through a delegate — the same distinction STC itself makes. The marker can sit on a method + * call, a property expression, or a bare variable (e.g. {@code length} inside {@code with}), so + * all three are checked. If nothing resolved through a delegate, packing preserves resolution. + */ + private boolean isDelegateIndependent(final ClosureExpression expression) { + Statement code = expression.getCode(); + if (code == null) return false; + boolean[] delegateResolved = {false}; + // Walk the whole closure tree, including nested closures: a delegate resolution anywhere in + // the body (e.g. an owner-passed closure whose inner closure resolves a name against the + // delegate) means the top closure carries a delegate chain that packing would break. + code.visit(new CodeVisitorSupport() { + @Override public void visitMethodCallExpression(final MethodCallExpression call) { + flag(call.getNodeMetaData(IMPLICIT_RECEIVER)); + // an implicit-this call STC left to runtime resolution — DYNAMIC_RESOLUTION from a + // type-checking extension's makeDynamic (whose virtual MethodNode also lands in + // DIRECT_METHOD_CALL_TARGET, so that alone cannot be trusted), or no recorded + // resolution at all — goes through the closure's owner/delegate chain (mixed-mode + // builder DSLs), so independence is unproven, exactly as for a delegate-resolved name + if (call.isImplicitThis() + && (call.getNodeMetaData(DYNAMIC_RESOLUTION) != null + || (call.getNodeMetaData(IMPLICIT_RECEIVER) == null + && call.getNodeMetaData(DIRECT_METHOD_CALL_TARGET) == null))) { + delegateResolved[0] = true; + } + super.visitMethodCallExpression(call); + } + @Override public void visitPropertyExpression(final PropertyExpression pexp) { + flag(pexp.getNodeMetaData(IMPLICIT_RECEIVER)); + if (pexp.isImplicitThis() && pexp.getNodeMetaData(DYNAMIC_RESOLUTION) != null) { + delegateResolved[0] = true; // extension-forced dynamic property (see above) + } + super.visitPropertyExpression(pexp); + } + @Override public void visitVariableExpression(final VariableExpression ve) { + flag(ve.getNodeMetaData(IMPLICIT_RECEIVER)); + // a name STC left dynamic (no local/param binding, no recorded receiver path) will be + // resolved at runtime through the closure's owner/delegate chain -- not provably owner + if (ve.getAccessedVariable() instanceof DynamicVariable + && ve.getNodeMetaData(IMPLICIT_RECEIVER) == null) { + delegateResolved[0] = true; + } + super.visitVariableExpression(ve); + } + private void flag(final Object receiver) { + if (receiver instanceof String && !((String) receiver).endsWith("owner")) delegateResolved[0] = true; + } + }); + return !delegateResolved[0]; + } + /** {@inheritDoc} */ @Override protected ClassNode createClosureClass(final ClosureExpression expression, final int mods) { 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 35ae0e8a7a3..13cd5a57cda 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,9 +18,10 @@ */ package org.codehaus.groovy.classgen.asm.sc; -import org.apache.groovy.util.SystemUtil; import org.codehaus.groovy.GroovyBugError; +import org.apache.groovy.util.SystemUtil; import org.codehaus.groovy.ast.ClassHelper; +import org.codehaus.groovy.control.CompilerConfiguration; import org.codehaus.groovy.ast.ClassNode; import org.codehaus.groovy.ast.CodeVisitorSupport; import org.codehaus.groovy.ast.ConstructorNode; @@ -450,7 +451,7 @@ private static boolean containsNestedFunction(final Statement code) { * 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"); + return SystemUtil.getBooleanSafe(CompilerConfiguration.LAMBDA_HOISTING); } private int lambdaImplCounter; diff --git a/src/main/java/org/codehaus/groovy/control/CompilerConfiguration.java b/src/main/java/org/codehaus/groovy/control/CompilerConfiguration.java index 95c2a98e196..2041eae9548 100644 --- a/src/main/java/org/codehaus/groovy/control/CompilerConfiguration.java +++ b/src/main/java/org/codehaus/groovy/control/CompilerConfiguration.java @@ -18,6 +18,7 @@ */ package org.codehaus.groovy.control; +import org.apache.groovy.lang.annotation.Incubating; import org.apache.groovy.util.Maps; import org.codehaus.groovy.GroovyBugError; import org.codehaus.groovy.control.customizers.CompilationCustomizer; @@ -67,6 +68,28 @@ public class CompilerConfiguration { /** Joint Compilation Option for enabling generating stubs in memory. */ public static final String MEM_STUB = "memStub"; + /** + * System-property name for the GEP-27 incubating option that automatically packs provably-safe + * {@code @CompileStatic} closures (hoisting their bodies to a shared adapter instead of a class). + * Incubating: this switch may change or be removed until the feature graduates. + */ + @Incubating + public static final String CLOSURE_PACKING = "groovy.target.closure.pack"; + + /** + * System-property name for the GEP-27 incubating option that, on the {@link #CLOSURE_PACKING} + * path, reports (as compiler warnings) each closure that could not be packed and why. + */ + @Incubating + public static final String CLOSURE_PACKING_REPORT = "groovy.target.closure.pack.report"; + + /** + * System-property name for the GEP-27 incubating option that hoists eligible SAM lambdas to a + * metafactory method instead of generating a per-lambda class. + */ + @Incubating + public static final String LAMBDA_HOISTING = "groovy.target.lambda.hoist"; + /** This ("1.4") is the value for targetBytecode to compile for a JDK 1.4. */ @Deprecated public static final String JDK4 = "1.4"; /** This ("1.5") is the value for targetBytecode to compile for a JDK 1.5. */ diff --git a/src/main/java/org/codehaus/groovy/runtime/GeneratedDispatcher.java b/src/main/java/org/codehaus/groovy/runtime/GeneratedDispatcher.java new file mode 100644 index 00000000000..fd7c70e367f --- /dev/null +++ b/src/main/java/org/codehaus/groovy/runtime/GeneratedDispatcher.java @@ -0,0 +1,179 @@ +/* + * 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 org.codehaus.groovy.runtime; + +import java.lang.invoke.CallSite; +import java.lang.invoke.ConstantCallSite; +import java.lang.invoke.LambdaMetafactory; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; + +/** + * A per-class table of compiler-generated dispatch targets, reached by a compact + * integer id instead of a {@link java.lang.invoke.MethodHandle}. + *

+ * The compiler emits per class: an array-shaped table ({@code $packedDispatch$(int, Object[])}, + * covering every target) plus array-free per-arity tables ({@code $packedDispatch1$}, + * {@code $packedDispatch2$}) for the hot shapes taking one or two values beyond the receiver — + * avoiding the packed argument array, which cannot be scalar-replaced when the dispatch call + * does not inline. Each table is a switch over the id whose case casts the arguments to the + * target's declared parameter types and invokes it directly, so the whole chain is ordinary, + * JIT-friendly bytecode. A single {@code invokedynamic} accessor per class links all three + * through {@link #bootstrap} into one constant {@link Bundle}, created lazily on first use. + *

+ * Adapters such as {@link PackedClosure} hold {@code (dispatchers, id)} and make a plain + * interface call per invocation. Unlike a {@code MethodHandle} held in an instance field — + * which the JIT cannot treat as a constant, so every call runs the (comparatively slow) + * method-handle invoker — this executes as a cheap indirect call, and inlines fully when the + * call site is monomorphic. The id space is not limited to packed closure bodies: any + * statically-known target the compiler wants to reach through a shared adapter (for example a + * statically-resolved method reference) can claim an id in the same tables. + * + * @since 6.0.0 + */ +@FunctionalInterface +public interface GeneratedDispatcher { + + /** Name of the generated array-shaped dispatch table method. */ + String TABLE_METHOD = "$packedDispatch$"; + /** Name of the generated one-value dispatch table method. */ + String TABLE1_METHOD = "$packedDispatch1$"; + /** Name of the generated two-value dispatch table method. */ + String TABLE2_METHOD = "$packedDispatch2$"; + + /** + * Invokes the dispatch target registered under {@code id} in the hosting class. + * + * @param id the compile-time-assigned index of the target in the hosting class's table + * @param args the packed argument array: {@code args[0]} is the receiver (the adapter's + * owner — ignored by static targets), followed by any captured values, then + * the call arguments, all in the target's declared parameter order + * @return the target's return value ({@code null} for a void target) + */ + Object dispatch(int id, Object[] args); + + /** + * Array-free companion for targets taking exactly one value beyond the receiver. + * A separate single-method interface so each table is adapted by one + * {@code LambdaMetafactory} linkage. + */ + @FunctionalInterface + interface Arity1 { + /** + * Invokes the target registered under {@code id} whose captured-plus-argument + * count is exactly one. + * + * @param id the compile-time-assigned index of the target + * @param owner the receiver (ignored by static targets) + * @param a the single captured value or call argument + * @return the target's return value + */ + Object dispatch1(int id, Object owner, Object a); + } + + /** + * Array-free companion for targets taking exactly two values beyond the receiver. + * @see Arity1 + */ + @FunctionalInterface + interface Arity2 { + /** + * Invokes the target registered under {@code id} whose captured-plus-argument + * count is exactly two. + * + * @param id the compile-time-assigned index of the target + * @param owner the receiver (ignored by static targets) + * @param a the first captured value or call argument + * @param b the second captured value or call argument + * @return the target's return value + */ + Object dispatch2(int id, Object owner, Object a, Object b); + } + + /** + * The hosting class's three dispatch shapes, linked once by {@link #bootstrap} and shared + * by all of that class's adapters. + */ + final class Bundle { + final GeneratedDispatcher dispatcher; + final Arity1 arity1; + final Arity2 arity2; + + Bundle(final GeneratedDispatcher dispatcher, final Arity1 arity1, final Arity2 arity2) { + this.dispatcher = dispatcher; + this.arity1 = arity1; + this.arity2 = arity2; + } + + /** The array-shaped dispatcher, covering every target. */ + public GeneratedDispatcher dispatcher() { return dispatcher; } + /** The one-value dispatcher. */ + public Arity1 arity1() { return arity1; } + /** The two-value dispatcher. */ + public Arity2 arity2() { return arity2; } + } + + /** + * Invokedynamic bootstrap for the hosting class's dispatcher accessor: adapts the class's + * three private static dispatch tables to their functional interfaces (one hidden class + * each, via {@code LambdaMetafactory} with the caller's full-privilege lookup) and returns + * them as one constant {@link Bundle}. Linked once per class, on first adapter creation. + * + * @param caller the hosting class's lookup (supplied by the JVM) + * @param name the invoked name (unused) + * @param type the accessor's type: {@code () -> Bundle} + * @return a constant call site producing the bundle + * @throws Throwable if the tables cannot be found or linked (a compiler bug) + */ + /** + * Constant-dynamic bootstrap for a closure literal's declared parameter types: decodes a + * method descriptor (which, unlike {@code Class} constants, can carry primitive types) with + * the hosting class's loader into a {@code Class[]} resolved once per literal site and shared + * by every adapter created there — closure creation then loads one constant instead of + * allocating and filling an array. Sharing matches generated closure classes, whose + * parameter-type arrays are likewise cached per class. + * + * @param caller the hosting class's lookup (supplied by the JVM) + * @param name the constant's name (unused) + * @param type the constant's type: {@code Class[]} + * @param descriptor a method descriptor whose parameter types are the closure's declared + * parameter types (the return type is ignored) + * @return the declared parameter types + */ + static Class[] paramTypes(final MethodHandles.Lookup caller, final String name, final Class type, final String descriptor) { + return MethodType.fromMethodDescriptorString(descriptor, caller.lookupClass().getClassLoader()).parameterArray(); + } + + static CallSite bootstrap(final MethodHandles.Lookup caller, final String name, final MethodType type) throws Throwable { + Class host = caller.lookupClass(); + MethodType arrayType = MethodType.methodType(Object.class, int.class, Object[].class); + MethodType oneType = MethodType.methodType(Object.class, int.class, Object.class, Object.class); + MethodType twoType = MethodType.methodType(Object.class, int.class, Object.class, Object.class, Object.class); + GeneratedDispatcher dispatcher = (GeneratedDispatcher) LambdaMetafactory.metafactory( + caller, "dispatch", MethodType.methodType(GeneratedDispatcher.class), + arrayType, caller.findStatic(host, TABLE_METHOD, arrayType), arrayType).getTarget().invokeExact(); + Arity1 arity1 = (Arity1) LambdaMetafactory.metafactory( + caller, "dispatch1", MethodType.methodType(Arity1.class), + oneType, caller.findStatic(host, TABLE1_METHOD, oneType), oneType).getTarget().invokeExact(); + Arity2 arity2 = (Arity2) LambdaMetafactory.metafactory( + caller, "dispatch2", MethodType.methodType(Arity2.class), + twoType, caller.findStatic(host, TABLE2_METHOD, twoType), twoType).getTarget().invokeExact(); + return new ConstantCallSite(MethodHandles.constant(Bundle.class, new Bundle(dispatcher, arity1, arity2))); + } +} diff --git a/src/main/java/org/codehaus/groovy/runtime/PackedClosure.java b/src/main/java/org/codehaus/groovy/runtime/PackedClosure.java new file mode 100644 index 00000000000..8879e25cec3 --- /dev/null +++ b/src/main/java/org/codehaus/groovy/runtime/PackedClosure.java @@ -0,0 +1,527 @@ +/* + * 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 org.codehaus.groovy.runtime; + +import groovy.lang.Closure; +import org.codehaus.groovy.classgen.asm.util.TypeUtil; +import org.codehaus.groovy.reflection.ParameterTypes; +import org.codehaus.groovy.reflection.stdclasses.CachedSAMClass; +import org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation; + +/** + * The shared {@link Closure} adapter family for {@code @PackedClosures} compact closure + * compilation. + *

+ * Normally the Groovy compiler generates one inner class per closure literal + * (e.g. {@code Owner$_method_closure1}). Under {@code @PackedClosures} an eligible + * closure body is instead hoisted into a synthetic method on the enclosing ("owner") + * class, and the closure literal is replaced by an instance of the fixed-arity family + * member matching its declared parameter count ({@link Fixed0}..{@link Fixed4}, + * {@link FixedIt} for implicit-parameter literals, {@link FixedN} for higher and vararg + * arities), which dispatches back to that method. This removes the per-closure generated + * class (and the deeply-nested {@code $_closure1$_closure2$_closure3} name explosion) + * while still yielding a real {@code groovy.lang.Closure} instance, so features that + * operate through {@code call()} (iteration, {@code curry}, {@code memoize}, + * {@code trampoline}) continue to work — and the family member's declared {@code doCall} + * signature(s) give class-level introspection (SAM-overload selection, MOP method + * selection) exactly the view a generated closure class would present. + *

+ * Dispatch goes through the hosting class's {@link GeneratedDispatcher} tables: the adapter + * holds the class's shared dispatchers, the hoisted method's compile-time id, and its own + * dispatch receiver (captured at construction, so {@code dehydrate()}/{@code rehydrate()} + * leave the closure callable). A target taking one or two values beyond the receiver — the + * overwhelmingly common closure shapes — dispatches through the array-free per-arity tables, + * passing the receiver, captured values and arguments as plain parameters; anything else + * packs {@code [receiver, captured..., args...]} into one array for the general table. + * Either way the chain is ordinary, JIT-friendly bytecode — no reflection, and no + * per-instance {@code MethodHandle} (which the JIT cannot constant-fold, making its invoker + * path many times slower than a direct call). The id binds the exact hoisted method, so an + * inherited packed closure can never misdispatch to a same-named method a subclass happens + * to declare. Argument adaptation on every route replicates metaclass dispatch on a + * generated closure class: arity mismatches raise {@code MissingMethodException}, a single + * {@code List} destructures across a non-one-parameter signature, trailing-array parameters + * vararg-collect, and per-parameter coercion follows the metaclass compatibility rules + * (including {@code Closure}-to-SAM). + */ +public abstract class PackedClosure extends Closure { + + private static final long serialVersionUID = 1L; + private static final Object[] EMPTY = new Object[0]; + + /** + * Fixed-arity members of the adapter family: the compiler instantiates the member matching the + * literal's declared parameter count ({@link FixedN} serves higher arities), so a packed + * closure's arity is visible to class-level introspection exactly as on a generated closure + * class — notably {@code MetaClassHelper}'s SAM-overload disambiguation, which reflects the + * argument class's declared {@code doCall} and cannot consult the instance. Each + * member declares ONLY the arity-matched {@code doCall}(s) (delegating to {@link #dispatchAll}) + * and carries the {@link GeneratedClosure} marker that introspection keys on, so MOP method + * selection sees exactly the signatures a generated closure class would declare — in + * particular, no varargs {@code doCall(Object[])} that would exact-match a single + * {@code Object[]} argument and spread it. The hot entry points remain the base {@code call} + * lanes, which do not allocate for these shapes. + */ + public static final class Fixed0 extends PackedClosure implements GeneratedClosure { + private static final long serialVersionUID = 1L; + public Fixed0(final Object owner, final GeneratedDispatcher.Bundle dispatchers, final int id, final String method, final Object[] captured, final Class[] visibleTypes, final boolean strict) { + super(owner, dispatchers, id, method, captured, visibleTypes, strict); + } + public Object doCall() { return dispatchAll(EMPTY); } + } + + public static final class Fixed1 extends PackedClosure implements GeneratedClosure { + private static final long serialVersionUID = 1L; + public Fixed1(final Object owner, final GeneratedDispatcher.Bundle dispatchers, final int id, final String method, final Object[] captured, final Class[] visibleTypes, final boolean strict) { + super(owner, dispatchers, id, method, captured, visibleTypes, strict); + } + public Object doCall(final Object a) { return dispatchAll(new Object[]{a}); } + } + + /** + * The implicit-parameter literal ({@code { it * 2 }}): a generated closure class declares both + * {@code doCall()} and {@code doCall(Object)} for it, which class-level introspection reads as + * the fuzzy "0 or 1" arity that lets the closure match both zero- and one-parameter SAM + * overloads (GROOVY-10905) — so this member declares the same pair. + */ + public static final class FixedIt extends PackedClosure implements GeneratedClosure { + private static final long serialVersionUID = 1L; + public FixedIt(final Object owner, final GeneratedDispatcher.Bundle dispatchers, final int id, final String method, final Object[] captured, final Class[] visibleTypes, final boolean strict) { + super(owner, dispatchers, id, method, captured, visibleTypes, strict); + } + public Object doCall() { return dispatchAll(EMPTY); } + public Object doCall(final Object a) { return dispatchAll(new Object[]{a}); } + } + + public static final class Fixed2 extends PackedClosure implements GeneratedClosure { + private static final long serialVersionUID = 1L; + public Fixed2(final Object owner, final GeneratedDispatcher.Bundle dispatchers, final int id, final String method, final Object[] captured, final Class[] visibleTypes, final boolean strict) { + super(owner, dispatchers, id, method, captured, visibleTypes, strict); + } + public Object doCall(final Object a, final Object b) { return dispatchAll(new Object[]{a, b}); } + } + + public static final class Fixed3 extends PackedClosure implements GeneratedClosure { + private static final long serialVersionUID = 1L; + public Fixed3(final Object owner, final GeneratedDispatcher.Bundle dispatchers, final int id, final String method, final Object[] captured, final Class[] visibleTypes, final boolean strict) { + super(owner, dispatchers, id, method, captured, visibleTypes, strict); + } + public Object doCall(final Object a, final Object b, final Object c) { return dispatchAll(new Object[]{a, b, c}); } + } + + public static final class Fixed4 extends PackedClosure implements GeneratedClosure { + private static final long serialVersionUID = 1L; + public Fixed4(final Object owner, final GeneratedDispatcher.Bundle dispatchers, final int id, final String method, final Object[] captured, final Class[] visibleTypes, final boolean strict) { + super(owner, dispatchers, id, method, captured, visibleTypes, strict); + } + public Object doCall(final Object a, final Object b, final Object c, final Object d) { return dispatchAll(new Object[]{a, b, c, d}); } + } + + /** + * Arities above four (rare) and vararg-shaped literals (trailing array parameter, e.g. + * {@code { ...z -> }}, any arity): a varargs {@code doCall} — MOP selection packs the + * arguments back into one array, and {@link #dispatchAll} applies the same arity/coercion + * contract as the base {@code call} lanes. For the vararg shapes this varargs declaration is + * also the faithful one: their generated class declares a vararg {@code doCall}, + * whose selection flexibility (zero arguments collect to an empty trailing array where a + * fixed one-parameter {@code doCall} would null-fill) a {@code Fixed*} member cannot + * reproduce. A single {@code Object[]} argument spreads through MOP routes here, exactly as + * it feeds the trailing array of a classed vararg {@code doCall}. + */ + public static final class FixedN extends PackedClosure implements GeneratedClosure { + private static final long serialVersionUID = 1L; + public FixedN(final Object owner, final GeneratedDispatcher.Bundle dispatchers, final int id, final String method, final Object[] captured, final Class[] visibleTypes, final boolean strict) { + super(owner, dispatchers, id, method, captured, visibleTypes, strict); + } + public Object doCall(final Object... args) { return dispatchAll(args); } + } + + /** + * The single bytecode-facing entry point for creating a packed closure: the compiler emits one + * {@code INVOKESTATIC} of this factory instead of hard-wiring {@code new PackedClosure$FixedN} + * per literal, so the fixed-arity family members stay out of the emitted-bytecode surface (only + * {@code PackedClosure} and {@code GeneratedDispatcher.Bundle} remain there). The runtime shape + * switch is negligible against the object-creation cost. {@code implicit} marks an implicit-{@code it} + * literal (fuzzy 0/1 arity), {@code vararg} a trailing-array parameter; both need the varargs + * {@code doCall} that only {@code FixedIt}/{@code FixedN} declare. + */ + public static PackedClosure create(final Object owner, final GeneratedDispatcher.Bundle dispatchers, + final int id, final String method, final Object[] captured, final Class[] visibleTypes, + final boolean strict, final boolean implicit, final boolean vararg) { + if (implicit) return new FixedIt(owner, dispatchers, id, method, captured, visibleTypes, strict); + if (!vararg) { + switch (visibleTypes.length) { + case 0: return new Fixed0(owner, dispatchers, id, method, captured, visibleTypes, strict); + case 1: return new Fixed1(owner, dispatchers, id, method, captured, visibleTypes, strict); + case 2: return new Fixed2(owner, dispatchers, id, method, captured, visibleTypes, strict); + case 3: return new Fixed3(owner, dispatchers, id, method, captured, visibleTypes, strict); + case 4: return new Fixed4(owner, dispatchers, id, method, captured, visibleTypes, strict); + default: break; + } + } + return new FixedN(owner, dispatchers, id, method, captured, visibleTypes, strict); + } + + /** + * The hoisted method's receiver, captured at construction. Dispatch uses this — never + * {@code getOwner()} — so {@code dehydrate()} (which nulls the visible owner for its + * disconnect contract) and {@code rehydrate(...)} (which installs a different one) leave the + * closure callable, exactly as a generated closure class whose body never touches the owner. + * Serialization stays blocked either way ({@link #writeObject}). + */ + private final Object receiver; + private final GeneratedDispatcher dispatcher; + private final GeneratedDispatcher.Arity1 arity1; + private final GeneratedDispatcher.Arity2 arity2; + private final int id; + private final String method; + private final Object[] captured; + private final boolean strict; + // Lazy: only the adapted (arity-mismatch) path needs it, and building it in the constructor + // would put an allocation on every closure creation and push past the JIT's inline budget. + private ParameterTypes paramInfo; + + /** + * @param owner the enclosing instance the hoisted method lives on (also used as thisObject; + * the enclosing class itself for a hoisted method in a static context) + * @param dispatchers the hosting class's shared dispatch tables (see {@link GeneratedDispatcher}) + * @param id the hoisted method's compile-time-assigned index in those tables + * @param method the name of the hoisted synthetic method (kept for diagnostics only) + * @param captured values captured from the enclosing scope, passed before the call arguments + * on every dispatch (a written capture passes its shared + * {@code groovy.lang.Reference} unchanged, so writes still propagate) + * @param visibleTypes the closure's declared parameter types, so callers that key behaviour on + * {@code getParameterTypes()} (DGM arity/type decisions) and argument + * adaptation (vararg collection into a trailing array parameter) behave + * exactly as with a generated closure class + * @param strict whether the delegate guard throws. {@code true} for the dynamic trust path + * (an unverifiable {@code @PackedClosures} assertion must fail fast on misuse); + * {@code false} when the type checker PROVED every free name owner-resolved. + */ + public PackedClosure(final Object owner, final GeneratedDispatcher.Bundle dispatchers, final int id, final String method, final Object[] captured, final Class[] visibleTypes, final boolean strict) { + super(owner, owner); + this.receiver = owner; + this.dispatcher = dispatchers.dispatcher; + this.arity1 = dispatchers.arity1; + this.arity2 = dispatchers.arity2; + this.id = id; + this.method = method; + this.captured = (captured != null) ? captured : EMPTY; + this.strict = strict; + this.maximumNumberOfParameters = visibleTypes.length; + this.parameterTypes = visibleTypes; + } + + // GEP-27 runtime guard: a hoisted body binds free names to the owner at compile time, so it + // cannot honour a caller-set delegate. Rather than silently mis-resolve (returning the owner's + // member where a normal closure would reach the delegate), fail fast at the point of misuse. + // Setting the delegate to the owner is the harmless default and stays a no-op. + @Override + public void setDelegate(final Object delegate) { + if (strict && delegate != null && delegate != getOwner()) { + throw new UnsupportedOperationException( + "Cannot set a delegate on a @PackedClosures closure (hoisted method '" + method + + "'): its free names were bound to the owner at compile time, so an external " + + "delegate cannot be honoured. Remove @PackedClosures from this scope, or " + + "exclude this closure, if it relies on delegate-based resolution."); + } + super.setDelegate(delegate); + } + + @Override + public void setResolveStrategy(final int resolveStrategy) { + // DELEGATE_FIRST/DELEGATE_ONLY need a delegate the hoisted body cannot consult; TO_SELF + // would resolve names against this shared adapter, which has no user-defined members. + // Only owner-based resolution (OWNER_FIRST/OWNER_ONLY) is reproducible by a hoisted body. + if (strict && resolveStrategy != OWNER_FIRST && resolveStrategy != OWNER_ONLY) { + throw new UnsupportedOperationException( + "Cannot set resolveStrategy=" + strategyName(resolveStrategy) + + " on a @PackedClosures closure (hoisted method '" + method + "'): a hoisted " + + "body resolves free names against the owner at compile time, so only " + + "OWNER_FIRST/OWNER_ONLY are supported. Remove @PackedClosures from this " + + "scope, or exclude this closure."); + } + super.setResolveStrategy(resolveStrategy); + } + + /** + * Names the hoisted body, so diagnostics identify the literal the way a generated class's + * name would ({@code Script$_run_closure1} becomes {@code Script.$packed$closure$0}). + */ + @Override + public String toString() { + Object owner = getOwner(); + Class oc = (owner instanceof Class) ? (Class) owner : (owner != null ? owner.getClass() : null); + return (oc != null ? oc.getName() : "?") + "." + method + "@" + Integer.toHexString(hashCode()); + } + + private static String strategyName(final int s) { + switch (s) { + case DELEGATE_FIRST: return "DELEGATE_FIRST"; + case DELEGATE_ONLY: return "DELEGATE_ONLY"; + case TO_SELF: return "TO_SELF"; + default: return String.valueOf(s); + } + } + + /** + * Packed closures are not serializable: the dispatch state is bound to hidden classes of the + * hosting class's module. Without this, the default field walk would fail deep inside + * {@code ObjectOutputStream} with a cryptic hidden-class name; instead fail fast with a + * message naming the closure and the opt-out. Note {@code Closure#dehydrate()} — the classed + * closure's route to serializability — does not help a packed closure: the dispatch fields + * remain, so opting the declaring scope out of packing is the remedy. + */ + private void writeObject(final java.io.ObjectOutputStream out) throws java.io.IOException { + Object owner = getOwner(); + String where = (owner == null) ? "" + : " on " + ((owner instanceof Class) ? ((Class) owner).getName() : owner.getClass().getName()); + throw new java.io.NotSerializableException( + "packed closure (hoisted body '" + method + "'" + where + "). Packed closures are not" + + " serializable and dehydrate() does not help (the dispatch state remains); exclude" + + " the declaring scope from packing -- e.g. @PackedClosures(mode = DISABLED) on the" + + " method or class, or compile without groovy.target.closure.pack -- if this closure" + + " must be serialized."); + } + + // Dispatch call() straight to dispatchAll: a varargs doCall would be vararg-ambiguous + // through the metaclass when a single argument is itself an Object[] (the metaclass would treat + // the element as the whole argument list and spread it) — which is exactly why dispatchAll is + // NOT named doCall. Unwrap invoker exceptions exactly as Closure.call does, so user exceptions + // surface unwrapped. + @Override + public final Object call(final Object... args) { + if (!mopUnperturbed()) return mopCall((args != null) ? args : EMPTY); + try { + return dispatchAll((args != null) ? args : EMPTY); + } catch (InvokerInvocationException e) { + org.apache.groovy.internal.util.UncheckedThrow.rethrow(e.getCause()); + return null; // unreachable + } + } + + // Fast lane for the ubiquitous one-argument call (each/collect on an arity-1 closure): the base + // class would wrap the argument in an Object[] and re-enter the varargs path, costing an array + // and a hop through doCall. This method must stay SMALL: it sits between a hot caller loop and + // the hoisted body, and inlines into it when hot — with the per-arity tables there is then no + // argument array at all on the common shapes. + @Override + public final Object call(final Object arguments) { + // one fused branch to the cold path keeps this lane small enough to inline even at + // cool call sites (and its own standalone compilation lean): the argument-wrapping + // fallbacks allocate, so they live out-of-line in callGeneral + if (maximumNumberOfParameters != 1 || !mopUnperturbed()) return callGeneral(arguments); + try { + return dispatchOne(coerceArg(arguments, parameterTypes[0])); + } catch (InvokerInvocationException e) { + org.apache.groovy.internal.util.UncheckedThrow.rethrow(e.getCause()); + return null; // unreachable + } + } + + private Object callGeneral(final Object arguments) { + if (maximumNumberOfParameters != 1) return call(new Object[]{arguments}); + return mopCall(new Object[]{arguments}); + } + + // The MOP guard (Closure#mopUnperturbed) sits on the call entry points only; dispatchAll stays + // direct because the subclass doCalls the MOP fallback selects delegate straight to it. + + /** The full MOP route for perturbed instances: interception, categories and EMC all apply. */ + private Object mopCall(final Object[] args) { + try { + return getMetaClass().invokeMethod(this, "doCall", args); + } catch (InvokerInvocationException e) { + org.apache.groovy.internal.util.UncheckedThrow.rethrow(e.getCause()); + return null; // unreachable + } + } + + /** + * The single dispatch entry every route funnels into: the {@code call} lanes, the + * {@code Fixed*} subclasses' arity-true {@code doCall}s, and the + * {@code PackedClosureMetaClass} short-circuit. Deliberately NOT named {@code doCall}: a + * public varargs {@code doCall(Object[])} inherited by every family member would, under + * MOP-routed dispatch, exact-match a single {@code Object[]} argument, beat + * {@code doCall(Object)}, and spread the array — a generated closure class declares no such + * method, so a classed closure never spreads. + */ + public final Object dispatchAll(final Object[] args) { + // Like call(Object), the exact-arity paths here must stay small enough to inline. + // Anything rare lives in doCallAdapted/coerce, out of the inlining budget. + Object[] provided = (args != null) ? args : EMPTY; + int arity = maximumNumberOfParameters; // direct field reads: cheaper than the virtual getters + // the caller supplied exactly the declared number of arguments (the common each()/collect() + // call): skip the List-destructuring, correctArguments and vararg-collection adaptation -- + // the dispatch table's case does any primitive unboxing -- and dispatch + if (provided.length != arity) return doCallAdapted(provided, arity); + Class[] types = parameterTypes; + if (arity == 1) return dispatchOne(coerceArg(provided[0], types[0])); + if (arity == 2 && captured.length == 0) { + return arity2.dispatch2(id, receiver, coerceArg(provided[0], types[0]), coerceArg(provided[1], types[1])); + } + return dispatchArray(provided, arity); + } + + /** + * Dispatches a single value: through the array-free tables while the captured values fit + * ({@code dispatch1} for none, {@code dispatch2} for one — together the overwhelmingly + * common closure shapes), else through the general array table. + */ + private Object dispatchOne(final Object arg) { + Object[] captured = this.captured; + int caps = captured.length; + if (caps == 0) return arity1.dispatch1(id, receiver, arg); + if (caps == 1) return arity2.dispatch2(id, receiver, captured[0], arg); + Object[] all = new Object[2 + caps]; + all[0] = receiver; + System.arraycopy(captured, 0, all, 1, caps); + all[1 + caps] = arg; + return dispatcher.dispatch(id, all); + } + + /** The general shape: coerce and pack {@code [owner, captured..., args...]} for the array table. */ + private Object dispatchArray(final Object[] provided, final int arity) { + Object[] captured = this.captured; + int base = 1 + captured.length; + Object[] all = new Object[base + arity]; + all[0] = receiver; + System.arraycopy(captured, 0, all, 1, captured.length); + Class[] types = parameterTypes; + for (int i = 0; i < arity; i++) { + all[base + i] = coerceArg(provided[i], types[i]); + } + return dispatcher.dispatch(id, all); + } + + // The dispatch table's case checkcasts/unboxes to the declared types; only a reference type + // the argument is NOT already an instance of needs Groovy coercion (e.g. GString -> String), + // matching a generated closure class. Cheapest test first: Object-typed parameters (every + // implicit-it and untyped param) exit on one comparison. Primitive parameters are checked + // against their wrapper, exactly as metaclass selection autoboxes ({ int x -> } accepts an + // Integer, coerces a compatible number shape, and rejects a Long with MME). + private Object coerceArg(final Object arg, final Class t) { + // hot-path shape matters: this inlines into the call lanes, so it is three cheap tests + // with everything rare out-of-line (a primitive parameter fails isInstance and re-checks + // against its wrapper in the tail, exactly as metaclass selection autoboxes) + if (t == Object.class || arg == null || t.isInstance(arg)) return arg; + return coerceArgSlow(arg, t); + } + + private Object coerceArgSlow(final Object arg, final Class t) { + if (t.isPrimitive()) { + Class ct = TypeUtil.autoboxType(t); + return ct.isInstance(arg) ? arg : coerce(arg, ct); + } + return coerce(arg, t); + } + + // A generated closure class accepts an argument exactly when metaclass method selection would: + // MetaClassHelper-compatible shapes coerce (BigDecimal literal -> double parameter, GString -> + // String, Closure -> SAM), an array parameter vararg-collects a single value, and anything else + // is a MissingMethodException -- never a quiet castToType (String -> Integer would parse, and + // String -> Object[] would explode into characters, where the metaclass rejects both). + private Object coerce(final Object arg, final Class t) { + if (t.isArray()) { + Class ac = arg.getClass(); + // an array of the same dimension IS the parameter array, arriving in a different + // guise (Integer[] where int[] is declared -- e.g. re-boxed by an upstream varargs + // hop): convert it element-wise, exactly as CachedMethod invocation coerces after + // ParameterTypes#fitToVargs applies the same dimension rule. Anything else is a + // single element to vararg-wrap -- never castToType a non-array (String -> Object[] + // would explode into characters, where the metaclass wraps). + if (ac.isArray() && ArrayTypeUtils.dimension(ac) == ArrayTypeUtils.dimension(t)) { + try { + return DefaultTypeTransformation.castToType(arg, t); + } catch (RuntimeException e) { + throw new groovy.lang.MissingMethodException("doCall", getClass(), new Object[]{arg}); + } + } + Object boxed = java.lang.reflect.Array.newInstance(t.getComponentType(), 1); + java.lang.reflect.Array.set(boxed, 0, arg); + return boxed; + } + // a Closure argument coerces to a SAM-interface parameter (metaclass selection accepts + // Closure for SAM params -- e.g. action.run({->}) into { Proc it -> it.doSomething() }); + // castToType performs the standard proxy conversion + boolean closureToSam = groovy.lang.Closure.class.isAssignableFrom(arg.getClass()) + && t.isInterface() + && CachedSAMClass.getSAMMethod(t) != null; + if (!closureToSam && !MetaClassHelper.isAssignableFrom(t, arg.getClass())) { + throw new groovy.lang.MissingMethodException("doCall", getClass(), new Object[]{arg}); + } + try { + return DefaultTypeTransformation.castToType(arg, t); + } catch (RuntimeException e) { + throw new groovy.lang.MissingMethodException("doCall", getClass(), new Object[]{arg}); + } + } + + private Object doCallAdapted(Object[] provided, final int arity) { + int base = 1 + captured.length; // packed layout: [owner, captured..., args...] + final Object[] original = provided; // MME reports the caller's argument types, pre-adaptation + // A real closure destructures a single List/Tuple argument across a non-one-parameter + // signature ({ a, b -> } called with one Tuple2; { -> } driven with a Tuple0); mirror + // that before arity normalisation. A one-parameter closure keeps the list as its argument. + if (provided.length == 1 && arity != 1 && provided[0] instanceof java.util.List) { + provided = ((java.util.List) provided[0]).toArray(); + } + // With the real parameter types available, adapt arguments exactly as metaclass dispatch on + // a generated closure class would -- collecting excess args into a trailing array parameter + // ({ Object[] args -> } called with several values), and coercing each argument to its + // declared parameter type (Closure -> SAM interface, GString -> String, number conversions) + // as metaclass dispatch does. + ParameterTypes info = paramInfo; + if (info == null) { + // benign race: ParameterTypes is derived from the final parameterTypes, so any winner + // is equivalent + paramInfo = info = new ParameterTypes(parameterTypes); + } + // arguments to a zero-parameter closure are always a mismatch ({ -> } invoked by each()); + // checked before correctArguments, which would silently drop them for a no-arg signature + if (arity == 0 && provided.length > 0) { + throw new groovy.lang.MissingMethodException("doCall", getClass(), original); + } + try { + provided = info.correctArguments(provided); + } catch (RuntimeException ignore) { + // fall through to the arity check below + } + // A generated closure class accepts exactly two arity adaptations beyond the vararg + // collection above: zero arguments to a one-parameter closure ({ it -> }() and + // { x -> }() both bind null), and the single-List destructuring already applied. Any + // other mismatch is a MissingMethodException from metaclass method selection -- callers + // like MockFor depend on the failure ({ -> } invoked with arguments must not run). + if (provided.length != arity) { + if (provided.length == 0 && arity == 1) { + provided = new Object[]{null}; + } else { + throw new groovy.lang.MissingMethodException("doCall", getClass(), original); + } + } + Class[] types = parameterTypes; + for (int i = 0; i < provided.length && i < types.length; i++) { + provided[i] = coerceArg(provided[i], types[i]); + } + Object[] all = new Object[base + arity]; + all[0] = receiver; + System.arraycopy(captured, 0, all, 1, captured.length); + System.arraycopy(provided, 0, all, base, arity); + return dispatcher.dispatch(id, all); + } +} diff --git a/src/main/java/org/codehaus/groovy/runtime/metaclass/PackedClosureMetaClass.java b/src/main/java/org/codehaus/groovy/runtime/metaclass/PackedClosureMetaClass.java new file mode 100644 index 00000000000..9263037fa08 --- /dev/null +++ b/src/main/java/org/codehaus/groovy/runtime/metaclass/PackedClosureMetaClass.java @@ -0,0 +1,93 @@ +/* + * 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 org.codehaus.groovy.runtime.metaclass; + +import groovy.lang.MetaClassImpl; +import groovy.lang.MetaClassRegistry; +import groovy.lang.MetaMethod; +import org.codehaus.groovy.reflection.ParameterTypes; +import org.codehaus.groovy.runtime.MetaClassHelper; +import org.codehaus.groovy.runtime.PackedClosure; + +import java.util.Collections; +import java.util.List; + +/** + * The stock metaclass for {@link PackedClosure}, giving packed closures their own MOP + * standing (the analog of {@link ClosureMetaClass} for generated closure classes, whose + * per-class metaclasses packed closures deliberately do not have). + *

+ * {@code invokeMethod("call"/"doCall")} dispatches straight to the adapter — a plain + * virtual call that routes to the hosting class's dispatch tables — with no reflection + * anywhere on the path, and, matching {@link ClosureMetaClass}, without consulting + * categories for the closure-invocation names (categories have never applied to closure + * {@code doCall} dispatch). Each fixed-arity family member class gets its own instance of + * this metaclass from the registry (a per-class registry can only be per-adapter-class), + * so class-level metaclass changes affect every packed closure of that member's arity; + * per-instance {@code setMetaClass} is honoured as usual — the adapter's dispatch guard + * routes perturbed instances through the replacement's {@code invokeMethod}. + *

+ * {@code respondsTo(Object, String, Object[])} is instance-faithful: the fixed-arity + * family member's {@code doCall}(s) are erased to {@code Object} parameters (and + * {@code FixedN}'s is varargs), so the answer for the closure-invocation names is + * additionally filtered by the instance's declared parameter types (which the adapter + * carries for exactly this purpose). Purely class-level introspection + * ({@code pickMethod}, {@code getMetaMethods}) necessarily reflects the shared adapter + * class, not any single literal. + * + * @since 6.0.0 + */ +public final class PackedClosureMetaClass extends MetaClassImpl { + + private static final Object[] NO_ARGS = new Object[0]; + + public PackedClosureMetaClass(final MetaClassRegistry registry, final Class theClass) { + super(registry, theClass); + } + + @Override + public Object invokeMethod(final Class sender, final Object object, final String methodName, + final Object[] originalArguments, final boolean isCallToSuper, final boolean fromInsideClass) { + if (!isCallToSuper && object instanceof PackedClosure + && ("call".equals(methodName) || "doCall".equals(methodName))) { + // reflection-free: a plain virtual call into the adapter, which coerces and routes + // to the hosting class's dispatch tables; user exceptions propagate unwrapped, as + // Closure.call's fallback expects. dispatchAll receives the argument array INTACT + // (a single Object[] argument stays one argument, as on a generated closure class), + // where MetaMethod selection on a varargs doCall would spread it. + return ((PackedClosure) object).dispatchAll((originalArguments != null) ? originalArguments : NO_ARGS); + } + return super.invokeMethod(sender, object, methodName, originalArguments, isCallToSuper, fromInsideClass); + } + + @Override + public List respondsTo(final Object obj, final String name, final Object[] argTypes) { + List answer = super.respondsTo(obj, name, argTypes); + if (!answer.isEmpty() && obj instanceof PackedClosure + && ("call".equals(name) || "doCall".equals(name))) { + // the generic varargs doCall answers everything; filter by the instance's + // declared parameter types so introspection matches a generated closure class + Class[] classes = MetaClassHelper.castArgumentsToClassArray(argTypes); + if (!new ParameterTypes(((PackedClosure) obj).getParameterTypes()).isValidMethod(classes)) { + return Collections.emptyList(); + } + } + return answer; + } +} diff --git a/src/test/groovy/gls/generics/GenericsBytecodeTest.groovy b/src/test/groovy/gls/generics/GenericsBytecodeTest.groovy index c3aa3e05508..28399c36598 100644 --- a/src/test/groovy/gls/generics/GenericsBytecodeTest.groovy +++ b/src/test/groovy/gls/generics/GenericsBytecodeTest.groovy @@ -17,6 +17,7 @@ * under the License. */ package gls.generics +import org.codehaus.groovy.control.CompilerConfiguration; class GenericsBytecodeTest extends GenericsTestBase { @@ -207,6 +208,9 @@ class GenericsBytecodeTest extends GenericsTestBase { // GROOVY-10229 void testWildcard3() { + // asserts generic signatures on the generated closure class, which does not exist + // when GEP-27 closure packing is enabled + if (Boolean.getBoolean(CompilerConfiguration.CLOSURE_PACKING)) return createClassInfo ''' @groovy.transform.CompileStatic class C { diff --git a/src/test/groovy/groovy/lang/ClosureSerializationCycleTest.groovy b/src/test/groovy/groovy/lang/ClosureSerializationCycleTest.groovy index d1a8eb3c1e4..e8fa73f9dc8 100644 --- a/src/test/groovy/groovy/lang/ClosureSerializationCycleTest.groovy +++ b/src/test/groovy/groovy/lang/ClosureSerializationCycleTest.groovy @@ -34,6 +34,9 @@ import static groovy.test.GroovyAssert.shouldFail * tests cover that rejection and confirm that legitimate closure serialization is * unaffected. */ +@groovy.transform.PackedClosures(mode = groovy.transform.PackedClosures.PackMode.DISABLED) +// the closure literals here are serialization-gadget FIXTURES: they must compile as classes +// (serializable) regardless of the GEP-27 packing flag, so deserialization hardening is tested final class ClosureSerializationCycleTest { private static void setClosureField(Closure target, String name, Object value) { diff --git a/src/test/groovy/groovy/lang/ReferenceSerializationTest.groovy b/src/test/groovy/groovy/lang/ReferenceSerializationTest.groovy index 485b1400037..d44bd640f8f 100644 --- a/src/test/groovy/groovy/lang/ReferenceSerializationTest.groovy +++ b/src/test/groovy/groovy/lang/ReferenceSerializationTest.groovy @@ -59,6 +59,8 @@ class ReferenceSerializationTest implements Serializable { } @Test + @groovy.transform.PackedClosures(mode = groovy.transform.PackedClosures.PackMode.DISABLED) + // closure round-trip fixture: must stay a (serializable) class regardless of the packing flag void testClosureSerializationWithAReferenceToALocalVariable() { int number = 2 def doubler = { it * number } diff --git a/src/test/groovy/groovy/transform/stc/LambdaHoistTest.groovy b/src/test/groovy/groovy/transform/stc/LambdaHoistTest.groovy index 9814ef5dad0..4ce40df1d42 100644 --- a/src/test/groovy/groovy/transform/stc/LambdaHoistTest.groovy +++ b/src/test/groovy/groovy/transform/stc/LambdaHoistTest.groovy @@ -41,7 +41,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue */ final class LambdaHoistTest { - private static final String PROP = 'groovy.target.lambda.hoist' + private static final String PROP = CompilerConfiguration.LAMBDA_HOISTING /** Compiles the source with hoisting on/off and returns the sorted generated class names. */ private static List classNames(String src, boolean hoist) { diff --git a/src/test/groovy/groovy/transform/stc/LambdaTest.groovy b/src/test/groovy/groovy/transform/stc/LambdaTest.groovy index 692d60df9a2..b0e0128302c 100644 --- a/src/test/groovy/groovy/transform/stc/LambdaTest.groovy +++ b/src/test/groovy/groovy/transform/stc/LambdaTest.groovy @@ -25,6 +25,7 @@ import org.junit.jupiter.api.condition.DisabledIfSystemProperty import static groovy.test.GroovyAssert.assertScript import static groovy.test.GroovyAssert.shouldFail +import org.codehaus.groovy.control.CompilerConfiguration; final class LambdaTest { @@ -1878,7 +1879,7 @@ final class LambdaTest { // GROOVY-9770 @Test - @DisabledIfSystemProperty(named = 'groovy.target.lambda.hoist', matches = 'true', + @DisabledIfSystemProperty(named = CompilerConfiguration.LAMBDA_HOISTING, matches = 'true', disabledReason = 'asserts the pre-hoist generated lambda class exists (GEP-27)') void testLambdaClassIsntSynthetic() { assertScript shell, ''' @@ -1896,7 +1897,7 @@ final class LambdaTest { // GROOVY-11905 @Nested - @DisabledIfSystemProperty(named = 'groovy.target.lambda.hoist', matches = 'true', + @DisabledIfSystemProperty(named = CompilerConfiguration.LAMBDA_HOISTING, 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 @@ -3432,7 +3433,7 @@ final class LambdaTest { } @Nested - @DisabledIfSystemProperty(named = 'groovy.target.lambda.hoist', matches = 'true', + @DisabledIfSystemProperty(named = CompilerConfiguration.LAMBDA_HOISTING, 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 diff --git a/src/test/groovy/org/codehaus/groovy/classgen/asm/NestHostTests.groovy b/src/test/groovy/org/codehaus/groovy/classgen/asm/NestHostTests.groovy index 797f6e950cd..268150134e5 100644 --- a/src/test/groovy/org/codehaus/groovy/classgen/asm/NestHostTests.groovy +++ b/src/test/groovy/org/codehaus/groovy/classgen/asm/NestHostTests.groovy @@ -21,6 +21,8 @@ package org.codehaus.groovy.classgen.asm import org.codehaus.groovy.control.CompilationUnit import org.codehaus.groovy.control.Phases import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.DisabledIfSystemProperty +import org.codehaus.groovy.control.CompilerConfiguration; final class NestHostTests { @@ -85,6 +87,8 @@ final class NestHostTests { } @Test + @DisabledIfSystemProperty(named = CompilerConfiguration.CLOSURE_PACKING, matches = 'true', + disabledReason = 'asserts closure classes among nest members; packed closures generate no classes') void testNestHost4() { def types = compileScript ''' class C { @@ -121,6 +125,8 @@ final class NestHostTests { } @Test + @DisabledIfSystemProperty(named = CompilerConfiguration.CLOSURE_PACKING, matches = 'true', + disabledReason = 'asserts closure classes among nest members; packed closures generate no classes') void testNestHost6() { def types = compileScript ''' @groovy.transform.CompileStatic @@ -137,6 +143,8 @@ final class NestHostTests { // GROOVY-11780 @Test + @DisabledIfSystemProperty(named = CompilerConfiguration.CLOSURE_PACKING, matches = 'true', + disabledReason = 'asserts closure classes among nest members; packed closures generate no classes') void testNestHost7() { def types = compileScript ''' class C { 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 13cbf2e3e62..0641c61755f 100644 --- a/src/test/groovy/org/codehaus/groovy/classgen/asm/TypeAnnotationsTest.groovy +++ b/src/test/groovy/org/codehaus/groovy/classgen/asm/TypeAnnotationsTest.groovy @@ -20,6 +20,7 @@ package org.codehaus.groovy.classgen.asm import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.DisabledIfSystemProperty +import org.codehaus.groovy.control.CompilerConfiguration; final class TypeAnnotationsTest extends AbstractBytecodeTestCase { @@ -270,6 +271,8 @@ final class TypeAnnotationsTest extends AbstractBytecodeTestCase { // GROOVY-11479 @Test + @DisabledIfSystemProperty(named = CompilerConfiguration.CLOSURE_PACKING, matches = 'true', + disabledReason = 'asserts the pre-pack generated closure-class bytecode shape; superseded when GEP-27 closure packing is enabled') void testTypeAnnotationsForClosure() { def bytecode = compile(classNamePattern: 'Foo\\$_closure1', method: 'doCall', imports + ''' @Retention(RUNTIME) @Target(TYPE_USE) @interface TypeAnno0 { } @@ -289,7 +292,7 @@ final class TypeAnnotationsTest extends AbstractBytecodeTestCase { // GROOVY-11479 @Test - @DisabledIfSystemProperty(named = 'groovy.target.lambda.hoist', matches = 'true', + @DisabledIfSystemProperty(named = CompilerConfiguration.LAMBDA_HOISTING, 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 + ''' diff --git a/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/FieldsAndPropertiesStaticCompileTest.groovy b/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/FieldsAndPropertiesStaticCompileTest.groovy index b5fb78315a0..b9e0d5a11ed 100644 --- a/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/FieldsAndPropertiesStaticCompileTest.groovy +++ b/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/FieldsAndPropertiesStaticCompileTest.groovy @@ -20,6 +20,8 @@ package org.codehaus.groovy.classgen.asm.sc import groovy.transform.stc.FieldsAndPropertiesSTCTest import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.DisabledIfSystemProperty +import org.codehaus.groovy.control.CompilerConfiguration; /** * Unit tests for static compilation : fields and properties. @@ -653,6 +655,8 @@ final class FieldsAndPropertiesStaticCompileTest extends FieldsAndPropertiesSTCT // GROOVY-7705, GROOVY-10687 @Test + @DisabledIfSystemProperty(named = CompilerConfiguration.CLOSURE_PACKING, matches = 'true', + disabledReason = 'asserts the pre-pack generated closure-class bytecode shape; superseded when GEP-27 closure packing is enabled') void testPrivateFieldMutationInClosureUsesDirectAccess() { for (prefix in ['','this.','thisObject.','getThisObject().']) { assertScript """ @@ -675,6 +679,8 @@ final class FieldsAndPropertiesStaticCompileTest extends FieldsAndPropertiesSTCT // GROOVY-7705, GROOVY-10687 @Test + @DisabledIfSystemProperty(named = CompilerConfiguration.CLOSURE_PACKING, matches = 'true', + disabledReason = 'asserts the pre-pack generated closure-class bytecode shape; superseded when GEP-27 closure packing is enabled') void testPrivateStaticFieldMutationInClosureUsesDirectAccess() { assertScript ''' class Foo { @@ -752,6 +758,8 @@ final class FieldsAndPropertiesStaticCompileTest extends FieldsAndPropertiesSTCT // GROOVY-7705, GROOVY-10687 @Test + @DisabledIfSystemProperty(named = CompilerConfiguration.CLOSURE_PACKING, matches = 'true', + disabledReason = 'asserts the pre-pack generated closure-class bytecode shape; superseded when GEP-27 closure packing is enabled') void testMultiplePrivateFieldMutatorDirectAccess() { assertScript ''' class C { @@ -776,6 +784,8 @@ final class FieldsAndPropertiesStaticCompileTest extends FieldsAndPropertiesSTCT // GROOVY-7705, GROOVY-9385, GROOVY-10687 @Test + @DisabledIfSystemProperty(named = CompilerConfiguration.CLOSURE_PACKING, matches = 'true', + disabledReason = 'asserts the pre-pack generated closure-class bytecode shape; superseded when GEP-27 closure packing is enabled') void testPrivateFieldBridgeMethodsAreGeneratedAsNecessary() { assertScript ''' class C { diff --git a/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/StaticCompileClosureGeneratedAnnotationTest.groovy b/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/StaticCompileClosureGeneratedAnnotationTest.groovy index ded3d54ba90..84f56f9b8fb 100644 --- a/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/StaticCompileClosureGeneratedAnnotationTest.groovy +++ b/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/StaticCompileClosureGeneratedAnnotationTest.groovy @@ -22,12 +22,16 @@ import groovy.transform.Generated import org.codehaus.groovy.control.CompilationUnit import org.codehaus.groovy.control.Phases import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.DisabledIfSystemProperty import java.lang.reflect.Method +import org.codehaus.groovy.control.CompilerConfiguration; /** * Verifies if {@link Generated} annotations are added on {@code call} methods of generated closure classes when static compilation is used. */ +@DisabledIfSystemProperty(named = CompilerConfiguration.CLOSURE_PACKING, matches = 'true', + disabledReason = 'reflects on generated closure classes, which do not exist when GEP-27 closure packing is enabled') final class StaticCompileClosureGeneratedAnnotationTest { private CompilationUnit compileScript(String script) { diff --git a/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/bugs/Groovy7276.groovy b/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/bugs/Groovy7276.groovy index 14a84ea908d..55a0a1ab84c 100644 --- a/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/bugs/Groovy7276.groovy +++ b/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/bugs/Groovy7276.groovy @@ -21,9 +21,13 @@ package org.codehaus.groovy.classgen.asm.sc.bugs import groovy.transform.stc.StaticTypeCheckingTestCase import org.codehaus.groovy.classgen.asm.sc.StaticCompilationTestSupport import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.DisabledIfSystemProperty import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ValueSource +import org.codehaus.groovy.control.CompilerConfiguration; +@DisabledIfSystemProperty(named = CompilerConfiguration.CLOSURE_PACKING, matches = 'true', + disabledReason = 'inspects private-access bridge bytecode on generated closure classes (astTrees), which do not exist when GEP-27 closure packing is enabled') final class Groovy7276 extends StaticTypeCheckingTestCase implements StaticCompilationTestSupport { @ParameterizedTest diff --git a/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/bugs/ReproducibleBytecodeBugs.groovy b/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/bugs/ReproducibleBytecodeBugs.groovy index e2a22ad5493..55ca8b233c8 100644 --- a/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/bugs/ReproducibleBytecodeBugs.groovy +++ b/src/test/groovy/org/codehaus/groovy/classgen/asm/sc/bugs/ReproducibleBytecodeBugs.groovy @@ -21,8 +21,10 @@ package org.codehaus.groovy.classgen.asm.sc.bugs import groovy.transform.stc.StaticTypeCheckingTestCase import org.codehaus.groovy.classgen.asm.sc.StaticCompilationTestSupport import org.junit.jupiter.api.RepeatedTest +import org.junit.jupiter.api.condition.DisabledIfSystemProperty import static org.junit.jupiter.api.Assertions.fail +import org.codehaus.groovy.control.CompilerConfiguration; final class ReproducibleBytecodeBugs extends StaticTypeCheckingTestCase implements StaticCompilationTestSupport { @@ -51,6 +53,8 @@ final class ReproducibleBytecodeBugs extends StaticTypeCheckingTestCase implemen } // GROOVY-8148 + @DisabledIfSystemProperty(named = CompilerConfiguration.CLOSURE_PACKING, matches = 'true', + disabledReason = 'asserts the pre-pack generated closure-class bytecode shape; superseded when GEP-27 closure packing is enabled') @RepeatedTest(100) void testShouldAlwaysAddClosureSharedVariablesInSameOrder() { assertScript ''' diff --git a/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallMopGuardTest.groovy b/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallMopGuardTest.groovy index 1dcbb1df2f0..8afc1d4bc17 100644 --- a/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallMopGuardTest.groovy +++ b/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallMopGuardTest.groovy @@ -23,14 +23,14 @@ import org.junit.jupiter.api.Test import static groovy.test.GroovyAssert.assertScript /** - * {@code Closure.call}'s cached direct-dispatch path must be semantically invisible: - * it may only be taken when nothing MOP-relevant is in play. These tests pin the - * guard — a per-instance metaclass whose {@code invokeMethod} intercepts - * {@code doCall} must be honoured on the Java/GDK entry ({@code closure.call(...)} - * from DGM), not just the dynamic path, for statically compiled closure classes - * (which declare typed doCall/call methods the cache serves). Without the guard, - * the cache invokes the target directly and the interception is silently skipped - * on one path but not the other. + * The cached direct-dispatch paths ({@code Closure.call}'s override cache and + * {@code PackedClosure}'s dispatch tables) must be semantically invisible: they may + * only be taken when nothing MOP-relevant is in play. These tests pin the guard — + * a per-instance metaclass whose {@code invokeMethod} intercepts {@code doCall} + * must be honoured on the Java/GDK entry ({@code closure.call(...)} from DGM), not + * just the dynamic path, for statically compiled closure classes and for packed + * closures alike. Without the guard, the caches invoke the target directly and the + * interception is silently skipped on one path but not the other. */ final class ClosureCallMopGuardTest { @@ -62,6 +62,23 @@ final class ClosureCallMopGuardTest { ''' } + @Test + void perInstanceMetaclassInterceptsPackedClosureOnBothPaths() { + assertScript INTERCEPTOR + ''' + @groovy.transform.PackedClosures + class P { + def m(Closure interceptor) { + def c = { it * 2 } + assert c instanceof org.codehaus.groovy.runtime.PackedClosure + assert [3].collect(c) == [6] // unperturbed: table dispatch + interceptor(c) + [c(3), [3].collect(c)] + } + } + assert new P().m(intercept) == ['intercepted', ['intercepted']] + ''' + } + @Test void unperturbedInstancesAreUnaffectedByAnotherInstancesMetaclass() { assertScript INTERCEPTOR + ''' diff --git a/src/test/groovy/org/codehaus/groovy/runtime/PackedClosureMetaClassTest.groovy b/src/test/groovy/org/codehaus/groovy/runtime/PackedClosureMetaClassTest.groovy new file mode 100644 index 00000000000..d8f7f780281 --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/runtime/PackedClosureMetaClassTest.groovy @@ -0,0 +1,104 @@ +/* + * 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 org.codehaus.groovy.runtime + +import org.junit.jupiter.api.Test + +import static groovy.test.GroovyAssert.assertScript + +/** + * {@link org.codehaus.groovy.runtime.metaclass.PackedClosureMetaClass} gives packed + * closures their own MOP standing: it is the registered stock metaclass for the shared + * {@code PackedClosure} adapter class, dispatches {@code call}/{@code doCall} without + * reflection, matches {@code ClosureMetaClass}'s category stance for those names, and + * answers {@code respondsTo} faithfully to the instance's declared parameter types + * (which purely class-level introspection cannot, since every packed closure shares + * one adapter class). + */ +final class PackedClosureMetaClassTest { + + @Test + void packedClosuresGetTheDedicatedStockMetaclass() { + assertScript ''' + import groovy.transform.CompileStatic + @CompileStatic + class Probe { + static String fieldMetaClassOf(Closure c) { c.getMetaClass().getClass().name } + } + @groovy.transform.PackedClosures + class P { def m() { def c = { it * 2 }; [Probe.fieldMetaClassOf(c), c(3), [3].collect(c)] } } + def (mc, dyn, gdk) = new P().m() + assert mc == 'org.codehaus.groovy.runtime.metaclass.PackedClosureMetaClass' + assert dyn == 6 // dynamic path through the metaclass + assert gdk == [6] // Java/GDK path through the guard + tables + ''' + } + + @Test + void invokeMethodDispatchesWithoutReflection() { + assertScript ''' + @groovy.transform.PackedClosures + class P { + def m() { + def c = { int a, int b -> a + b } + [c.invokeMethod('doCall', [2, 3] as Object[]), + c.invokeMethod('call', [4, 5] as Object[])] + } + } + assert new P().m() == [5, 9] + ''' + } + + @Test + void respondsToIsInstanceFaithful() { + assertScript ''' + @groovy.transform.PackedClosures + class P { + def m() { + def c = { Integer x -> x * 2 } + def mc = c.metaClass + [mc.respondsTo(c, 'doCall', [Integer] as Object[]).empty, + mc.respondsTo(c, 'doCall', [Date] as Object[]).empty, + mc.respondsTo(c, 'doCall', [Integer, Integer] as Object[]).empty] + } + } + def (matching, wrongType, wrongArity) = new P().m() + assert !matching // declared shape answers + assert wrongType // a Date is not an Integer + assert wrongArity // two args to a one-param closure + ''' + } + + @Test + void categoryStanceMatchesClosureClasses() { + // ClosureMetaClass has never consulted categories for doCall dispatch; the packed + // metaclass must agree, so packed and classed closures behave identically in use{} + assertScript ''' + class ShoutCategory { + static Object doCall(Closure self, Object arg) { 'category' } + } + @groovy.transform.PackedClosures + class P { def m() { def c = { it * 2 }; use(ShoutCategory) { [c(3), [3].collect(c)] } } } + def plain = { it * 2 } + def plainResults = use(ShoutCategory) { [plain(3), [3].collect(plain)] } + assert new P().m() == [6, [6]] + assert plainResults == [6, [6]] + ''' + } +} diff --git a/src/test/groovy/org/codehaus/groovy/transform/ClosurePackCapabilityTest.groovy b/src/test/groovy/org/codehaus/groovy/transform/ClosurePackCapabilityTest.groovy new file mode 100644 index 00000000000..2bf50fdb934 --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/transform/ClosurePackCapabilityTest.groovy @@ -0,0 +1,349 @@ +/* + * 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 org.codehaus.groovy.transform + +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.Handle +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.assertTrue + +/** + * The GEP-27 capability analysis: under {@code @CompileStatic} a closure the type checker resolved + * against the owner (no {@code DELEGATION_METADATA}) is provably delegate-independent, so it is + * packed automatically — no {@code @PackedClosures} annotation — when the {@code + * groovy.target.closure.pack} flag is set. Closures resolved against a delegate ({@code @DelegatesTo}) + * are proven delegate-dependent and kept as classes; dynamic closures (no static resolution) are + * never auto-packed. The flag is read per compilation, so it is toggled per test. + */ +final class ClosurePackCapabilityTest { + + private static final String PROP = CompilerConfiguration.CLOSURE_PACKING + + private static T withFlag(boolean on, Closure work) { + String previous = System.getProperty(PROP) + if (on) 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 List classNames(String src, boolean flag) { + withFlag(flag) { + def cu = new CompilationUnit(new CompilerConfiguration()) + cu.addSource('Src.groovy', src) + cu.compile(Phases.CLASS_GENERATION) + cu.classes.collect { it.name }.sort() + } + } + + private static int closureClassCount(List names) { + names.count { it.contains('_closure') } + } + + private static Object eval(String src, String tail, boolean flag) { + withFlag(flag) { new GroovyShell().evaluate(src + '\n' + tail) } + } + + private static final String STATIC_PLAIN = '''import groovy.transform.CompileStatic + @CompileStatic + class Plain { + List doubled(List xs) { xs.collect { Integer it -> it * 2 } } + int total(List xs) { int t = 0; xs.each { Integer it -> t += it }; t } + }''' + + @Test + void staticDelegateIndependentClosuresAutoPackWhenFlagOn() { + // No @PackedClosures anywhere: the flag alone drives packing of the proven-safe closures. + assertEquals(0, closureClassCount(classNames(STATIC_PLAIN, true)), + 'delegate-independent @CompileStatic closures should auto-pack') + def p = eval(STATIC_PLAIN, 'new Plain()', true) + assertEquals([2, 4, 6], p.doubled([1, 2, 3])) // behaviour unchanged + assertEquals(6, p.total([1, 2, 3])) // captured write, Reference-threaded + } + + @Test + void nothingAutoPacksWhenFlagOff() { + // Default: no automatic packing, so @CompileStatic code is byte-for-byte as today. + assertTrue(closureClassCount(classNames(STATIC_PLAIN, false)) >= 2, + 'with the flag off no closure should be auto-packed') + assertEquals([2, 4, 6], eval(STATIC_PLAIN, 'new Plain().doubled([1, 2, 3])', false)) + } + + @Test + void delegateResolvedClosuresAreProvenDependentAndKeptAsClasses() { + // The builder's closure resolves tag()/nest against the @DelegatesTo delegate, so STC marks it + // with DELEGATION_METADATA. The capability analysis must NOT auto-pack it (that would rebind + // the calls to the owner and break the DSL), and delegate resolution must still work. + String src = '''import groovy.transform.CompileStatic + class Builder { + List tags = [] + void tag(String t) { tags << t } + List run(@DelegatesTo(Builder) Closure c) { + c.delegate = this; c.resolveStrategy = Closure.DELEGATE_FIRST; c.call(); tags + } + } + @CompileStatic + class UsesDsl { + List make() { new Builder().run { tag('a'); tag('b') } } + }''' + def names = classNames(src, true) + assertTrue(closureClassCount(names) >= 1, "delegate DSL closure must stay a class: $names") + assertEquals(['a', 'b'], eval(src, 'new UsesDsl().make()', true)) + } + + @Test + void autoPackedBodyIsStaticallyCompiled() { + // The hoisted body reuses the expressions StaticCompilationVisitor already annotated, and the + // read-only capture is passed as a typed leading parameter -- so the body compiles with static + // dispatch (zero invokedynamic), unlike the Reference-boxed closure-class form. + String src = '''import groovy.transform.CompileStatic + @CompileStatic + class T { + List tag(List xs, String p) { xs.collect { String s -> p + s } } + }''' + byte[] bytes = withFlag(true) { + def cu = new CompilationUnit(new CompilerConfiguration()) + cu.addSource('T.groovy', src) + cu.compile(Phases.CLASS_GENERATION) + cu.classes.find { it.name == 'T' }.bytes + } + def hoisted = [:] + new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] exc) { + if (!name.startsWith('$packed$')) return null + hoisted[name] = [desc: desc, indy: 0] + return new MethodVisitor(Opcodes.ASM9) { + @Override + void visitInvokeDynamicInsn(String n, String d, Handle bsm, Object... args) { + hoisted[name].indy++ + } + } + } + }, 0) + assertEquals(1, hoisted.size(), "expected one hoisted method, got: ${hoisted.keySet()}") + def name = hoisted.keySet().iterator().next() + def info = hoisted[name] + assertTrue(info.desc.startsWith('(Ljava/lang/String;Ljava/lang/String;'), + "captured value should be a typed String parameter: ${info.desc}") + assertEquals(0, info.indy, "hoisted body should be statically dispatched, got ${info.indy} indy in $name") + assertEquals(['p-x'], eval(src, 'new T().tag(["x"], "p-")', true)) + } + + @Test + void writtenCapturesPackViaSharedReferenceHolder() { + // The shared Reference is passed straight into the hoisted method as a holder parameter, so + // the untouched body compiles with the implicit get()/set() the ASM generator already emits + // for closure classes. That supports every write form -- compound ops beyond +/-, and postfix + // increments with correct value-in-expression semantics. + String src = '''import groovy.transform.CompileStatic + @CompileStatic + class W { + int total(List xs) { int t = 0; xs.each { t += it }; t } + long lshift(List xs){ long m = 1; xs.each { m <<= it }; m } // beyond +/- compound + List olds(List xs) { + int c = 10 + List seen = [] + xs.each { seen << c++ } // postfix VALUE used + seen << c + seen + } + }''' + assertEquals(0, closureClassCount(classNames(src, true)), 'all write forms should pack') + def w = eval(src, 'new W()', true) + assertEquals(6, w.total([1, 2, 3])) + assertEquals(32L, w.lshift([2, 3])) + assertEquals([10, 11, 12], w.olds([0, 0])) // c++ yields the OLD value each time, then final c + } + + @Test + void packedClosureDestructuresSingleListArgument() { + // Real closures destructure a single List/Tuple argument across a multi-param signature; + // the shared adapter must preserve that. + String src = '''import groovy.transform.CompileStatic + @CompileStatic + class D { + List pairs(List> ts) { + List out = [] + ts.each { String s, Integer n -> out << "$s=$n".toString() } + out + } + }''' + assertEquals(0, closureClassCount(classNames(src, true))) + assertEquals(['a=1', 'b=2'], + eval(src, 'new D().pairs([Tuple.tuple("a", 1), Tuple.tuple("b", 2)])', true)) + } + + @Test + void dynamicFreeNameClosuresAreNotAutoPacked() { + // No @CompileStatic AND a free name (the implicit-this call helper(), which the runtime + // resolves through the owner/delegate chain): delegate-independence is unproven, so the + // automatic path must not fire even with the flag on -- only @PackedClosures (trust + + // runtime guard) could pack these. + String src = '''class Dyn { + def helper(x) { x } + List doubled(List xs) { xs.collect { helper(it) * 2 } } + }''' + assertTrue(closureClassCount(classNames(src, true)) >= 1, + 'a dynamic closure with a free name must not be auto-packed') + assertEquals([2, 4, 6], eval(src, 'new Dyn().doubled([1, 2, 3])', true)) + } + + @Test + void fieldBoundNamesAreFreeNamesToo() { + // a bare name bound to an owner FIELD by VariableScopeVisitor is not a DynamicVariable, + // but it is still a free name at runtime: under DELEGATE_FIRST a delegate intercepts it + // (the ClosureResolvingTest contract). The syntactic check must decline it, keeping the + // closure a class so delegate resolution keeps working. + String src = '''class Del { + def foo = 'hello' + } + class Host { + def foo = 'bar' + def bar = 'foo' + def run() { + def c = { foo + bar } + def before = c() + c.resolveStrategy = Closure.DELEGATE_FIRST + c.delegate = new Del() // intercepts foo; bar falls back to the owner + [before, c()] + } + }''' + assertTrue(closureClassCount(classNames(src, true)) >= 1, + 'a closure referencing owner fields must not be auto-packed') + assertEquals(['barfoo', 'hellofoo'], eval(src, 'new Host().run()', true)) + } + + @Test + void dynamicDelegateIndependentClosuresAutoPackBySyntax() { + // GROOVY-12151 dynamic syntactic path: a closure with NO free name -- only its parameter, + // a constant, and a parameter receiver -- cannot be affected by any caller-set delegate, so + // it is delegate-independent by syntax alone (no types needed) and the flag auto-packs it + // even under dynamic compilation. + String src = '''class Dyn { + List doubled(List xs) { xs.collect { it * 2 } } + }''' + assertEquals(0, closureClassCount(classNames(src, true)), + 'a syntactically delegate-independent dynamic closure should auto-pack under the flag') + assertEquals([2, 4, 6], eval(src, 'new Dyn().doubled([1, 2, 3])', true)) + } + + @Test + void syntacticSubsetCoversTheCommonShapes() { + // the everyday delegate-independent shapes: implicit it, captured local, explicit/param + // receiver, multi-arg comparator -- all pack dynamically under the flag with no annotation + String src = '''class Dyn { + def implicitIt(xs) { xs.collect { it * 2 } } + def capture(xs, k) { xs.collect { x -> x + k } } + def paramReceiver(xs){ xs.collect { it.toString().length() } } + def comparator(xs) { xs.sort(false) { a, b -> a <=> b } } + }''' + assertEquals(0, closureClassCount(classNames(src, true)), + 'the common no-free-name shapes should all pack under the flag') + def d = eval(src, 'new Dyn()', true) + assertEquals([2, 4, 6], d.implicitIt([1, 2, 3])) + assertEquals([11, 12], d.capture([1, 2], 10)) + assertEquals([2, 1], d.paramReceiver(['ab', 'c'])) + assertEquals([1, 2, 3], d.comparator([3, 1, 2])) + } + + @Test + void syntacticallyPackedClosureTreatsDelegateAsHarmlessNoOp() { + // the non-strict guard: a syntactically-packed closure has no free name, so a caller-set + // delegate must be IGNORED, not rejected -- exactly as a normal closure behaves. (The + // annotation trust path, by contrast, throws, since it cannot prove independence.) + Object result = eval('', ''' + Closure c = { x -> x + 1 } + assert c.getClass().name.startsWith('org.codehaus.groovy.runtime.PackedClosure') + c.delegate = new Object() + c.resolveStrategy = Closure.DELEGATE_FIRST + c(41) + ''', true) + assertEquals(42, result) + } + + @Test + void dynamicLambdaOfTheSubsetIsPackedToo() { + // a dynamically compiled lambda is compiled as a closure, so the same syntactic path + // body-hoists the equivalent dynamic lambda subset (it just does not reach the + // LambdaMetafactory singleton form, which stays @CompileStatic-only) + String src = '''import java.util.function.Function + class Dyn { + def use() { + Function f = (Integer x) -> x + 1 + f.apply(41) + } + }''' + assertEquals(0, closureClassCount(classNames(src, true)), + 'a syntactically-independent dynamic lambda should auto-pack under the flag') + assertEquals(42, eval(src, 'new Dyn().use()', true)) + } + + @Test + void fixedArityFamilyDisambiguatesSamOverloads() { + // MetaClassHelper's SAM-overload disambiguation reflects the argument CLASS's declared + // doCall to match closure arity against the SAM's method arity (GROOVY-9881) -- it cannot + // consult the instance. The compiler therefore instantiates the fixed-arity family member + // (PackedClosure$FixedN) matching the literal's parameter count, restoring class-level + // arity introspection exactly as on a generated closure class (the ActorTest/DGM + // "Ambiguous method overloading" family). + String src = '''import java.util.function.* + class Api { + static String take(Function f) { 'fn1:' + f.apply(21) } + static String take(BiFunction f) { 'fn2:' + f.apply(20, 1) } + } + class Uses { + def go() { + def c1 = { x -> x * 2 } + def c2 = { a, b -> a + b + 21 } + assert c1.getClass().simpleName == 'Fixed1' + assert c2.getClass().simpleName == 'Fixed2' + [Api.take(c1), Api.take(c2)] + } + }''' + assertEquals(0, closureClassCount(classNames(src, true)), + 'both closures should pack (no free names)') + assertEquals(['fn1:42', 'fn2:42'], eval(src, 'new Uses().go()', true)) + } + + @Test + void intersectionTypedClosuresDecline() { + // a closure cast to an intersection type needs its generated class to declare the marker + // interfaces (StaticTypesClosureWriter#addIntersectionMarkers); the one shared adapter + // cannot do that per-literal, so it stays a class even with the flag on + String src = '''import groovy.transform.CompileStatic + @CompileStatic + class C { Runnable make() { (Runnable & java.io.Serializable) { -> } } }''' + assertTrue(closureClassCount(classNames(src, true)) >= 1, + 'an intersection-typed closure must stay a class') + def r = eval(src, 'new C().make()', true) + assertTrue(r instanceof Runnable && r instanceof Serializable, + 'the closure class must still implement both intersection interfaces') + } +} diff --git a/src/test/groovy/org/codehaus/groovy/transform/PackedClosureBoundariesTest.groovy b/src/test/groovy/org/codehaus/groovy/transform/PackedClosureBoundariesTest.groovy new file mode 100644 index 00000000000..42666fc60ec --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/transform/PackedClosureBoundariesTest.groovy @@ -0,0 +1,129 @@ +/* + * 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 org.codehaus.groovy.transform + +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 static org.junit.jupiter.api.Assertions.assertEquals + +/** + * The packability boundary as one readable matrix — executable documentation of GEP-27's + * "packability decision procedure". Each case is a method body containing one closure literal; + * the assertion is whether that literal packs (no generated closure class) or declines (keeps + * its class), under the flag with {@code @CompileStatic} or dynamic compilation as noted. + * Individual gates have focused behavioural tests elsewhere; this class exists so the whole + * boundary can be read top-to-bottom in one place. + */ +final class PackedClosureBoundariesTest { + + private static final String PROP = CompilerConfiguration.CLOSURE_PACKING + + /** [description, dynamic-packs?, cs-packs?, method body containing exactly one closure literal] */ + private static final List CASES = [ + // ---- the syntactic no-free-name subset: packs everywhere ------------------------------- + ['no free names', true, true, 'def m(List xs) { xs.collect { x -> x + 1 } }'], + ['implicit it', true, true, 'def m(List xs) { xs.collect { it * 2 } }'], + ['explicit zero params', true, true, 'def m() { def c = { -> 42 }; c() }'], + ['captured local (read-only)', true, true, 'def m(List xs) { def k = 10; xs.collect { x -> x + k } }'], + ['captured local (written)', true, true, 'def m(List xs) { int t = 0; xs.each { t += it }; t }'], + ['parameter receiver', true, true, 'def m(List xs) { xs.collect { it.toString() } }'], + // ---- free names: the types-or-trust boundary (dynamic declines, CS proof packs) -------- + ['implicit-this method call', false, true, 'def helper(x) { x }\ndef m(List xs) { xs.collect { helper(it) } }'], + ['bare field-bound name', false, true, 'int field = 1\ndef m(List xs) { xs.collect { it + field } }'], + ['explicit this-property', false, true, 'int field = 1\ndef m(List xs) { xs.collect { it + this.field } }'], + // ---- real-Closure semantics: declines everywhere ---------------------------------------- + ['uses delegate', false, false, 'def m() { def c = { delegate.toString() }; c }'], + ['uses owner', false, false, 'def m() { def c = { owner.toString() }; c }'], + ['default parameter values', false, false, 'def m() { def c = { int x = 1 -> x }; c(2) }'], + // ---- escapes: declines everywhere -------------------------------------------------------- + ['returned', false, false, 'Closure m() { return { it } }'], + ['stored to property', false, false, 'def m(Map attrs) { attrs.handler = { it } }'], + ['in a collection literal', false, false, 'def m() { [{ it }] }'], + // ---- serialization-bound: declines everywhere ------------------------------------------- + ['cast to Serializable', false, false, 'def m() { ({ it } as Serializable) != null }'], + ['local into writeObject', false, false, '''def m() { + def c = { it } + new ObjectOutputStream(new ByteArrayOutputStream()).writeObject(c) + }'''], + // ---- contexts the adapter cannot inhabit: declines everywhere --------------------------- + ['intersection cast', false, false, 'def m() { (Runnable & java.io.Serializable) { -> } }'], + ] + + @Test + void boundaries() { + CASES.each { desc, dynPacks, csPacks, body -> + assertEquals(dynPacks, packs("class C {\n${body}\n}"), + "dynamic: '$desc' should ${dynPacks ? '' : 'NOT '}pack") + assertEquals(csPacks, packs("@groovy.transform.CompileStatic\nclass C {\n${body}\n}"), + "@CompileStatic: '$desc' should ${csPacks ? '' : 'NOT '}pack") + } + } + + @Test + void fieldInitializerDeclines() { + // separate shape: the literal is a class-level field initialiser, not inside a method + assert !packs('class C { def action = { 42 } }') + assert !packs('@groovy.transform.CompileStatic\nclass C { def action = { 42 } }') + } + + @Test + void traitBodyDeclines() { + assert !packs('trait T { def m(List xs) { xs.collect { it * 2 } } }\nclass C implements T {}') + } + + @Test + void constructorSpecialCallDeclines() { + assert !packs('class C { C(Closure c) {}; C() { this({ 42 }) } }') + } + + @Test + void reportPropertySurfacesDeclineReasonsOnTheFlagPath() { + // groovy.target.closure.pack.report=true: every decline on the un-annotated flag path is + // reported as a warning with its reason -- the operational boundary explainer + System.setProperty(CompilerConfiguration.CLOSURE_PACKING_REPORT, 'true') + try { + def cu = compile('class C { Closure m() { return { it } } }') // escapes: returned + def warnings = cu.errorCollector.warnings + assert warnings.any { it.message.contains('closure was not packed') && it.message.contains('escapes') }, + "expected an escape decline warning, got: ${warnings*.message}" + } finally { + System.clearProperty(CompilerConfiguration.CLOSURE_PACKING_REPORT) + } + } + + private static boolean packs(String src) { + compile(src).classes.every { !it.name.contains('_closure') } + } + + private static CompilationUnit compile(String src) { + String previous = System.getProperty(PROP) + System.setProperty(PROP, 'true') + try { + def cu = new CompilationUnit(new CompilerConfiguration()) + cu.addSource('C.groovy', src) + cu.compile(Phases.CLASS_GENERATION) + cu + } finally { + if (previous != null) System.setProperty(PROP, previous) else System.clearProperty(PROP) + } + } +} diff --git a/src/test/groovy/org/codehaus/groovy/transform/PackedClosureDebugMetadataTest.groovy b/src/test/groovy/org/codehaus/groovy/transform/PackedClosureDebugMetadataTest.groovy new file mode 100644 index 00000000000..4dd6c5ebfff --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/transform/PackedClosureDebugMetadataTest.groovy @@ -0,0 +1,165 @@ +/* + * 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 org.codehaus.groovy.transform + +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.assertTrue + +/** + * A packed closure's hoisted body must carry the debug metadata a debugger relies on — + * the packed counterpart of {@code LambdaHoistTest#hoistedLambdaMethodCarriesDebugMetadata}. + * Because the hoisted method reuses the original AST statements, source positions propagate + * unchanged; these tests pin that: a {@code LineNumberTable} entry per body line (line + * breakpoints, single-step and stack frames land on the right source lines) and a + * {@code LocalVariableTable} naming the captured values, the closure parameters (including + * implicit {@code it}) and the body locals (the variables view is complete). The hoisted + * method and all generated dispatch machinery are {@code ACC_SYNTHETIC}, so tools that hide + * synthetics (member lists, Groovydoc, stubs) do not surface them. + */ +final class PackedClosureDebugMetadataTest { + + @Test + void hoistedBodyCarriesDebugMetadataStaticProvenPath() { + // multi-line body, a read-only capture (base), a written capture (total, threaded as a + // shared Reference but named for the debugger), a typed parameter and body locals + String src = '''import groovy.transform.CompileStatic + @CompileStatic + @groovy.transform.PackedClosures + class D { + int sum(List xs, int base) { + int total = 0 + xs.each { Integer x -> + int a = x + base + int b = a * 2 + total += b + } + total + } + }''' + byte[] bytes = compiledBytes(src, 'D') + def method = packedMethods(bytes) + assertTrue(!method.isEmpty(), 'expected a hoisted $packed$closure$ method on D') + def info = debugInfo(bytes, method[0]) + // 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}") + // captures (read-only and written), the typed parameter, and the in-body locals all named + assertTrue(info.locals.containsAll(['base', 'total', 'x', 'a', 'b']), + "expected named locals base/total/x/a/b in LocalVariableTable, got ${info.locals}") + assertTrue(info.synthetic, 'hoisted body should be ACC_SYNTHETIC so tools hide it') + assertTrue(info.priv, 'hoisted body should be private (exact dispatch, no API surface)') + } + + @Test + void hoistedBodyCarriesDebugMetadataDynamicTrustPath() { + // dynamic (untyped) closure with implicit `it`: the debugger must still see `it` by name + String src = '''@groovy.transform.PackedClosures + class E { + def doubles(List xs) { + xs.collect { + def twice = it * 2 + twice + } + } + }''' + byte[] bytes = compiledBytes(src, 'E') + def method = packedMethods(bytes) + assertTrue(!method.isEmpty(), 'expected a hoisted $packed$closure$ method on E') + def info = debugInfo(bytes, method[0]) + assertTrue(info.lineCount >= 2, "expected >=2 line-number entries, got ${info.lineCount}") + assertTrue(info.locals.containsAll(['it', 'twice']), + "expected named locals it/twice in LocalVariableTable, got ${info.locals}") + assertTrue(info.synthetic && info.priv, 'hoisted body should be private ACC_SYNTHETIC') + } + + @Test + void allDispatchMachineryIsSynthetic() { + // the dispatch tables and the bundle accessor are implementation detail: every generated + // member beyond the user's own methods must be ACC_SYNTHETIC so tooling hides it + String src = '''@groovy.transform.PackedClosures + class F { + def a(List xs) { xs.collect { it + 1 } } + def b(List xs) { int s = 0; xs.each { s += it }; s } + }''' + byte[] bytes = compiledBytes(src, 'F') + def nonSynthetic = [] + new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] exc) { + if (name.contains('packed') && (access & Opcodes.ACC_SYNTHETIC) == 0) nonSynthetic << name + return null + } + }, ClassReader.SKIP_CODE) + assertEquals([], nonSynthetic, 'all $packed... machinery should be ACC_SYNTHETIC') + } + + /** Compiles the source with debug info on and returns the named class's bytes. */ + private static byte[] compiledBytes(String src, String className) { + 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 + } + + /** Names of the hoisted {@code $packed$closure$} methods in the class. */ + private static List packedMethods(byte[] bytes) { + List found = [] + new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + MethodVisitor visitMethod(int access, String name, String desc, String sig, String[] exc) { + if (name.startsWith('$packed$closure$')) found << name + return null + } + }, ClassReader.SKIP_CODE) + found + } + + /** LineNumberTable entry count, LocalVariableTable names, synthetic and private flags. */ + private static Map debugInfo(byte[] bytes, String methodName) { + int[] lineCount = [0] + List locals = [] + boolean[] synthetic = [false] + boolean[] priv = [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 + priv[0] = (access & Opcodes.ACC_PRIVATE) != 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], priv: priv[0]] + } +} diff --git a/src/test/groovy/org/codehaus/groovy/transform/PackedClosuresTransformTest.groovy b/src/test/groovy/org/codehaus/groovy/transform/PackedClosuresTransformTest.groovy new file mode 100644 index 00000000000..dfcddc1ecf4 --- /dev/null +++ b/src/test/groovy/org/codehaus/groovy/transform/PackedClosuresTransformTest.groovy @@ -0,0 +1,551 @@ +/* + * 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 org.codehaus.groovy.transform + +import org.codehaus.groovy.control.CompilationUnit +import org.codehaus.groovy.control.CompilerConfiguration +import org.codehaus.groovy.control.MultipleCompilationErrorsException +import org.codehaus.groovy.control.Phases +import org.junit.jupiter.api.Test + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertThrows +import static org.junit.jupiter.api.Assertions.assertTrue + +/** + * Tests for {@link groovy.transform.PackedClosures}: eligible closure literals are + * hoisted into synthetic methods on the enclosing class and replaced by a single shared + * {@code PackedClosure} adapter, so no per-closure inner class is generated. Ineligible + * closures are left exactly as they are today. + */ +final class PackedClosuresTransformTest { + + /** Compiles the source and returns the sorted names of every generated class. */ + private static List generatedClassNames(String src) { + def cu = new CompilationUnit(new CompilerConfiguration()) + cu.addSource('Sample.groovy', src) + cu.compile(Phases.CLASS_GENERATION) + cu.classes.collect { it.name }.sort() + } + + private static int closureClassCount(List names) { + names.count { it.contains('_closure') } + } + + /** Evaluates the source (which must end by yielding an instance) and returns it. */ + private static Object instance(String src) { + new GroovyShell().evaluate(src) + } + + private static final String SAMPLE = ''' + @groovy.transform.PackedClosures + class Sample { + String simple() { [1, 2, 3].collect { it * 2 }.join(',') } + String nested(Map m) { + def out = [] + m.each { k, v -> [1, 2].each { i -> out << "${k}${v}${i}".toString() } } + out.join(',') + } + int readCapture(List xs, int base) { (xs.collect { it + base }.sum()) as int } + } + ''' + + @Test + void eligibleClosuresGenerateNoClosureClasses() { + def names = generatedClassNames(SAMPLE) + // Without @PackedClosures this same source produces 4 closure classes, including the + // deeply-nested Sample$_nested_closure2$_closure4. + assertEquals(['Sample'], names, "only the owner class should be generated, got: $names") + assertEquals(0, closureClassCount(names)) + } + + @Test + void packedClosuresBehaveIdenticallyToPlainOnes() { + def plain = instance(SAMPLE.replace('@groovy.transform.PackedClosures', '') + '\n new Sample()') + def packed = instance(SAMPLE + '\n new Sample()') + + assertEquals(plain.simple(), packed.simple()) + assertEquals('2,4,6', packed.simple()) + + assertEquals(plain.nested([a: 1, b: 2]), packed.nested([a: 1, b: 2])) + assertEquals('a11,a12,b21,b22', packed.nested([a: 1, b: 2])) // nested closures + captured k, v + + assertEquals(plain.readCapture([10, 20, 30], 5), packed.readCapture([10, 20, 30], 5)) + assertEquals(75, packed.readCapture([10, 20, 30], 5)) // captured 'base' read by value + } + + @Test + void writerModePacksMemoizeAndCapturedWriteButDeclinesDelegate() { + // Each class mixes an always-eligible closure with a second one. In the writer path a + // captured write is packed by threading a Reference, and memoize/curry operate on the shared + // adapter (a real Closure), so those pack too - only a closure that needs a real delegate is + // declined and kept as a class. Behavior is unchanged in every case. + def cases = [ + [name: 'delegate (declines: needs a real delegate)', + keptClasses: 1, + src : '''@groovy.transform.PackedClosures + class X { + def eligible() { [1, 2].collect { it + 1 } } + def ineligible() { def sb = new StringBuilder(); def c = { delegate.append('hi') } + c.delegate = sb; c(); sb.toString() } + }''', + expect: 'hi'], + [name: 'memoize (packs: adapter is a real Closure)', + keptClasses: 0, + src : '''@groovy.transform.PackedClosures + class X { + def eligible() { [1, 2].collect { it + 1 } } + def ineligible() { def c = { int n -> n * n }.memoize(); c(3) + c(3) } + }''', + expect: 18], + [name: 'capturedWrite (packs: Reference-threaded)', + keptClasses: 0, + src : '''@groovy.transform.PackedClosures + class X { + def eligible() { [1, 2].collect { it + 1 } } + def ineligible() { int total = 0; [1, 2, 3].each { total += it }; total } + }''', + expect: 6], + ] + cases.each { c -> + def names = generatedClassNames(c.src) + assertEquals(c.keptClasses, closureClassCount(names), "[${c.name}] closure classes kept: $names") + def obj = instance(c.src + '\n new X()') + assertEquals([2, 3], obj.eligible(), "[${c.name}] eligible closure result") + assertEquals(c.expect, obj.ineligible(), "[${c.name}] second closure result") + } + } + + @Test + void escapingClosuresAreDeclinedAtCompileTime() { + // A packed closure is bound to the owner; a shared adapter fails fast if a delegate is later + // set on it. To avoid that surprise for the clearest cases, a closure that visibly escapes the + // method - stored into a field/property/index, returned, appended, or placed in a collection + // literal - is declined at compile time and kept as a normal closure class. Owner-local, + // non-escaping closures still pack. Behavior is unchanged either way. + String src = '''@groovy.transform.PackedClosures + class E { + Closure field + void toProperty(Map attrs) { attrs.optionValue = { it * 2 } } // property/index store + void toField() { field = { it + 1 } } // field store + Closure returned() { return { it - 1 } } // returned + void appended(List sink) { sink << { it } } // appended + int usesEach(List xs) { int t = 0; xs.each { t += it }; t } // owner-local -> packs + List maps(List xs) { xs.collect { it * 3 } } // owner-local -> packs + }''' + def names = generatedClassNames(src) + // the four escaping closures are kept as classes; the two owner-local ones are packed away + assertEquals(4, closureClassCount(names), "escaping closures should be declined: $names") + + def e = instance(src + '\n new E()') + def attrs = [:]; e.toProperty(attrs) + assertEquals(42, attrs.optionValue(21)) // declined closure still works + assertEquals(9, e.returned()(10)) + assertEquals(6, e.usesEach([1, 2, 3])) // packed closure still works + assertEquals([3, 6, 9], e.maps([1, 2, 3])) + } + + /** + * Under {@code @CompileStatic} the transform applies a cast-to-context heuristic: the hoisted + * method is marked {@code @CompileStatic(SKIP)} (so its Object-typed body is checked dynamically), + * and where a packed result flows into a syntactically-declared target — a typed method return or + * a typed local declaration — the result is cast to that type so static type checking still infers + * correctly (e.g. {@code collect(...)} → {@code List}). This covers a large fraction of + * real usage; the boundary is exercised by {@link #compileStaticBoundaryHasNoDeclaredTarget}. + */ + @Test + void compileStaticPacksResultsWithDeclaredTargets() { + String src = '''import groovy.transform.CompileStatic + @CompileStatic + @groovy.transform.PackedClosures + class S { + List doubled(List xs) { xs.collect { Integer it -> it * 2 } } // implicit-return target + List tag(List xs, String p){ xs.collect { Integer n -> p + n } } + String join(List xs) { List ss = xs.collect { Integer it -> "v$it".toString() }; ss.join(',') } // typed-local target + int total(List xs, int base){ int t = 0; xs.each { t += it + base }; t } // captured write, packed via Reference + }''' + // every closure packs, including the captured-write one (Reference-threaded): no class remains + assertEquals(['S'], generatedClassNames(src)) + + def s = instance(src + '\n new S()') + assertEquals([2, 4, 6], s.doubled([1, 2, 3])) + assertEquals(['x1', 'x2'], s.tag([1, 2], 'x')) + assertEquals('v1,v2,v3', s.join([1, 2, 3])) + assertEquals(36, s.total([1, 2, 3], 10)) + } + + /** + * A packed result passed straight into a typed argument (no syntactic target declaration). The + * writer path infers through the {@code collect(...)} call, so this shape packs and behaves + * correctly. It is kept as a regression guard: the general {@code @CompileStatic} soundness story + * (reading already-computed inferred types rather than a cast-to-context heuristic) is still the + * Groovy 7 work per GEP-27, but this common no-declared-target shape must not regress into a + * compile error. + */ + @Test + void compileStaticPacksResultPassedAsArgument() { + String passedAsArg = '''import groovy.transform.CompileStatic + @CompileStatic + @groovy.transform.PackedClosures + class Z { + int need(List ys) { ys.sum() as int } + int m(List xs) { need(xs.collect { Integer it -> it * 2 }) } + }''' + assertEquals(['Z'], generatedClassNames(passedAsArg)) // closure packs, no class remains + assertEquals(12, instance(passedAsArg + '\n new Z()').m([1, 2, 3])) + } + + /** + * The non-{@code @CompileStatic} counterpart to the two {@code compileStatic*} tests above (the + * {@code SAMPLE}-based tests already cover dynamic compilation generally). The same owner-local + * closure shapes — a returned result, a result passed straight as an argument, and a captured + * write — also pack under dynamic compilation via the {@code @PackedClosures} trust path, leaving + * no closure class and behaving identically. + */ + @Test + void dynamicCompilationPacksTheSameClosureShapes() { + String src = '''@groovy.transform.PackedClosures + class D { + def doubled(List xs) { xs.collect { it * 2 } } // result returned + int need(List ys) { ys.sum() } + int passedAsArg(List xs) { need(xs.collect { it * 2 }) } // result passed as arg + int total(List xs, int base) { int t = 0; xs.each { t += it + base }; t } // captured write + }''' + assertEquals(['D'], generatedClassNames(src), 'every closure should pack: no class remains') + def d = instance(src + '\n new D()') + assertEquals([2, 4, 6], d.doubled([1, 2, 3])) + assertEquals(12, d.passedAsArg([1, 2, 3])) + assertEquals(36, d.total([1, 2, 3], 10)) + } + + @Test + void inheritedPackedClosureResolvesToItsOwnDeclaringClass() { + // A superclass's packed closure must not dispatch to a same-named hoisted method that a + // subclass happens to declare (GROOVY-12151 review): the adapter's MethodHandle is an + // invokespecial constant bound to the exact private method, so it is invoked exactly, not + // virtually. Both classes' first packed closure is $packed$closure$0, so a naive by-name + // resolution on the runtime class would run B's body for A's closure. + String src = '''@groovy.transform.PackedClosures + class A { def foo() { [1].collect { it + 1 } } } + @groovy.transform.PackedClosures + class B extends A { def bar() { [1].collect { it + 100 } } }''' + def b = instance(src + '\n new B()') + assertEquals([2], b.foo()) // A's closure body (it + 1), not B's (it + 100) + assertEquals([101], b.bar()) + } + + @Test + void capturedLocalShadowingAFieldIsReboundToTheLocalNotTheField() { + // The captured 'base' is a local that shadows the field of the same name (the one form of + // shadowing Groovy allows). Rebinding matches the captured variable by identity, so the + // hoisted method reads the captured local, not the field. + String src = '''@groovy.transform.PackedClosures + class C { + int base = 999 + List m() { int base = 100; [1, 2].collect { it + base } } + }''' + assertEquals(['C'], generatedClassNames(src)) // the closure packs (no closure class) + assertEquals([101, 102], instance(src + '\n new C()').m()) // captured local 100, not field 999 + } + + /** Source with one closure that packs and one that declines (a with{} delegate DSL), at the given mode. */ + private static String modeSrc(String mode) { + """import groovy.transform.CompileStatic + import groovy.transform.PackedClosures + import groovy.transform.PackedClosures.PackMode + @CompileStatic @PackedClosures(mode = PackMode.$mode) + class M { + List ok(List xs) { xs.collect { String s -> s.toUpperCase() } } + String dsl() { new StringBuilder().with { append('x'); toString() } } + }""" + } + + /** Compiles the source and returns its collected warnings, or throws on a compile error. */ + private static List warnings(String src) { + def cu = new CompilationUnit(new CompilerConfiguration()) + cu.addSource('M.groovy', src) + cu.compile(Phases.CLASS_GENERATION) + cu.errorCollector.warnings ?: [] + } + + @Test + void modeLenientIsSilent() { + assertEquals(0, warnings(modeSrc('LENIENT')).size(), 'LENIENT must not warn on a decline') + } + + @Test + void modeWarnReportsEachDeclineWithReason() { + def ws = warnings(modeSrc('WARN')) + assertEquals(1, ws.size(), "expected one warning for the declined with{} closure, got: ${ws*.message}") + assertTrue(ws[0].message.contains('not packed') && ws[0].message.contains('delegate'), + "warning should name the reason: ${ws[0].message}") + } + + @Test + void modeStrictFailsCompilationOnADecline() { + def e = assertThrows(MultipleCompilationErrorsException) { warnings(modeSrc('STRICT')) } + assertTrue(e.message.contains('not packed'), "error should explain the decline: $e.message") + } + + @Test + void modeAppliesOnlyToTheAnnotation_flagPathStaysLenient() { + // same declining closure, but driven by the flag with no annotation -> no diagnostics + String src = '''import groovy.transform.CompileStatic + @CompileStatic + class F { String dsl() { new StringBuilder().with { append('x'); toString() } } }''' + String prev = System.getProperty(CompilerConfiguration.CLOSURE_PACKING) + System.setProperty(CompilerConfiguration.CLOSURE_PACKING, 'true') + try { + assertEquals(0, warnings(src).size(), 'the automatic flag path must stay lenient (no warnings)') + } finally { + if (prev != null) System.setProperty(CompilerConfiguration.CLOSURE_PACKING, prev) + else System.clearProperty(CompilerConfiguration.CLOSURE_PACKING) + } + } + + @Test + void modeDisabledMethodOptsOutOfAPackedClass() { + // the class opts in, but the DISABLED method is excluded -- most-specific wins + String src = '''import groovy.transform.CompileStatic + import groovy.transform.PackedClosures + import groovy.transform.PackedClosures.PackMode + @CompileStatic @PackedClosures + class C { + List a(List xs) { xs.collect { String s -> s.toUpperCase() } } + @PackedClosures(mode = PackMode.DISABLED) + List b(List xs) { xs.collect { String s -> s.toLowerCase() } } + }''' + // a() packs (no class); only b()'s closure remains + assertEquals(1, closureClassCount(generatedClassNames(src)), + 'only the DISABLED method should keep its closure class') + def c = instance(src + '\n new C()') + assertEquals(['X'], c.a(['x'])) + assertEquals(['x'], c.b(['X'])) + } + + @Test + void modeDisabledOverridesTheAutomaticFlag() { + String src = '''import groovy.transform.CompileStatic + import groovy.transform.PackedClosures + import groovy.transform.PackedClosures.PackMode + @CompileStatic @PackedClosures(mode = PackMode.DISABLED) + class C { List a(List xs) { xs.collect { String s -> s.toUpperCase() } } }''' + String prev = System.getProperty(CompilerConfiguration.CLOSURE_PACKING) + System.setProperty(CompilerConfiguration.CLOSURE_PACKING, 'true') + try { + assertEquals(1, closureClassCount(generatedClassNames(src)), + 'DISABLED must override the flag: the closure stays a class') + } finally { + if (prev != null) System.setProperty(CompilerConfiguration.CLOSURE_PACKING, prev) + else System.clearProperty(CompilerConfiguration.CLOSURE_PACKING) + } + } + + @Test + void dispatchLaneRouting() { + // One shape per dispatch lane: the array-free per-arity tables (dispatch1 for one value + // beyond the receiver, dispatch2 for two — captures count as values) and the general + // array table for everything else. The adapter routes by captured-plus-argument count; + // a mis-route lands on a table's default case, which throws GroovyBugError — so correct + // results ARE the routing assertion. + def c = instance(''' + @groovy.transform.PackedClosures + class Lanes { + def a1c0(List xs) { xs.collect { it * 2 } } // dispatch1 + def a1c1(List xs, int b) { xs.collect { it + b } } // dispatch2 (read capture) + def a1w1(List xs) { int s = 0; xs.each { s += it }; s } // dispatch2 (written capture: Reference) + def a1c2(List xs, int b, int f) { xs.collect { (it + b) * f } } // array (three values) + def a2c0(Map m) { def out = []; m.each { k, v -> out << "$k$v".toString() }; out } // dispatch2 + def a2c1(Map m, String p) { def out = []; m.each { k, v -> out << "$p$k$v".toString() }; out } // array + def a0c0() { def cl = { -> 42 }; cl() } // array (receiver only) + def a3c0(Map m) { m.inject(0) { acc, k, v -> acc + v } } // array (three values) + static a1static(List xs) { xs.collect { it + 1 } } // dispatch1, Class owner + } + new Lanes() + ''') + assertEquals([2, 4, 6], c.a1c0([1, 2, 3])) + assertEquals([11, 12], c.a1c1([1, 2], 10)) + assertEquals(6, c.a1w1([1, 2, 3])) + assertEquals([22, 24], c.a1c2([1, 2], 10, 2)) + assertEquals(['a1', 'b2'], c.a2c0([a: 1, b: 2])) + assertEquals(['-a1', '-b2'], c.a2c1([a: 1, b: 2], '-')) + assertEquals(42, c.a0c0()) + assertEquals(3, c.a3c0([a: 1, b: 2])) + assertEquals([2, 3], c.class.a1static([1, 2])) + } + + @Test + void chunkedArityTablesRouteAllIds() { + // More same-arity targets than one switch may hold (DISPATCH_CHUNK = 8) forces the + // two-level arity table: an entry method selecting the chunk by id range, then a + // per-chunk lookupswitch. Every closure must still reach ITS body — an id landing in + // the wrong chunk or case throws GroovyBugError or returns the wrong offset. + def methods = (0..<10).collect { "def m$it(List xs) { xs.collect { it + $it } }" }.join('\n') + def c = instance(""" + @groovy.transform.PackedClosures + class Chunky { + $methods + } + new Chunky() + """) + (0..<10).each { i -> + assertEquals([i, i + 1], c."m$i"([0, 1]), "closure $i dispatched to the wrong target") + } + } + + @Test + void visiblySerializationBoundClosuresDecline() { + // a literal cast/coerced to a Serializable type, or passed directly to writeObject, is + // visibly serialization-bound: it declines (keeps its class, so serialization works as + // before) while sibling closures still pack; transitive routes stay with the runtime guard + String src = ''' + @groovy.transform.PackedClosures + class Ser implements Serializable { + def direct() { + def baos = new ByteArrayOutputStream() + new ObjectOutputStream(baos).writeObject({ it * 2 }) + baos.size() > 0 + } + def coerced() { ({ it * 3 } as Serializable) != null } + def packedStill(List xs) { xs.collect { it + 1 } } + }''' + def names = generatedClassNames(src) + assertEquals(2, closureClassCount(names), + "the two serialization-bound literals keep their classes: $names") + def s = instance(src + '\n new Ser()') + assertTrue(s.direct()) // classed closure serializes (owner Serializable) + assertTrue(s.coerced()) + assertEquals([2, 3], s.packedStill([1, 2])) // the control closure still packs and works + } + + @Test + void strictModeReportsSerializationBoundDecline() { + String src = '''import groovy.transform.PackedClosures + import groovy.transform.PackedClosures.PackMode + @PackedClosures(mode = PackMode.STRICT) + class X { def m(ObjectOutputStream out) { out.writeObject({ it }) } }''' + def e = assertThrows(MultipleCompilationErrorsException) { generatedClassNames(src) } + assertTrue(e.message.contains('serialization-bound'), e.message) + } + + @Test + void serializationFailsFastWithActionableMessage() { + // A packed closure is not serializable (its dispatch state is bound to hidden classes of + // the hosting module) and this cannot be detected at compile time. Rather than the cryptic + // hidden-class NotSerializableException the default field walk would produce, the adapter + // fails fast naming the hoisted body and the opt-out. The classed escape hatch — + // dehydrate() then serialize — is also pinned: it works for a closure class but cannot + // help a packed closure, which is why the message points at excluding the scope instead. + def result = instance(''' + @groovy.transform.PackedClosures + class S { + def m() { + def c = { it * 2 } + assert c instanceof org.codehaus.groovy.runtime.PackedClosure + try { + new ObjectOutputStream(new ByteArrayOutputStream()).writeObject(c.dehydrate()) + 'serialized' + } catch (java.io.NotSerializableException e) { + e.message + } + } + } + new S().m() + ''') + assertTrue(result.contains('$packed$closure$'), "message should name the hoisted body: $result") + assertTrue(result.contains('PackedClosures'), "message should name the opt-out: $result") + + // contrast: the same closure as a class serializes via the standard dehydrate() route + // (DISABLED opt-out keeps it a class even when the flag is forced on globally) + def classed = instance(''' + @groovy.transform.PackedClosures(mode = groovy.transform.PackedClosures.PackMode.DISABLED) + class T { + def m() { + def c = { it * 2 } + new ObjectOutputStream(new ByteArrayOutputStream()).writeObject(c.dehydrate()) + 'serialized' + } + } + new T().m() + ''') + assertEquals('serialized', classed) + } + + @Test + void closuresInsideTraitsDecline() { + // a closure in a trait moves into a static $Trait$Helper with a synthetic $self receiver, + // a context packed dispatch does not reproduce (it produced invalid bytecode) -- so the + // closure stays a class there, and the trait works normally + String src = ''' + @groovy.transform.PackedClosures + trait T { def doubled(List xs) { xs.collect { it * 2 } } } + class C implements T {} + ''' + assertTrue(closureClassCount(generatedClassNames(src)) >= 1, + 'a closure inside a trait must stay a class') + def c = instance(src + '\n new C()') + assertEquals([2, 4, 6], c.doubled([1, 2, 3])) + } + + @Test + void closuresInSpecialConstructorCallsDecline() { + // a closure passed to this(...)/super(...) is compiled before the enclosing instance is + // initialised (this = uninitializedThis), so it cannot be the packed adapter's owner -- + // packing it emitted invalid bytecode (GROOVY-3831 shape) -- so it stays a class + String src = ''' + @groovy.transform.PackedClosures + class C { + def result + C(Closure c) { result = c() } + C() { this({ 40 + 2 }) } + } + ''' + assertTrue(closureClassCount(generatedClassNames(src)) >= 1, + 'a closure in a this(...) call must stay a class') + assertEquals(42, instance(src + '\n new C()').result) + } + + @Test + void nestedClosureRecapturingReadOnlyCaptureGetsTheSharedReference() { + // a nested closure that stays a class takes the shared groovy.lang.Reference for every + // capture in its constructor; a read-only capture of the PACKED outer must therefore be + // holder-threaded, not by-value (by-value emitted invalid bytecode -- the MainJavadoc + // VerifyError family). The outer still packs; the inner map-literal closures keep their + // classes (escape gate); behaviour and mutation-through-the-shared-holder are identical. + String src = ''' + @groovy.transform.PackedClosures + class N { + def m() { + def items = [1, 2, 3] + def iterable = { [ hasNext:{ !items.isEmpty() }, next:{ items.pop() } ] as Iterator } as Iterable + def n = 0 + for (x in iterable) { n++ } + [n, items.size()] + } + } + ''' + def names = generatedClassNames(src) + assertTrue(names.every { !it.contains('$_m_closure') }, + "the outer closure should pack (no method-named closure class): $names") + // the inner closures keep their classes, named after the hoisted method they now live in + assertTrue(names.count { it.contains('packed') && it.contains('_closure') } >= 2, + "the nested map-value closures keep their classes: $names") + assertEquals([3, 0], instance(src + '\n new N()').m()) + } +} diff --git a/subprojects/groovy-console/src/test/groovy/groovy/console/ui/AstNodeToScriptAdapterTest.groovy b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/AstNodeToScriptAdapterTest.groovy index ad76afd49ff..9d5c25613c2 100644 --- a/subprojects/groovy-console/src/test/groovy/groovy/console/ui/AstNodeToScriptAdapterTest.groovy +++ b/subprojects/groovy-console/src/test/groovy/groovy/console/ui/AstNodeToScriptAdapterTest.groovy @@ -1367,4 +1367,22 @@ b $$v"""/$, // the closure class does not exist yet, so nothing is rendered assert !compileToScript(script, CompilePhase.SEMANTIC_ANALYSIS).contains('/* => ') } + + @Test + void testPackedClosureShowsHoistedMethodInsteadOfClass() { + String script = ''' + @groovy.transform.PackedClosures + class Demo { + def packs() { [1, 2].each { it * 2 } } + def declines() { [1, 2].each { delegate.hashCode() } } + } + ''' + String result = compileToScript(script, CompilePhase.CLASS_GENERATION) + + // a packed closure has no generated class, so it is marked with the hoisted method it became + // (trailing () distinguishes it from a class); a closure that declines packing (here, because + // it uses delegate) keeps its generated closure class and is marked with that name + assert result.contains('/* => Demo.$packed$closure$0() */') + assert result.contains('/* => Demo$_declines_closure1 */') + } } diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ClosurePackBench.java b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ClosurePackBench.java new file mode 100644 index 00000000000..835525d8109 --- /dev/null +++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/bench/ClosurePackBench.java @@ -0,0 +1,171 @@ +/* + * 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 org.apache.groovy.bench; + +import groovy.lang.GroovyClassLoader; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.codehaus.groovy.control.CompilerConfiguration; + +/** + * Steady-state closure invocation under closure packing (GROOVY-12151, + * {@code groovy.target.closure.pack}) versus generated closure classes, for the + * two hot closure shapes: a non-capturing {@code collect} body and a + * written-capture {@code each} accumulator. + *

+ * The flag is read at closure-emission time, so {@link #setup} compiles the + * fixture classes with a fresh {@link GroovyClassLoader} after setting it from + * the {@code pack} param — JMH runs each param value in its own forks, so the + * two states never share a JVM or its profile. A vacuity guard asserts the flag + * really flipped the emission (no {@code _closure} classes when packing, some + * when not). + *

+ * Each shape runs twice: {@code mono} always invokes one fixture class, the + * best case for JIT inlining; {@code mega} rotates over {@link #CLASSES} + * fixture classes, making the shared {@code PackedClosure} adapter's dispatcher + * call site megamorphic (each class has its own hidden dispatcher class) — the + * realistic many-classes regime, and the honest comparison against generated + * closure classes, whose {@code call} sites in DGM are equally megamorphic. + * Single-class microbenches overstate packed dispatch costs (a deep monomorphic + * inline of the shared adapter trips the JIT's {@code InlineSmallCode} + * heuristic); this pair brackets the real-world range instead. + */ +@Warmup(iterations = 5, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(2) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@State(Scope.Benchmark) +public class ClosurePackBench { + + /** Implemented by every compiled fixture class, so invocation needs no reflection. */ + public interface PackWorkload { + Object squared(List xs); + int sum(List xs); + } + + private static final int CLASSES = 16; // power of two: mega rotation uses a mask + + /** Compile-time flag: {@code true} packs eligible closures, {@code false} = closure classes. */ + @Param({"false", "true"}) + public String pack; + + private final PackWorkload[] fixtures = new PackWorkload[CLASSES]; + private List xs; + private int idx; + private String priorPackFlag; + + @Setup(Level.Trial) + public void setup() throws Exception { + priorPackFlag = System.getProperty(CompilerConfiguration.CLOSURE_PACKING); + System.setProperty(CompilerConfiguration.CLOSURE_PACKING, pack); + try (GroovyClassLoader loader = new GroovyClassLoader(getClass().getClassLoader())) { + for (int i = 0; i < CLASSES; i += 1) { + String source = + "@groovy.transform.CompileStatic\n" + + "class Fix" + i + " implements org.apache.groovy.bench.ClosurePackBench.PackWorkload {\n" + + " Object squared(List xs) { xs.collect { it * it } }\n" + + " int sum(List xs) { int s = 0; xs.each { Integer x -> s += x }; s }\n" + + "}\n"; + Class c = loader.parseClass(source, "Fix" + i + ".groovy"); + fixtures[i] = (PackWorkload) c.getDeclaredConstructor().newInstance(); + } + // vacuity guard: the flag must actually gate the emission + boolean closureClasses = false; + for (Class c : loader.getLoadedClasses()) { + if (c.getName().contains("_closure")) closureClasses = true; + } + if (Boolean.parseBoolean(pack) == closureClasses) { + throw new IllegalStateException("closure.pack=" + pack + " but closure classes " + + (closureClasses ? "were" : "were not") + " generated"); + } + } + xs = new ArrayList<>(); + for (int i = 1; i <= 12; i += 1) xs.add(i); + } + + @TearDown(Level.Trial) + public void tearDown() { + // Restore the flag to its prior value (not just clear it). With forking on (the default) + // each trial owns its JVM, but in an unforked or multi-benchmark run several benchmarks + // share a JVM, so restoring rather than clearing keeps one trial's pack setting from + // leaking into another's compilation and skewing results. + if (priorPackFlag != null) { + System.setProperty(CompilerConfiguration.CLOSURE_PACKING, priorPackFlag); + } else { + System.clearProperty(CompilerConfiguration.CLOSURE_PACKING); + } + } + + private int next() { + idx = (idx + 1) & (CLASSES - 1); + return idx; + } + + /** + * Non-capturing {@code collect} body, one fixture class (monomorphic best case). + * @return the collected list + */ + @Benchmark + public Object squared_mono() { + return fixtures[0].squared(xs); + } + + /** + * Non-capturing {@code collect} body over rotating fixture classes (megamorphic). + * @return the collected list + */ + @Benchmark + public Object squared_mega() { + return fixtures[next()].squared(xs); + } + + /** + * Written-capture {@code each} accumulator, one fixture class (monomorphic best case). + * @return the accumulated sum + */ + @Benchmark + public int sum_mono() { + return fixtures[0].sum(xs); + } + + /** + * Written-capture {@code each} accumulator over rotating fixture classes (megamorphic). + * @return the accumulated sum + */ + @Benchmark + public int sum_mega() { + return fixtures[next()].sum(xs); + } +}