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 3740ee7e4c GROOVY-12164: extend Closure.call fast path to typed
one-arg overrides
3740ee7e4c is described below
commit 3740ee7e4c96992bb7ba3d915df5947b01ed2e06
Author: Paul King <[email protected]>
AuthorDate: Tue Jul 14 16:35:55 2026 +1000
GROOVY-12164: extend Closure.call fast path to typed one-arg overrides
A closure with a typed parameter generates call(T)/doCall(T) rather than
call(Object), which the GROOVY-11911 CallOverride lookup cannot see, so
every such call fell back to full metaclass dispatch (~2.5x slower via
DGM iteration). Cache the single unambiguous typed one-arg override with
an instance-of guard (boxed for primitives): exact-instance arguments
dispatch directly; anything needing Groovy coercion (GString->String,
number conversions, null) still falls through to the metaclass, which
behaves exactly as before. Untyped closures are unaffected.
---
src/main/java/groovy/lang/Closure.java | 86 ++++++++++++++-
src/test/groovy/bugs/Groovy12164.groovy | 186 ++++++++++++++++++++++++++++++++
2 files changed, 267 insertions(+), 5 deletions(-)
diff --git a/src/main/java/groovy/lang/Closure.java
b/src/main/java/groovy/lang/Closure.java
index d559bc55a8..cde84c068d 100644
--- a/src/main/java/groovy/lang/Closure.java
+++ b/src/main/java/groovy/lang/Closure.java
@@ -41,6 +41,7 @@ import java.io.Serializable;
import java.io.Writer;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
import java.util.ArrayDeque;
import java.util.Collections;
import java.util.Deque;
@@ -540,6 +541,14 @@ public abstract class Closure<V> extends
GroovyObjectSupport implements Cloneabl
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;
}
@@ -1378,7 +1387,7 @@ public abstract class Closure<V> extends
GroovyObjectSupport implements Cloneabl
/**
* Marker instance indicating that no call override exists.
*/
- static final CallOverride NONE = new CallOverride(null, null);
+ static final CallOverride NONE = new CallOverride(null, null, null);
/**
* Cached zero-argument call override.
*/
@@ -1387,10 +1396,25 @@ public abstract class Closure<V> extends
GroovyObjectSupport implements Cloneabl
* Cached single-argument call override.
*/
final Method oneArg;
+ /**
+ * 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)}.
+ */
+ final Method oneArgTyped;
+ /**
+ * 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.
+ */
+ final Class<?> oneArgGuard;
- private CallOverride(Method zeroArg, Method oneArg) {
+ 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]);
}
/**
@@ -1403,14 +1427,17 @@ public abstract class Closure<V> extends
GroovyObjectSupport implements Cloneabl
if (type == Closure.class) return NONE;
Method zero = findOverride(type);
Method one = findOverride(type, Object.class);
- if (zero == null && one == null) return NONE;
- return new CallOverride(zero, one);
+ Method typed = (one == null) ? findTypedOneArgOverride(type) :
null;
+ if (zero == null && one == null && typed == null) return NONE;
+ return new CallOverride(zero, one, typed);
}
private static Method findOverride(Class<?> type, Class<?>... params) {
try {
Method m = type.getMethod("call", params);
- if (m.getDeclaringClass() == Closure.class) return null;
+ // a static call(...) is not an instance override: reflective
invocation would
+ // silently ignore the receiver, where the MOP fallback
dispatches instance doCall
+ if (m.getDeclaringClass() == Closure.class ||
Modifier.isStatic(m.getModifiers())) return null;
try {
m.setAccessible(true);
} catch (RuntimeException ignored) {
@@ -1422,5 +1449,54 @@ public abstract class Closure<V> extends
GroovyObjectSupport implements Cloneabl
return null;
}
}
+
+ /**
+ * 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;
+ if (type == long.class) return Long.class;
+ if (type == boolean.class) return Boolean.class;
+ if (type == double.class) return Double.class;
+ if (type == char.class) return Character.class;
+ if (type == byte.class) return Byte.class;
+ if (type == short.class) return Short.class;
+ if (type == float.class) return Float.class;
+ return type; // void: unreachable for a parameter type
+ }
}
}
diff --git a/src/test/groovy/bugs/Groovy12164.groovy
b/src/test/groovy/bugs/Groovy12164.groovy
new file mode 100644
index 0000000000..a91b80e060
--- /dev/null
+++ b/src/test/groovy/bugs/Groovy12164.groovy
@@ -0,0 +1,186 @@
+/*
+ * 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 (GROOVY-11911) only recognised a
+ * {@code call(Object)} override, so a closure with a <em>typed</em> parameter
—
+ * which generates {@code call(T)}/{@code doCall(T)} — fell back to full
+ * metaclass dispatch on every call. GROOVY-12164 extends the cache with a
+ * guarded typed override: an argument already an instance of the declared type
+ * dispatches directly; anything needing Groovy coercion (GString to String,
+ * number conversions, null) still goes through the metaclass. These tests pin
+ * the behaviour either side of that guard — results must be indistinguishable
+ * from metaclass dispatch. Iteration goes through DGM, whose Java code calls
+ * {@code closure.call(item)}, the path the cache serves.
+ */
+final class Groovy12164 {
+
+ @Test
+ void testTypedParameterViaIteration() {
+ assertScript '''
+ int sum = 0
+ [1, 2, 3, 4].each { Integer x -> sum += x }
+ assert sum == 10
+ assert [1, 2, 3].collect { Integer x -> x * x } == [1, 4, 9]
+ '''
+ }
+
+ @Test
+ void testTypedParameterDirectCall() {
+ assertScript '''
+ Closure<Integer> c = { Integer x -> x + 1 }
+ assert c.call(41) == 42
+ assert c(41) == 42
+ '''
+ }
+
+ @Test
+ void testNullArgumentStillDispatches() {
+ // null is not an instance of the declared type: must fall through to
the
+ // metaclass and reach the body as null, exactly as before
+ assertScript '''
+ def seen = []
+ [null, 2].each { Integer x -> seen << x }
+ assert seen == [null, 2]
+ '''
+ }
+
+ @Test
+ void testCoercionStillApplies() {
+ assertScript '''
+ // GString -> String coercion happens on the metaclass path
+ def name = 'world'
+ def result = ["hello ${name}"].collect { String s ->
s.toUpperCase() }
+ assert result == ['HELLO WORLD']
+ assert result[0] instanceof String
+ '''
+ }
+
+ @Test
+ void testNumberConversionMatchesMetaclassDispatch() {
+ assertScript '''
+ import groovy.test.GroovyAssert
+ // The metaclass coerces some number shapes (BigDecimal literal to
a double
+ // parameter) and rejects others (Long to an int parameter) with a
+ // MissingMethodException. The typed guard declines both to the
metaclass,
+ // so each keeps its pre-existing outcome.
+ def seen = []
+ [1.5, 2.5].each { double x -> seen << x }
+ assert seen == [1.5d, 2.5d]
+ GroovyAssert.shouldFail(MissingMethodException) {
+ [1L].each { int x -> }
+ }
+ '''
+ }
+
+ @Test
+ void testPrimitiveParameterExactMatch() {
+ // an Integer argument to an int parameter passes the (boxed) guard
and unboxes
+ assertScript '''
+ int sum = 0
+ [1, 2, 3].each { int x -> sum += x }
+ assert sum == 6
+ '''
+ }
+
+ @Test
+ void testDefaultParameterValues() {
+ // default values generate overloaded doCall/call: the guard must not
disturb selection
+ assertScript '''
+ def c = { Integer x, Integer y = 10 -> x + y }
+ assert c.call(1) == 11
+ assert c.call(1, 2) == 3
+ '''
+ }
+
+ @Test
+ void testVarargClosureCollection() {
+ // an array-typed parameter is vararg collection, which stays
metaclass work
+ assertScript '''
+ def c = { Object... items -> items.size() }
+ assert c.call('a') == 1
+ assert c.call('a', 'b') == 2
+ '''
+ }
+
+ @Test
+ void testMultiParameterDestructuring() {
+ // a single List argument to a two-parameter closure destructures via
the metaclass
+ assertScript '''
+ def seen = []
+ [[1, 'a'], [2, 'b']].each { Integer n, String s -> seen <<
"$n$s".toString() }
+ assert seen == ['1a', '2b']
+ '''
+ }
+
+ @Test
+ void testTypedSubclassOverride() {
+ // a Closure subclass with a typed call override is served by the same
cache
+ assertScript '''
+ 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]
+ '''
+ }
+
+ @Test
+ void testStaticCallDeclarationIsNotAnOverride() {
+ // A public static call(T) — or static call(Object) — is not an
instance override:
+ // the cached fast path must skip it (reflective invocation would
silently ignore the
+ // receiver) and keep dispatching instance doCall via the metaclass.
Exercised through
+ // DGM iteration, the Closure.call(Object...) entry point the cache
serves; a direct
+ // c.call(x) from Groovy code is metaclass dispatch, where selecting a
static is legal.
+ assertScript '''
+ class TypedStatic extends Closure<String> {
+ TypedStatic() { super(null) }
+ static String call(Integer x) { 'static' }
+ String doCall(Integer x) { 'instance' }
+ }
+ class ObjectStatic extends Closure<String> {
+ ObjectStatic() { super(null) }
+ static String call(Object x) { 'static' }
+ String doCall(Object x) { 'instance' }
+ }
+ assert [1].collect(new TypedStatic()) == ['instance']
+ assert [1].collect(new ObjectStatic()) == ['instance']
+ '''
+ }
+
+ @Test
+ void testExceptionPropagation() {
+ // an exception thrown by the typed body surfaces unwrapped
+ assertScript '''
+ import groovy.test.GroovyAssert
+ def boom = { Integer x -> throw new IllegalStateException('boom '
+ x) }
+ def e = GroovyAssert.shouldFail(IllegalStateException) {
+ [7].each(boom)
+ }
+ assert e.message == 'boom 7'
+ '''
+ }
+}