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:33:57 UTC

(groovy) branch GROOVY_5_1_X 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 GROOVY_5_1_X
in repository https://gitbox.apache.org/repos/asf/groovy.git


The following commit(s) were added to refs/heads/GROOVY_5_1_X by this push:
     new 514e66f42a GROOVY-12257: Restore ObjectUtil as a binary-compatibility facade for Groovy 4 compiled @Immutable classes
514e66f42a is described below

commit 514e66f42aae71aa22c1b3ed7ac7c6d3e8c88019
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.
---
 .../java/org/apache/groovy/runtime/ObjectUtil.java |  98 ++++++++++++++++
 .../apache/groovy/runtime/ObjectUtilTest.groovy    | 130 +++++++++++++++++++++
 2 files changed, 228 insertions(+)

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()
+        }
+    }
+}