You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/08/15 20:38:58 UTC
(groovy) branch master updated: GROOVY-12257: Restore ObjectUtil as a binary-compatibility facade for Groovy 4 compiled @Immutable classes
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 31803c59de GROOVY-12257: Restore ObjectUtil as a binary-compatibility facade for Groovy 4 compiled @Immutable classes
31803c59de is described below
commit 31803c59ded358c0d23b818af4d1a192327d260f
Author: Paul King <pa...@asert.com.au>
AuthorDate: Sat Aug 15 18:48:25 2026 +1000
GROOVY-12257: Restore ObjectUtil as a binary-compatibility facade for Groovy 4 compiled @Immutable classes
The @Immutable transform in Groovy 4.0.5 through 4.0.x (GROOVY-10747)
emitted references to org.apache.groovy.runtime.ObjectUtil.cloneObject
into generated constructors and getters for defensive copies. The class
was removed along with the $getLookup machinery it relied on
(GROOVY-10931), so such pre-compiled classes fail on Groovy 5+ with
NoClassDefFoundError at first instantiation — only when a clone path
actually executes, which for collection properties depends on the
runtime value being Cloneable, making the failure intermittent.
Restore the class as a deprecated facade with the original semantics
minus the lookup dependency: null passes through, non-Cloneables throw
CloneNotSupportedException, arrays clone via the unchanged ArrayUtil
fast paths, and other Cloneables clone through their public clone()
via the MOP (guarded by the same getMethod check as the original, so a
non-public clone() still surfaces NoSuchMethodException). Nothing
compiled by Groovy 5+ references the class; current transforms emit
indy or InvokerHelper clone calls instead.
Verified against real Groovy 4.0.33 bytecode: the reproducer's List
property shape and the unconditional array/getter shape both fail with
NoClassDefFoundError before this change and pass with it, preserving
defensive-copy semantics.
---
COMPATIBILITY.md | 25 ++++
.../java/org/apache/groovy/runtime/ObjectUtil.java | 98 ++++++++++++++++
.../apache/groovy/runtime/ObjectUtilTest.groovy | 130 +++++++++++++++++++++
3 files changed, 253 insertions(+)
diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md
index 8f026ab81b..062e9d09ec 100644
--- a/COMPATIBILITY.md
+++ b/COMPATIBILITY.md
@@ -206,6 +206,31 @@ planned for deprecation/removal in a future Groovy version. Formal
`@Deprecated` may be restored before 6 GA once beta feedback confirms
indy remains acceptable for those use cases.
+### Groovy 5/6 — `ObjectUtil` restored for Groovy 4 compiled `@Immutable` classes (GROOVY-12257)
+
+The `@Immutable` transform in Groovy 4.0.5 through 4.0.x emitted
+references to `org.apache.groovy.runtime.ObjectUtil.cloneObject` into
+generated constructors and getters for defensive copies of array,
+`Cloneable`, and collection properties (GROOVY-10747). The class was
+removed in 5.0.0-alpha-1 together with the `$getLookup` machinery it
+relied on (GROOVY-10931), so those pre-compiled classes failed on
+Groovy 5+ with `NoClassDefFoundError` — only at first execution of a
+clone path, which for collection properties depends on the runtime
+value being `Cloneable`, making the failure intermittent and easy to
+miss in testing.
+
+`ObjectUtil` is restored (5.1.1, 6.0.0) as a deprecated
+binary-compatibility facade with the original semantics: arrays clone
+via the unchanged `ArrayUtil` fast paths, other `Cloneable`s through
+their public `clone()` via the MOP, and a non-public `clone()` still
+surfaces `NoSuchMethodException` exactly as before. Classes compiled
+by ≤ 4.0.4 (which used `ReflectionMethodInvoker`, still present) and
+by 5+ (whose transforms emit `invokedynamic` or `InvokerHelper` clone
+calls) never reference the class. It exists only as a link target for
+Groovy 4 bytecode and is not intended for direct use. This lets
+"legacy" Grails plugins compiled with Groovy 4 still work with later
+Grails versions using more recent Groovy versions.
+
### Groovy 6 — `instanceof` pattern variable flow scoping (GROOVY-12242)
Groovy 6 aligns JEP 394 / JLS §6.3-style *pattern variable* scoping for
diff --git a/src/main/java/org/apache/groovy/runtime/ObjectUtil.java b/src/main/java/org/apache/groovy/runtime/ObjectUtil.java
new file mode 100644
index 0000000000..9249b1fbec
--- /dev/null
+++ b/src/main/java/org/apache/groovy/runtime/ObjectUtil.java
@@ -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.apache.groovy.runtime;
+
+import org.codehaus.groovy.GroovyBugError;
+import org.codehaus.groovy.runtime.ArrayUtil;
+import org.codehaus.groovy.runtime.InvokerHelper;
+
+/**
+ * Binary-compatibility facade for class files compiled by Groovy 4.0.5+
+ * (GROOVY-12257).
+ * <p>
+ * The {@code @Immutable} transform in Groovy 4.0.5 through 4.0.x emitted
+ * {@code INVOKESTATIC} calls to {@link #cloneObject(Object)} into generated
+ * constructors and getters for defensive copies (GROOVY-10747). The original
+ * class was removed together with the {@code $getLookup} machinery it relied
+ * on (GROOVY-10931), which broke such pre-compiled classes with a
+ * {@link NoClassDefFoundError} at first instantiation. This facade restores
+ * the call target with equivalent behaviour, minus the lookup dependency:
+ * arrays are cloned via {@link ArrayUtil} exactly as before, and other
+ * {@link Cloneable}s through their public {@code clone()} via the MOP.
+ * <p>
+ * Nothing compiled by Groovy 5+ references this class; its transforms emit
+ * {@code invokedynamic}-based or {@code InvokerHelper} clone calls instead.
+ *
+ * @since 4.0.5
+ * @deprecated retained only so Groovy 4 compiled bytecode keeps linking;
+ * not emitted by any current transform and not intended for direct use
+ */
+@Deprecated
+public class ObjectUtil {
+
+ /**
+ * Clone the specified object
+ *
+ * @param object the object to clone
+ * @return the cloned object
+ * @param <T> the object type
+ * @throws Throwable some exception or error
+ * @since 4.0.5
+ */
+ @SuppressWarnings("unchecked")
+ public static <T> T cloneObject(T object) throws Throwable {
+ if (null == object) return null;
+
+ final Class<?> clazz = object.getClass();
+ if (!(object instanceof Cloneable)) throw new CloneNotSupportedException(clazz.getName());
+
+ if (clazz.isArray()) {
+ if (clazz.getComponentType().isPrimitive()) {
+ if (byte[].class == clazz) {
+ return (T) ArrayUtil.cloneArray((byte[]) object);
+ } else if (short[].class == clazz) {
+ return (T) ArrayUtil.cloneArray((short[]) object);
+ } else if (int[].class == clazz) {
+ return (T) ArrayUtil.cloneArray((int[]) object);
+ } else if (char[].class == clazz) {
+ return (T) ArrayUtil.cloneArray((char[]) object);
+ } else if (long[].class == clazz) {
+ return (T) ArrayUtil.cloneArray((long[]) object);
+ } else if (float[].class == clazz) {
+ return (T) ArrayUtil.cloneArray((float[]) object);
+ } else if (double[].class == clazz) {
+ return (T) ArrayUtil.cloneArray((double[]) object);
+ } else if (boolean[].class == clazz) {
+ return (T) ArrayUtil.cloneArray((boolean[]) object);
+ }
+
+ throw new GroovyBugError(clazz.getName() + " is not an array of primitive type"); // should never happen
+ }
+ return (T) ArrayUtil.cloneArray((Object[]) object);
+ }
+
+ // same NoSuchMethodException as the original lookup-based
+ // implementation when clone() is not public
+ clazz.getMethod("clone");
+
+ return (T) InvokerHelper.invokeMethod(object, "clone", InvokerHelper.EMPTY_ARGS);
+ }
+
+ private ObjectUtil() {}
+}
diff --git a/src/test/groovy/org/apache/groovy/runtime/ObjectUtilTest.groovy b/src/test/groovy/org/apache/groovy/runtime/ObjectUtilTest.groovy
new file mode 100644
index 0000000000..afd52207f2
--- /dev/null
+++ b/src/test/groovy/org/apache/groovy/runtime/ObjectUtilTest.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.apache.groovy.runtime
+
+import org.junit.jupiter.api.Test
+
+import java.lang.reflect.Modifier
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals
+import static org.junit.jupiter.api.Assertions.assertEquals
+import static org.junit.jupiter.api.Assertions.assertNotSame
+import static org.junit.jupiter.api.Assertions.assertNull
+import static org.junit.jupiter.api.Assertions.assertThrows
+import static org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Tests for the {@link ObjectUtil} binary-compatibility facade (GROOVY-12257).
+ * <p>
+ * Class files compiled by Groovy 4.0.5+ with {@code @Immutable} contain
+ * {@code INVOKESTATIC org/apache/groovy/runtime/ObjectUtil.cloneObject
+ * (Ljava/lang/Object;)Ljava/lang/Object;} in generated constructors and
+ * getters, so both the exact linkage shape and the 4.x runtime semantics
+ * are locked in here.
+ */
+final class ObjectUtilTest {
+
+ /** The exact symbol Groovy 4 bytecode links against must keep resolving. */
+ @Test
+ void cloneObject_keepsTheErasedSignatureGroovy4BytecodeReferences() {
+ def m = ObjectUtil.getMethod('cloneObject', Object)
+ assertTrue(Modifier.isPublic(m.modifiers))
+ assertTrue(Modifier.isStatic(m.modifiers))
+ assertEquals(Object, m.returnType)
+ assertEquals('org.apache.groovy.runtime.ObjectUtil', m.declaringClass.name)
+ }
+
+ @Test
+ void cloneObject_null_returnsNull() {
+ assertNull(ObjectUtil.cloneObject(null))
+ }
+
+ @Test
+ void cloneObject_nonCloneable_throwsCloneNotSupported() {
+ def e = assertThrows(CloneNotSupportedException) { ObjectUtil.cloneObject('a string') }
+ assertEquals('java.lang.String', e.message)
+ }
+
+ @Test
+ void cloneObject_primitiveArrays_cloneToEqualDistinctArrays() {
+ byte[] bytes = [1, 2]
+ short[] shorts = [3, 4]
+ int[] ints = [5, 6]
+ char[] chars = ['a' as char, 'b' as char]
+ long[] longs = [7L, 8L]
+ float[] floats = [1.5f, 2.5f]
+ double[] doubles = [3.5d, 4.5d]
+ boolean[] booleans = [true, false]
+ [bytes, shorts, ints, chars, longs, floats, doubles, booleans].each { array ->
+ def copy = ObjectUtil.cloneObject(array)
+ assertNotSame(array, copy)
+ assertEquals(array.class, copy.class)
+ assertTrue(Arrays.equals(array, copy))
+ }
+ }
+
+ @Test
+ void cloneObject_objectArray_clonesShallowly() {
+ String[] source = ['x', 'y']
+ String[] copy = ObjectUtil.cloneObject(source)
+ assertNotSame(source, copy)
+ assertArrayEquals(source, copy)
+ }
+
+ /** The reproducer shape: a Cloneable collection defensively copied by @Immutable. */
+ @Test
+ void cloneObject_arrayList_clonesViaPublicClone() {
+ def source = new ArrayList<>(['value'])
+ def copy = ObjectUtil.cloneObject(source)
+ assertNotSame(source, copy)
+ assertEquals(source, copy)
+ copy << 'mutated'
+ assertEquals(['value'], source)
+ }
+
+ @Test
+ void cloneObject_cloneableWithPublicClone_usesIt() {
+ def source = new PublicClone(value: 42)
+ def copy = ObjectUtil.cloneObject(source)
+ assertNotSame(source, copy)
+ assertEquals(42, copy.value)
+ }
+
+ /** The 4.x implementation used getMethod, so a non-public clone() never worked; keep that contract. */
+ @Test
+ void cloneObject_cloneableWithoutPublicClone_throwsNoSuchMethod() {
+ assertThrows(NoSuchMethodException) { ObjectUtil.cloneObject(new ProtectedClone()) }
+ }
+
+ static final class PublicClone implements Cloneable {
+ int value
+
+ @Override
+ Object clone() {
+ new PublicClone(value: value)
+ }
+ }
+
+ static final class ProtectedClone implements Cloneable {
+ @Override
+ protected Object clone() {
+ super.clone()
+ }
+ }
+}