This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
The following commit(s) were added to refs/heads/master by this push:
new 8a51ac0dc0 GROOVY-12165: extend Closure.call fast path to
multi-argument overrides
8a51ac0dc0 is described below
commit 8a51ac0dc00d4e2c05a57e8557ad1fae9cfe6697
Author: Paul King <[email protected]>
AuthorDate: Tue Jul 14 20:30:20 2026 +1000
GROOVY-12165: extend Closure.call fast path to multi-argument overrides
The cached fast path covered only zero- and one-argument call overrides
(GROOVY-11911, GROOVY-12164), so every multi-argument closure call from
Java -- all Map iteration, eachWithIndex, inject -- took full metaclass
dispatch. Generalise the cache to an arity-indexed table (0..4) with
per-argument instance-of guards: at each arity an all-Object override
wins outright (the 11911 rule), otherwise the single unambiguous typed
override dispatches when every argument is already an instance of its
declared type (the 12164 rule), and anything else (coercion, null,
same-arity overloads, static declarations, array params) falls through
to the metaclass exactly as before. Map#each is ~3-3.6x faster, inject
~2x, eachWithIndex ~1.65x; one-arg calls also gain ~13% by spreading
the argument array directly instead of re-wrapping it.
GROOVY-12165: key the cache on doCall and guard it on the stock metaclass
Per the closure-dispatch design discussion: call selects a doCall, so
the
arity-indexed cache is re-keyed from call overloads to the subclass's
doCall declarations (hierarchy walk, most-derived override wins, vararg/
static/bridge shapes and same-arity overloads decline to the metaclass).
Declared call()/call(Object) overrides keep per-arity precedence
(GROOVY-11911) -- DGM's closure.call(item) reaches such an override via
plain virtual dispatch, so the varargs entry must agree -- while typed
or
multi-arg call overloads are no longer honoured, restoring 3.x-5.x
behaviour (the ecosystem census found zero reliance: every real subclass
either overrides call(Object...) or declares doCall). MethodClosure and
CurriedClosure are excluded: MetaClassImpl has dedicated dispatch for
them, so their doCall declarations are not what the MOP would select.
The cache is now also semantically invisible: a mopPerturbed flag
(checked at construction, latched by setMetaClass -- the only mutation
path for the instance's metaclass field) plus the category quick-exit
gate the fast path, so a per-instance metaclass or active category
routes through invokeMethod as it did before the cache existed. The
re-entry latch is scoped to the 11911 call-form targets only: doCall
targets skip the ThreadLocal entirely, so nested closure calls inside a
dispatched body keep their own fast path (sum-style shapes gain ~50%).
Also keeps the GString-key coercion test honest (Copilot review): the
key stays a GString so the guard genuinely fails and the metaclass
coercion path is exercised.
---
src/main/java/groovy/lang/Closure.java | 290 +++++++++++++++------
src/test/groovy/bugs/Groovy12164.groovy | 18 +-
src/test/groovy/bugs/Groovy12165.groovy | 153 +++++++++++
.../groovy/runtime/ClosureCallDoCallKeyTest.groovy | 130 +++++++++
.../groovy/runtime/ClosureCallMopGuardTest.groovy | 98 +++++++
5 files changed, 600 insertions(+), 89 deletions(-)
diff --git a/src/main/java/groovy/lang/Closure.java
b/src/main/java/groovy/lang/Closure.java
index cde84c068d..000f8b595e 100644
--- a/src/main/java/groovy/lang/Closure.java
+++ b/src/main/java/groovy/lang/Closure.java
@@ -45,6 +45,7 @@ import java.lang.reflect.Modifier;
import java.util.ArrayDeque;
import java.util.Collections;
import java.util.Deque;
+import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Set;
@@ -536,35 +537,48 @@ public abstract class Closure<V> extends
GroovyObjectSupport implements Cloneabl
@SuppressWarnings("unchecked")
public V call(final Object... arguments) {
Class<?> myClass = getClass();
- if (myClass != Closure.class && arguments != null &&
!IN_CALL_FALLBACK.get()) {
+ if (myClass != Closure.class && arguments != null && mopUnperturbed())
{
CallOverride override = CALL_OVERRIDES.get(myClass);
+ // call selects a doCall: the cache is keyed on the subclass's
doCall declarations,
+ // arity-indexed (GROOVY-12165) so the shapes the GDK drives hard
(Map#each,
+ // eachWithIndex, inject) dispatch directly. An argument that is
already an instance
+ // of each declared parameter type dispatches directly; anything
that needs Groovy
+ // coercion (GString -> String, number conversions, null) falls
through to the
+ // metaclass below, which coerces exactly as before
(GROOVY-12164). A subclass with
+ // no doCall keeps the GROOVY-11911 call()/call(Object)
compatibility carve-out,
+ // whose targets alone need the re-entry latch (a custom call
override may delegate
+ // back to call(...), which must not re-dispatch to the same
target).
Method target = null;
- if (arguments.length == 1 && override.oneArg != null) {
- target = override.oneArg;
- } else if (arguments.length == 1 && override.oneArgTyped != null
&& override.oneArgGuard.isInstance(arguments[0])) {
- // GROOVY-12164: a closure with a typed parameter declares
call(T)/doCall(T) rather
- // than call(Object), which the lookup above cannot see --
without this branch every
- // such call pays full metaclass dispatch. An argument that is
already an instance of
- // the declared type dispatches directly; anything that needs
Groovy coercion
- // (GString -> String, number conversions, null) falls through
to the metaclass below,
- // which coerces exactly as before.
- target = override.oneArgTyped;
- } else if (arguments.length == 0 && override.zeroArg != null) {
- target = override.zeroArg;
+ int arity = arguments.length;
+ if (arity < CallOverride.ARITY_LIMIT) {
+ Method m = override.byArity[arity];
+ if (m != null &&
CallOverride.guardsPass(override.guards[arity], arguments)) {
+ target = m;
+ }
}
if (target != null) {
- IN_CALL_FALLBACK.set(Boolean.TRUE);
- try {
- return (V) (arguments.length == 0
- ? target.invoke(this)
- : target.invoke(this, arguments[0]));
- } catch (InvocationTargetException ite) {
- UncheckedThrow.rethrow(ite.getCause());
- return null; // unreachable statement
- } catch (IllegalAccessException iae) {
- throw new GroovyRuntimeException(iae);
- } finally {
- IN_CALL_FALLBACK.set(Boolean.FALSE);
+ if (!override.callForm[arity]) {
+ // a doCall body: re-entry is ordinary recursion, no latch
required
+ try {
+ return (V) target.invoke(this, arguments);
+ } catch (InvocationTargetException ite) {
+ UncheckedThrow.rethrow(ite.getCause());
+ return null; // unreachable statement
+ } catch (IllegalAccessException iae) {
+ throw new GroovyRuntimeException(iae);
+ }
+ } else if (!IN_CALL_FALLBACK.get()) {
+ IN_CALL_FALLBACK.set(Boolean.TRUE);
+ try {
+ return (V) target.invoke(this, arguments);
+ } catch (InvocationTargetException ite) {
+ UncheckedThrow.rethrow(ite.getCause());
+ return null; // unreachable statement
+ } catch (IllegalAccessException iae) {
+ throw new GroovyRuntimeException(iae);
+ } finally {
+ IN_CALL_FALLBACK.set(Boolean.FALSE);
+ }
}
}
}
@@ -578,6 +592,39 @@ public abstract class Closure<V> extends
GroovyObjectSupport implements Cloneabl
}
}
+ /**
+ * Whether something MOP-relevant is in play for this instance: a
non-stock metaclass
+ * (replaced, wrapped or Expando — it must see {@code invokeMethod}).
Checked once at
+ * construction and latched by {@link #setMetaClass}, which is the only
way the instance's
+ * metaclass field can change — so reading one boolean here is equivalent
to re-checking
+ * the metaclass class on every call, at a fraction of the cost on the hot
dispatch lanes.
+ */
+ private transient boolean mopPerturbed = nonStockMetaClass();
+
+ 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;
+ }
+
+ @Override
+ public void setMetaClass(final MetaClass metaClass) {
+ super.setMetaClass(metaClass);
+ // conservative: any explicitly assigned metaclass (even a stock one)
routes via the MOP
+ mopPerturbed = true;
+ }
+
+ /**
+ * Whether nothing MOP-relevant is in play for this instance, so a cached
direct
+ * dispatch path is semantically invisible: the metaclass is a stock one
and no
+ * category is active on the current thread — the same conditions the
classic
+ * call-site caches check (see {@code AbstractCallSite}). One boolean load
and one
+ * quick-exit counter read, cheap enough for per-element dispatch lanes.
+ */
+ protected final boolean mopUnperturbed() {
+ return !mopPerturbed &&
!org.codehaus.groovy.runtime.GroovyCategorySupport.hasCategoryInCurrentThread();
+ }
+
/**
* Throws the supplied throwable as a runtime exception, wrapping checked
throwables.
*
@@ -1385,51 +1432,158 @@ public abstract class Closure<V> extends
GroovyObjectSupport implements Cloneabl
private static final class CallOverride {
/**
- * Marker instance indicating that no call override exists.
+ * Cached arities: {@code 0..ARITY_LIMIT-1}. Sized to cover every
callback shape the GDK
+ * drives (iteration and inject/withIndex variants peak at three
parameters, plus slack).
*/
- static final CallOverride NONE = new CallOverride(null, null, null);
+ static final int ARITY_LIMIT = 5;
/**
- * Cached zero-argument call override.
+ * Marker instance indicating that no dispatch targets exist.
*/
- final Method zeroArg;
+ static final CallOverride NONE = new CallOverride(new
Method[ARITY_LIMIT], new Class[ARITY_LIMIT][], new boolean[ARITY_LIMIT]);
/**
- * Cached single-argument call override.
+ * Cached {@code doCall} targets indexed by parameter count, or null
where absent,
+ * ambiguous, or inaccessible — {@code call} selects a {@code doCall},
so the cache is
+ * keyed on the {@code doCall} declarations themselves. At each arity
an all-{@code Object}
+ * doCall wins outright; otherwise the single unambiguous doCall with
declared parameter
+ * types is cached with guards (GROOVY-12164/12165). For a subclass
declaring NO doCall at
+ * all, the GROOVY-11911 compatibility carve-out caches its {@code
call()}/{@code call(Object)}
+ * overrides instead (the historical Java-written adapter shape).
*/
- final Method oneArg;
+ final Method[] byArity;
/**
- * Cached single-argument call override with a declared (non-Object)
parameter type,
- * used only when {@link #oneArg} is absent (GROOVY-12164). A closure
literal with a
- * typed parameter generates {@code call(T)}/{@code doCall(T)} instead
of
- * {@code call(Object)}.
+ * Per-arity instance-of guards for {@link #byArity}: the declared
parameter types
+ * (boxed for primitives), with null entries for {@code Object}
parameters; a wholly
+ * null guard array means no checks at all. An argument that is not
already an instance
+ * of its declared type needs Groovy coercion, which only the
metaclass fallback provides.
*/
- final Method oneArgTyped;
+ final Class<?>[][] guards;
/**
- * Instance-of guard for {@link #oneArgTyped}: the declared parameter
type (boxed for a
- * primitive). An argument that is not already an instance needs
Groovy coercion, which
- * only the metaclass fallback provides.
+ * Which cached targets are {@code call} forms (the 11911 shapes)
rather than doCall
+ * bodies. Only those need the re-entry latch: a custom {@code call}
override may
+ * delegate back to {@code call(...)}, which must not re-dispatch to
the same target.
+ * doCall targets are user bodies — re-entry through them is ordinary
recursion — so
+ * their dispatch skips the ThreadLocal entirely (and nested closure
calls inside a
+ * dispatched body keep their own fast path).
*/
- final Class<?> oneArgGuard;
+ final boolean[] callForm;
- private CallOverride(Method zeroArg, Method oneArg, Method
oneArgTyped) {
- this.zeroArg = zeroArg;
- this.oneArg = oneArg;
- this.oneArgTyped = oneArgTyped;
- this.oneArgGuard = (oneArgTyped == null) ? null :
wrapperOf(oneArgTyped.getParameterTypes()[0]);
+ private CallOverride(Method[] byArity, Class<?>[][] guards, boolean[]
callForm) {
+ this.byArity = byArity;
+ this.guards = guards;
+ this.callForm = callForm;
+ }
+
+ static boolean guardsPass(Class<?>[] guards, Object[] args) {
+ if (guards == null) return true; // all parameters are Object
+ for (int i = 0, n = args.length; i < n; i += 1) {
+ Class<?> g = guards[i];
+ if (g != null && !g.isInstance(args[i])) return false;
+ }
+ return true;
}
/**
- * Finds call overrides declared by the supplied closure subclass.
+ * Finds the subclass's {@code doCall} declarations, one target per
arity — {@code call}
+ * selects a {@code doCall}, so the cache is keyed on the doCall
declarations themselves
+ * and any visibility the MOP would dispatch is accepted (the
hierarchy is walked with
+ * {@code getDeclaredMethods}; an overridden signature keeps its
most-derived form).
+ * Bridge methods are skipped (their erased twin is the real
declaration), as are static
+ * declarations and methods with array-typed parameters (that shape
belongs to vararg
+ * collection, which is metaclass work). Same-arity overloads beyond
an all-Object one
+ * are ambiguous: the metaclass performs the selection.
+ * <p>
+ * Declared {@code call()}/{@code call(Object)} overrides (the
GROOVY-11911 shapes) take
+ * precedence at their arities: DGM's {@code closure.call(item)}
reaches such an override
+ * through plain virtual dispatch anyway, so the varargs entry must
agree, and a
+ * decorating override must not be bypassed. Typed or multi-argument
{@code call}
+ * overloads are NOT honoured — per the {@code Closure} contract, only
doCall carries
+ * the body.
*
* @param type the closure subclass
- * @return the resolved override metadata
+ * @return the resolved dispatch metadata
*/
static CallOverride lookup(Class<?> type) {
if (type == Closure.class) return NONE;
+ // The cache is only valid where it mirrors the MOP's selection.
MetaClassImpl's
+ // invokeMethod has dedicated dispatch for these adapter types (a
method pointer
+ // invokes its target method; a curried closure uncurries and
re-enters), so their
+ // doCall declarations, where present, are NOT what the MOP would
select — e.g.
+ // MethodClosure's vestigial doCall would mis-dispatch a
class-owner method pointer.
+ if
(org.codehaus.groovy.runtime.MethodClosure.class.isAssignableFrom(type)
+ ||
org.codehaus.groovy.runtime.CurriedClosure.class.isAssignableFrom(type)) {
+ return NONE;
+ }
+ Method[] exact = new Method[ARITY_LIMIT];
+ Method[] typed = new Method[ARITY_LIMIT];
+ boolean[] typedAmbiguous = new boolean[ARITY_LIMIT];
+ Set<String> seen = new HashSet<>();
+ for (Class<?> c = type; c != null && c != Closure.class; c =
c.getSuperclass()) {
+ for (Method m : c.getDeclaredMethods()) {
+ if (!"doCall".equals(m.getName()) || m.isBridge() ||
Modifier.isStatic(m.getModifiers())) {
+ continue;
+ }
+ Class<?>[] params = m.getParameterTypes();
+ if (!seen.add(java.util.Arrays.toString(params)))
continue; // overridden below: most-derived wins
+ int arity = m.getParameterCount();
+ if (arity >= ARITY_LIMIT) continue;
+ boolean allObject = true, hasArray = false;
+ for (Class<?> p : params) {
+ if (p != Object.class) allObject = false;
+ if (p.isArray()) hasArray = true;
+ }
+ if (hasArray) continue;
+ if (allObject) {
+ if (exact[arity] == null) exact[arity] = m;
+ } else if (typed[arity] != null) {
+ typedAmbiguous[arity] = true;
+ } else {
+ typed[arity] = m;
+ }
+ }
+ }
+ Method[] byArity = new Method[ARITY_LIMIT];
+ Class<?>[][] guards = new Class[ARITY_LIMIT][];
+ boolean[] callForm = new boolean[ARITY_LIMIT];
+ boolean any = false;
+ // GROOVY-11911: a declared call()/call(Object) override takes
precedence at its
+ // arity even when doCall methods exist — DGM's closure.call(item)
reaches such an
+ // override through plain virtual dispatch anyway, so the varargs
entry must agree
+ // (a decorating override would otherwise be silently bypassed on
one form only).
Method zero = findOverride(type);
+ if (zero != null) {
+ byArity[0] = zero;
+ callForm[0] = true;
+ any = true;
+ }
Method one = findOverride(type, Object.class);
- Method typed = (one == null) ? findTypedOneArgOverride(type) :
null;
- if (zero == null && one == null && typed == null) return NONE;
- return new CallOverride(zero, one, typed);
+ if (one != null) {
+ byArity[1] = one;
+ callForm[1] = true;
+ any = true;
+ }
+ for (int arity = 0; arity < ARITY_LIMIT; arity += 1) {
+ if (byArity[arity] != null) continue; // a call-form override
claimed this arity
+ Method m = (exact[arity] != null) ? exact[arity]
+ : (typedAmbiguous[arity] ? null : typed[arity]);
+ if (m == null) continue;
+ try {
+ m.setAccessible(true);
+ } catch (RuntimeException ignored) {
+ // module/package access denied; fall through to MOP doCall
+ continue;
+ }
+ byArity[arity] = m;
+ if (m != exact[arity]) {
+ Class<?>[] params = m.getParameterTypes();
+ Class<?>[] g = new Class[arity];
+ for (int i = 0; i < arity; i += 1) {
+ g[i] = (params[i] == Object.class) ? null :
wrapperOf(params[i]);
+ }
+ guards[arity] = g;
+ }
+ any = true;
+ }
+ return any ? new CallOverride(byArity, guards, callForm) : NONE;
}
private static Method findOverride(Class<?> type, Class<?>... params) {
@@ -1450,42 +1604,6 @@ public abstract class Closure<V> extends
GroovyObjectSupport implements Cloneabl
}
}
- /**
- * Finds the single unambiguous one-argument {@code call} override
with a declared
- * (non-Object) parameter type (GROOVY-12164). Array-typed parameters
are skipped —
- * that shape belongs to vararg collection, which is metaclass work —
as are bridge
- * methods (their erased twin is the real override) and overloaded
typed overrides
- * (ambiguous: the metaclass performs the selection).
- *
- * @param type the closure subclass
- * @return the override, or null when absent, ambiguous, or
inaccessible
- */
- private static Method findTypedOneArgOverride(Class<?> type) {
- Method candidate = null;
- for (Method m : type.getMethods()) {
- // getMethods() includes public statics: a static call(T) is
not an instance
- // override (reflective invocation would ignore the receiver),
so skip it and
- // let the MOP fallback dispatch instance doCall as before
- if (m.getParameterCount() != 1 || !"call".equals(m.getName())
|| m.isBridge()
- || Modifier.isStatic(m.getModifiers()) ||
m.getDeclaringClass() == Closure.class) {
- continue;
- }
- Class<?> param = m.getParameterTypes()[0];
- if (param == Object.class || param.isArray()) continue;
- if (candidate != null) return null; // overloaded: leave
selection to the metaclass
- candidate = m;
- }
- if (candidate != null) {
- try {
- candidate.setAccessible(true);
- } catch (RuntimeException ignored) {
- // module/package access denied; fall through to MOP doCall
- return null;
- }
- }
- return candidate;
- }
-
private static Class<?> wrapperOf(Class<?> type) {
if (!type.isPrimitive()) return type;
if (type == int.class) return Integer.class;
diff --git a/src/test/groovy/bugs/Groovy12164.groovy
b/src/test/groovy/bugs/Groovy12164.groovy
index a91b80e060..991da6305e 100644
--- a/src/test/groovy/bugs/Groovy12164.groovy
+++ b/src/test/groovy/bugs/Groovy12164.groovy
@@ -136,15 +136,27 @@ final class Groovy12164 {
@Test
void testTypedSubclassOverride() {
- // a Closure subclass with a typed call override is served by the same
cache
+ // call selects a doCall: a subclass declaring only a typed call(T) —
and no doCall —
+ // is NOT dispatched on the Java/GDK entry (the Closure javadoc
requires doCall); this
+ // matches 3.x-5.x behaviour. The dynamic path remains MOP-permissive
and still finds
+ // the declared method (long-standing ClosureMetaClass behaviour).
assertScript '''
+ import groovy.test.GroovyAssert
class Doubler extends Closure<Integer> {
Doubler() { super(null) }
Integer call(Integer x) { x * 2 }
}
Closure<Integer> d = new Doubler()
- assert d.call(21) == 42
- assert [1, 2, 3].collect(d) == [2, 4, 6]
+ assert d.call(21) == 42 // dynamic dispatch: metaclass finds the
declared method
+ GroovyAssert.shouldFail(MissingMethodException) {
+ [1, 2, 3].collect(d) // Java entry: no doCall, no dispatch
+ }
+ // with a doCall the same shape is served by the cache on both
paths
+ class Tripler extends Closure<Integer> {
+ Tripler() { super(null) }
+ Integer doCall(Integer x) { x * 3 }
+ }
+ assert [1, 2].collect(new Tripler()) == [3, 6]
'''
}
diff --git a/src/test/groovy/bugs/Groovy12165.groovy
b/src/test/groovy/bugs/Groovy12165.groovy
new file mode 100644
index 0000000000..20af501c92
--- /dev/null
+++ b/src/test/groovy/bugs/Groovy12165.groovy
@@ -0,0 +1,153 @@
+/*
+ * 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 bugs
+
+import org.junit.jupiter.api.Test
+
+import static groovy.test.GroovyAssert.assertScript
+
+/**
+ * {@code Closure.call}'s cached fast path covered only zero- and one-argument
+ * overrides (GROOVY-11911, GROOVY-12164), so every multi-argument closure call
+ * from Java — all {@code Map} iteration, {@code eachWithIndex}, {@code inject}
+ * — took full metaclass dispatch. GROOVY-12165 generalises the cache to an
+ * arity-indexed table (0..4) with per-argument instance-of guards: at each
+ * arity an all-{@code Object} override wins outright, otherwise the single
+ * unambiguous typed override dispatches when every argument is already an
+ * instance of its declared type, and anything else (coercion, null, ambiguity)
+ * falls through to the metaclass exactly as before. These tests pin both sides
+ * of the guards at the new arities; iteration goes through DGM, whose Java
+ * code invokes {@code closure.call(...)}, the path the cache serves.
+ */
+final class Groovy12165 {
+
+ @Test
+ void testMapIterationUntyped() {
+ assertScript '''
+ def m = [a: 1, b: 2, c: 3]
+ int sum = 0
+ def keys = []
+ m.each { k, v -> keys << k; sum += (int) v }
+ assert keys == ['a', 'b', 'c']
+ assert sum == 6
+ assert m.collect { k, v -> "$k$v".toString() } == ['a1', 'b2',
'c3']
+ '''
+ }
+
+ @Test
+ void testMapIterationTyped() {
+ assertScript '''
+ def m = [a: 1, b: 2]
+ int sum = 0
+ m.each { String k, Integer v -> sum += v }
+ assert sum == 3
+ assert m.any { String k, Integer v -> v == 2 }
+ assert m.every { String k, Integer v -> v > 0 }
+ '''
+ }
+
+ @Test
+ void testEachWithIndexAndInject() {
+ assertScript '''
+ def seen = []
+ ['a', 'b'].eachWithIndex { x, i -> seen << "$i:$x".toString() }
+ assert seen == ['0:a', '1:b']
+ assert [1, 2, 3, 4].inject(0) { a, x -> (int) a + (int) x } == 10
+ assert [1, 2, 3].inject(1) { Integer a, Integer x -> a * x } == 6
+ '''
+ }
+
+ @Test
+ void testThreeArgumentClosure() {
+ assertScript '''
+ // Map#inject drives a three-parameter closure with (acc, key,
value) from Java
+ assert [a: 1, b: 2, c: 3].inject(0) { acc, k, v -> (int) acc +
(int) v } == 6
+ assert [a: 1, b: 2].inject(0) { Integer acc, String k, Integer v
-> acc + v } == 3
+ '''
+ }
+
+ @Test
+ void testTypedGuardFallsThroughToCoercion() {
+ assertScript '''
+ // a GString key is NOT an instance of the String parameter type:
the guard fails,
+ // the call falls through to the metaclass, and its coercion
produces a real String
+ def name = 'a'
+ def m = [("k${name}"): 1]
+ assert m.keySet()[0] !instanceof String // premise: the key stays
a GString
+ def out = []
+ m.each { String k, Integer v -> assert k.class == String; out <<
"$k=$v".toString() }
+ assert out == ['ka=1']
+ // null value is not an instance of Integer: metaclass path,
arrives as null
+ def m2 = [x: null]
+ def seen = []
+ m2.each { String k, Integer v -> seen << v }
+ assert seen == [null]
+ '''
+ }
+
+ @Test
+ void testDefaultParametersPopulateMultipleArities() {
+ assertScript '''
+ def c = { Integer x, Integer y = 10, Integer z = 100 -> x + y + z }
+ assert c.call(1) == 111
+ assert c.call(1, 2) == 103
+ assert c.call(1, 2, 3) == 6
+ '''
+ }
+
+ @Test
+ void testSameArityOverloadsStayOnMetaclass() {
+ // two typed two-arg overrides are ambiguous: the cache must decline
and the metaclass
+ // fallback dispatches doCall — Map#collect drives call(k, v) from Java
+ assertScript '''
+ class Picky extends Closure<String> {
+ Picky() { super(null) }
+ String call(Integer a, Integer b) { 'ints' }
+ String call(String a, String b) { 'strings' }
+ String doCall(Object a, Object b) { 'doCall' }
+ }
+ assert [(1): 2].collect(new Picky()) == ['doCall']
+ '''
+ }
+
+ @Test
+ void testStaticMultiArgCallDeclarationIsNotAnOverride() {
+ // a public static call(T, T) is not an instance override: dispatch
keeps going
+ // through the metaclass to instance doCall (same rule as one-arg,
GROOVY-12164);
+ // Map#collect drives the two-arg call(k, v) entry point from Java
+ assertScript '''
+ class TwoStatic extends Closure<String> {
+ TwoStatic() { super(null) }
+ static String call(Integer a, Integer b) { 'static' }
+ String doCall(Integer a, Integer b) { 'instance' }
+ }
+ assert [(1): 2].collect(new TwoStatic()) == ['instance']
+ '''
+ }
+
+ @Test
+ void testMultiParameterDestructuringPreserved() {
+ // a single List argument to a two-parameter closure still destructures
+ assertScript '''
+ def seen = []
+ [[1, 'a'], [2, 'b']].each { Integer n, String s -> seen <<
"$n$s".toString() }
+ assert seen == ['1a', '2b']
+ '''
+ }
+}
diff --git
a/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallDoCallKeyTest.groovy
b/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallDoCallKeyTest.groovy
new file mode 100644
index 0000000000..34aa41d54a
--- /dev/null
+++
b/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallDoCallKeyTest.groovy
@@ -0,0 +1,130 @@
+/*
+ * 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
+
+/**
+ * {@code Closure.call}'s cached fast path is keyed on the subclass's {@code
doCall}
+ * declarations — "call selects a doCall" — rather than on {@code call}
overloads,
+ * with the GROOVY-11911 {@code call()}/{@code call(Object)} carve-out kept for
+ * subclasses declaring no doCall at all. These tests pin the doCall keying
for the
+ * shapes the ecosystem actually writes (a hand-written subclass with a typed
doCall
+ * is the Gradle KotlinClosure0-3 pattern), the hierarchy walk, the vararg
exclusion,
+ * ambiguity, and that nested closure calls inside a dispatched body keep
their own
+ * fast path (the re-entry latch applies only to carve-out targets).
+ */
+final class ClosureCallDoCallKeyTest {
+
+ @Test
+ void typedDoCallAdapterIsServed() {
+ // the KotlinClosure1 shape: hand-written subclass, typed doCall, no
call overloads
+ assertScript '''
+ class Adapter extends Closure<Integer> {
+ Adapter() { super(null) }
+ Integer doCall(Integer x) { x + 100 }
+ }
+ def a = new Adapter()
+ assert [1, 2].collect(a) == [101, 102] // Java/GDK entry
+ assert a(5) == 105 // dynamic entry
+ // guard fall-through: a non-Integer argument declines to the
metaclass,
+ // which rejects it exactly as before
+ groovy.test.GroovyAssert.shouldFail(MissingMethodException) {
+ ['x'].collect(a)
+ }
+ '''
+ }
+
+ @Test
+ void protectedDoCallIsServed() {
+ // the MOP dispatches protected doCall declarations, so the cache must
too
+ assertScript '''
+ class Guarded extends Closure<String> {
+ Guarded() { super(null) }
+ protected String doCall(String s) { 'p:' + s }
+ }
+ assert ['a', 'b'].collect(new Guarded()) == ['p:a', 'p:b']
+ '''
+ }
+
+ @Test
+ void varargDoCallStaysOnMetaclass() {
+ // the Geb InvocationForwarding shape: an array-typed parameter is
vararg
+ // collection, which stays metaclass work — behaviour unchanged either
way
+ assertScript '''
+ class Fwd extends Closure<Object> {
+ Fwd() { super(null) }
+ protected doCall(Object[] args) { args.toList() }
+ }
+ def f = new Fwd()
+ assert f.call(1, 2) == [1, 2]
+ assert [7].collect(f) == [[7]]
+ '''
+ }
+
+ @Test
+ void overriddenDoCallDispatchesMostDerived() {
+ // the hierarchy walk dedupes by signature: an override wins over its
parent
+ assertScript '''
+ class Base extends Closure<String> {
+ Base() { super(null) }
+ String doCall(Integer x) { 'base:' + x }
+ }
+ class Derived extends Base {
+ @Override String doCall(Integer x) { 'derived:' + x }
+ }
+ assert [1].collect(new Derived()) == ['derived:1']
+ assert [1].collect(new Base()) == ['base:1']
+ '''
+ }
+
+ @Test
+ void ambiguousSameArityDoCallsDeclineToMetaclass() {
+ // two typed same-arity doCalls: selection is the metaclass's job;
results must
+ // match dynamic dispatch
+ assertScript '''
+ class Picky extends Closure<String> {
+ Picky() { super(null) }
+ String doCall(Integer x) { 'int' }
+ String doCall(String x) { 'str' }
+ }
+ def p = new Picky()
+ assert [1].collect(p) == ['int']
+ assert ['a'].collect(p) == ['str']
+ '''
+ }
+
+ @Test
+ void nestedClosureCallsInsideDispatchedBodyKeepWorking() {
+ // the re-entry latch applies only to 11911 carve-out targets: a
doCall body that
+ // itself drives closures through the GDK must behave identically (and
keeps the
+ // fast path — previously the thread-global latch silently disabled it)
+ assertScript '''
+ def inner = { it * 2 }
+ def outer = { List xs -> xs.collect(inner).sum() }
+ assert [[1, 2, 3]].collect(outer) == [12]
+ // recursion through the cache is ordinary recursion
+ def fact
+ fact = { int n -> n <= 1 ? 1 : n * fact(n - 1) }
+ assert fact(5) == 120
+ '''
+ }
+}
diff --git
a/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallMopGuardTest.groovy
b/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallMopGuardTest.groovy
new file mode 100644
index 0000000000..1dcbb1df2f
--- /dev/null
+++ b/src/test/groovy/org/codehaus/groovy/runtime/ClosureCallMopGuardTest.groovy
@@ -0,0 +1,98 @@
+/*
+ * 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
+
+/**
+ * {@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.
+ */
+final class ClosureCallMopGuardTest {
+
+ private static final String INTERCEPTOR = '''
+ def intercept = { Closure target ->
+ def mc = new DelegatingMetaClass(target.metaClass) {
+ Object invokeMethod(Object object, String methodName, Object[]
args) {
+ if (methodName == 'doCall' || methodName == 'call') return
'intercepted'
+ super.invokeMethod(object, methodName, args)
+ }
+ }
+ mc.initialize()
+ target.metaClass = mc
+ target
+ }
+ '''
+
+ @Test
+ void perInstanceMetaclassInterceptsStaticallyCompiledClosureOnGdkPath() {
+ assertScript INTERCEPTOR + '''
+ import groovy.transform.CompileStatic
+ @CompileStatic
+ class D { Closure maker() { return { Integer x -> x * 2 } } }
+ def c = new D().maker()
+ assert [3].collect(c) == [6] // unperturbed: fast path,
real body
+ intercept(c)
+ assert c(3) == 'intercepted' // dynamic path
+ assert [3].collect(c) == ['intercepted'] // Java/GDK path: the
guard must route via the MOP
+ '''
+ }
+
+ @Test
+ void unperturbedInstancesAreUnaffectedByAnotherInstancesMetaclass() {
+ assertScript INTERCEPTOR + '''
+ import groovy.transform.CompileStatic
+ @CompileStatic
+ class D2 { Closure maker() { return { Integer x -> x + 1 } } }
+ def a = new D2().maker()
+ def b = new D2().maker()
+ intercept(a)
+ assert [1].collect(a) == ['intercepted']
+ assert [1].collect(b) == [2] // b keeps the fast path
+ '''
+ }
+
+ @Test
+ void behaviourInsideCategoryBlocksIsPreserved() {
+ // A category on the current thread conservatively disables the direct
path (matching
+ // AbstractCallSite); results must be identical either way. Note
ClosureMetaClass has
+ // never consulted categories for doCall dispatch (verified 3.x-5.x),
so the pin here
+ // is "unchanged behaviour", not interception.
+ assertScript '''
+ class ShoutCategory {
+ static String doCall(Closure self, Object arg) { 'category' }
+ }
+ def c = { it * 2 }
+ assert [3].collect(c) == [6]
+ use(ShoutCategory) {
+ assert c(3) == 6 // dynamic path: unchanged
(as in 3.x-5.x)
+ assert [3].collect(c) == [6] // Java/GDK path via the MOP
route: unchanged
+ }
+ assert [3].collect(c) == [6] // fast path restored after
the block
+ '''
+ }
+}