copying: fold in graph copying (shared nodes, cycles, identity-map fix), serialization rules (constructors, record canonical constructor, ObjectInputFilter), record array components, clone() as a defensive copy, two more compile-error exhibits; 24 tests

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b4BL3cEPHQTk2McE7C
This commit is contained in:
2026-11-12 20:29:58 +05:30
co-authored by Claude Sonnet 5.5
parent 38fbd513c9
commit c8588ae520
17 changed files with 442 additions and 11 deletions
+9 -3
View File
@@ -17,7 +17,7 @@ Module in `java-core-examples`, the Java-core series. There is no `docs/` folder
export JAVA_HOME=/path/to/jdk-25 export JAVA_HOME=/path/to/jdk-25
mvn compile mvn compile
scripts/run.sh CloneDemo # one demo scripts/run.sh CloneDemo # one demo
scripts/broken.sh # the deliberate compile error scripts/broken.sh # the three deliberate compile errors
scripts/run-all.sh # regenerate every file in output/ scripts/run-all.sh # regenerate every file in output/
``` ```
@@ -28,15 +28,21 @@ scripts/run-all.sh # regenerate every file in output/
| `ShallowVsDeepDemo.java` | Assignment vs shallow copy vs deep copy on one `Person` graph. | | `ShallowVsDeepDemo.java` | Assignment vs shallow copy vs deep copy on one `Person` graph. |
| `CloneDemo.java` | `clone()` skips the constructor, is shallow, needs `Cloneable`, and a parent that uses `new` breaks subclasses. Arrays and `ArrayList.clone()` are shallow too. | | `CloneDemo.java` | `clone()` skips the constructor, is shallow, needs `Cloneable`, and a parent that uses `new` breaks subclasses. Arrays and `ArrayList.clone()` are shallow too. |
| `src/broken/FinalFieldClone.java` | Does not compile: a `final` field cannot be deep-copied inside `clone()`. Not part of the Maven build. | | `src/broken/FinalFieldClone.java` | Does not compile: a `final` field cannot be deep-copied inside `clone()`. Not part of the Maven build. |
| `src/broken/ForgotToOverride.java` | Does not compile: `implements Cloneable` without a public `clone()` override leaves `clone()` protected. |
| `src/broken/WithExpression.java` | Does not compile on JDK 25: the `with` expression of JEP 468 (derived record creation) is not part of the language yet. |
| `CopyConstructorDemo.java` | Copy constructors with `final` fields, subtype slicing, and a virtual `copy()` that fixes it. | | `CopyConstructorDemo.java` | Copy constructors with `final` fields, subtype slicing, and a virtual `copy()` that fixes it. |
| `SerializationCopyDemo.java` | Deep copy via `ObjectOutputStream`: `transient` loss, shared references and cycles preserved, `NotSerializableException`. | | `SerializationCopyDemo.java` | Deep copy via `ObjectOutputStream`: `transient` loss, shared references and cycles preserved, `NotSerializableException`. |
| `RecordCopyDemo.java` | Records are only shallowly immutable: `List.copyOf` in the compact constructor, withers, a mutable `Date` component. | | `RecordCopyDemo.java` | Records are only shallowly immutable: `List.copyOf` in the compact constructor, withers, a mutable `Date` component. |
| `DefensiveCopyDemo.java` | Leaks through constructor and getter; `unmodifiableList` (view) vs `List.copyOf` (snapshot). | | `DefensiveCopyDemo.java` | Leaks through constructor and getter; `unmodifiableList` (view) vs `List.copyOf` (snapshot). |
| `CollectionCopyDemo.java` | Every standard list, map and array copy is shallow; the deep versions. | | `CollectionCopyDemo.java` | Every standard list, map and array copy is shallow; the deep versions. |
| `CopyOfIdentityDemo.java` | When `List.copyOf` returns the same instance and when it copies; null elements. | | `CopyOfIdentityDemo.java` | When `List.copyOf` returns the same instance and when it copies; null elements. |
| `GraphCopyDemo.java` | Shared nodes and cycles: a recursive copy constructor duplicates the shared node and overflows the stack on a cycle; an identity-map copy and serialization both handle them. |
| `SerializationRulesDemo.java` | Which constructors run during a serialization copy (classes vs records) and an `ObjectInputFilter` allow-list. |
| `RecordArrayDemo.java` | An array record component leaks through the constructor and the accessor; the generated `equals()` compares arrays by identity. |
| `DefensiveCloneDemo.java` | Why a defensive copy must not call `clone()` on a non-final parameter type (a `Date` subclass hands back itself). |
| `CopyCostDemo.java` | Indicative cost of copy constructor vs `clone()` vs serialization. Timing: results drift per run. | | `CopyCostDemo.java` | Indicative cost of copy constructor vs `clone()` vs serialization. Timing: results drift per run. |
| `CopyingTest.java` | Pins every claim above as an assertion (17 tests). | | `CopyingTest.java`, `CopyingExtrasTest.java` | Pin every claim above as an assertion (17 + 7 = 24 tests). |
| `output/01-11` | Captured runs of everything above. `10-cost.txt` is the only non-deterministic file. | | `output/01-17` | Captured runs of everything above. `10-cost.txt` is the only non-deterministic file. |
## License ## License
MIT - see [LICENSE](../LICENSE). MIT - see [LICENSE](../LICENSE).
+3 -2
View File
@@ -1,3 +1,4 @@
[INFO] Tests run: 17, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.145 s -- in com.ankurm.copying.CopyingTest [INFO] Tests run: 17, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.150 s -- in com.ankurm.copying.CopyingTest
[INFO] Tests run: 17, Failures: 0, Errors: 0, Skipped: 0 [INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.053 s -- in com.ankurm.copying.CopyingExtrasTest
[INFO] Tests run: 24, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS [INFO] BUILD SUCCESS
+11
View File
@@ -0,0 +1,11 @@
== 1. Diamond: root.left and root.right are the same node ==
original : left == right : true
copy constructor : left == right : false
identity-map copy : left == right : true
serialization copy : left == right : true
== 2. Cycle: a -> b -> a ==
original : a.left.left == a : true
copy constructor : StackOverflowError
identity-map copy : copy.left.left == copy : true
identity-map copy : copy != original : true
+11
View File
@@ -0,0 +1,11 @@
== 1. Classes: only the first non-serializable superclass constructor runs ==
after new Child() : Child runs = 1, Parent runs = 1
after the copy : Child runs = 1, Parent runs = 2
== 2. Records: the canonical constructor DOES run ==
after new Money(..) : canonical runs = 1
after the copy : canonical runs = 2
== 3. An allow-list filter on the reading side ==
list of String and Money : [text, Money[paise=1]]
list holding an AtomicInteger : InvalidClassException: filter status: REJECTED
+9
View File
@@ -0,0 +1,9 @@
== 1. RawPacket keeps the caller's array and returns its own ==
after raw[0] = 99 : [99, 2, 3]
after rp.data()[1] = 88 : [99, 88, 3]
== 2. Packet copies in the constructor and in the accessor ==
after the same two writes : [1, 2, 3]
== 3. The generated equals() still compares the arrays by identity ==
new Packet({1}).equals(new Packet({1})) : false
+7
View File
@@ -0,0 +1,7 @@
== 1. A 'defensive' copy made with clone() trusts the argument's own clone() ==
clone() result is the attacker's object : true
clone() result class : EvilDate
== 2. A copy made with the constructor does not ==
new Date(getTime()) is the attacker's object : false
new Date(getTime()) result class : Date
+5
View File
@@ -0,0 +1,5 @@
$ javac src/broken/ForgotToOverride.java
src/broken/ForgotToOverride.java:10: error: clone() has protected access in Object
Object copy = foo.clone();
^
1 error
+11
View File
@@ -0,0 +1,11 @@
$ javac src/broken/WithExpression.java
src/broken/WithExpression.java:8: error: ';' expected
Point q = p with { x = 10; };
^
src/broken/WithExpression.java:8: error: not a statement
Point q = p with { x = 10; };
^
src/broken/WithExpression.java:8: error: ';' expected
Point q = p with { x = 10; };
^
3 errors
+12 -6
View File
@@ -1,9 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Compiles the deliberately broken source and captures the compiler's own message. # Compiles each deliberately broken source and captures the compiler's own message.
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
OUT=$(mktemp -d) OUT=$(mktemp -d)
{ capture() { # <source file name> <output name>
echo '$ javac src/broken/FinalFieldClone.java' {
javac -d "$OUT" src/broken/FinalFieldClone.java 2>&1 | grep -v '^Picked up JAVA_TOOL_OPTIONS' || true echo "\$ javac src/broken/$1"
} > output/02-final-field-clone.txt javac -d "$OUT" "src/broken/$1" 2>&1 | grep -v '^Picked up JAVA_TOOL_OPTIONS' || true
cat output/02-final-field-clone.txt } > "output/$2.txt"
cat "output/$2.txt"
}
capture FinalFieldClone.java 02-final-field-clone
capture ForgotToOverride.java 16-forgot-override
capture WithExpression.java 17-with-expression
rm -rf "$OUT"
+4
View File
@@ -13,6 +13,10 @@ run RecordCopyDemo 06-records
run DefensiveCopyDemo 07-defensive run DefensiveCopyDemo 07-defensive
run CollectionCopyDemo 08-collections run CollectionCopyDemo 08-collections
run CopyOfIdentityDemo 11-copyof-identity run CopyOfIdentityDemo 11-copyof-identity
run GraphCopyDemo 12-graph-copy
run SerializationRulesDemo 13-serialization-rules
run RecordArrayDemo 14-record-arrays
run DefensiveCloneDemo 15-defensive-clone
mvn -B test 2>&1 | grep -E 'Tests run|BUILD|ERROR|FAIL' | grep -v 'Picked up' > output/09-tests.txt mvn -B test 2>&1 | grep -E 'Tests run|BUILD|ERROR|FAIL' | grep -v 'Picked up' > output/09-tests.txt
run CopyCostDemo 10-cost run CopyCostDemo 10-cost
echo "regenerated:"; ls output echo "regenerated:"; ls output
+13
View File
@@ -0,0 +1,13 @@
/** Implements Cloneable but forgets to override clone(), then calls it. Does not compile. */
public class ForgotToOverride {
static class Foo implements Cloneable {
int x = 1;
}
public static void main(String[] args) {
Foo foo = new Foo();
Object copy = foo.clone();
System.out.println(copy);
}
}
+11
View File
@@ -0,0 +1,11 @@
/** A "wither" expression on a record (JEP 468, derived record creation). Not part of JDK 25. */
public class WithExpression {
record Point(int x, int y) {}
public static void main(String[] args) {
Point p = new Point(1, 2);
Point q = p with { x = 10; };
System.out.println(q);
}
}
@@ -0,0 +1,33 @@
package com.ankurm.copying;
import java.util.Date;
/**
* Why a defensive copy must not call clone() on a parameter whose type is not final.
* Run: scripts/run.sh DefensiveCloneDemo
*/
public class DefensiveCloneDemo {
/** A Date subclass whose clone() hands back the attacker's own object. */
static class EvilDate extends Date {
EvilDate(long t) { super(t); }
@Override
public Object clone() { return this; }
}
public static void main(String[] args) {
Date attacker = new EvilDate(3_000);
System.out.println("== 1. A 'defensive' copy made with clone() trusts the argument's own clone() ==");
Date viaClone = (Date) attacker.clone();
System.out.println(" clone() result is the attacker's object : " + (viaClone == attacker));
System.out.println(" clone() result class : " + viaClone.getClass().getSimpleName());
System.out.println();
System.out.println("== 2. A copy made with the constructor does not ==");
Date viaConstructor = new Date(attacker.getTime());
System.out.println(" new Date(getTime()) is the attacker's object : " + (viaConstructor == attacker));
System.out.println(" new Date(getTime()) result class : " + viaConstructor.getClass().getSimpleName());
}
}
@@ -0,0 +1,82 @@
package com.ankurm.copying;
import java.io.Serializable;
import java.util.IdentityHashMap;
import java.util.Map;
/**
* Real object graphs have shared nodes and cycles. A recursive copy constructor treats every
* reference as a brand new object: it duplicates shared nodes and never finishes on a cycle.
* Run: scripts/run.sh GraphCopyDemo
*/
public class GraphCopyDemo {
static class Node implements Serializable {
String name;
Node left;
Node right;
Node(String name) { this.name = name; }
/** Naive "deep" copy constructor: recurse into every reference. */
Node(Node other) {
this.name = other.name;
this.left = other.left == null ? null : new Node(other.left);
this.right = other.right == null ? null : new Node(other.right);
}
/** The fix: remember what was already copied, by identity, and reuse it. */
static Node copyWithMemo(Node n, Map<Node, Node> seen) {
if (n == null) return null;
Node done = seen.get(n);
if (done != null) return done;
Node c = new Node(n.name);
seen.put(n, c); // register BEFORE recursing, so a cycle finds it and stops
c.left = copyWithMemo(n.left, seen);
c.right = copyWithMemo(n.right, seen);
return c;
}
}
static Node diamond() {
Node root = new Node("root");
Node shared = new Node("shared");
root.left = shared;
root.right = shared;
return root;
}
static Node cycle() {
Node a = new Node("a");
Node b = new Node("b");
a.left = b;
b.left = a; // b points back at a
return a;
}
public static void main(String[] args) throws Exception {
System.out.println("== 1. Diamond: root.left and root.right are the same node ==");
Node d = diamond();
System.out.println(" original : left == right : " + (d.left == d.right));
Node naive = new Node(d);
System.out.println(" copy constructor : left == right : " + (naive.left == naive.right));
Node memo = Node.copyWithMemo(d, new IdentityHashMap<>());
System.out.println(" identity-map copy : left == right : " + (memo.left == memo.right));
Node ser = SerializationCopyDemo.deepCopy(d);
System.out.println(" serialization copy : left == right : " + (ser.left == ser.right));
System.out.println();
System.out.println("== 2. Cycle: a -> b -> a ==");
Node c = cycle();
System.out.println(" original : a.left.left == a : " + (c.left.left == c));
try {
new Node(c);
System.out.println(" copy constructor : finished");
} catch (StackOverflowError e) {
System.out.println(" copy constructor : StackOverflowError");
}
Node memoCycle = Node.copyWithMemo(c, new IdentityHashMap<>());
System.out.println(" identity-map copy : copy.left.left == copy : " + (memoCycle.left.left == memoCycle));
System.out.println(" identity-map copy : copy != original : " + (memoCycle != c));
}
}
@@ -0,0 +1,44 @@
package com.ankurm.copying;
import java.util.Arrays;
/**
* An array component of a record leaks twice: through the constructor and through the accessor.
* Run: scripts/run.sh RecordArrayDemo
*/
public class RecordArrayDemo {
/** Stores the caller's array and hands it back. */
record RawPacket(byte[] data) {}
/** Copies on the way in and on the way out. */
record Packet(byte[] data) {
Packet { data = data.clone(); }
@Override
public byte[] data() { return data.clone(); }
}
public static void main(String[] args) {
System.out.println("== 1. RawPacket keeps the caller's array and returns its own ==");
byte[] raw = {1, 2, 3};
RawPacket rp = new RawPacket(raw);
raw[0] = 99;
System.out.println(" after raw[0] = 99 : " + Arrays.toString(rp.data()));
rp.data()[1] = 88;
System.out.println(" after rp.data()[1] = 88 : " + Arrays.toString(rp.data()));
System.out.println();
System.out.println("== 2. Packet copies in the constructor and in the accessor ==");
byte[] raw2 = {1, 2, 3};
Packet pk = new Packet(raw2);
raw2[0] = 99;
pk.data()[1] = 88;
System.out.println(" after the same two writes : " + Arrays.toString(pk.data()));
System.out.println();
System.out.println("== 3. The generated equals() still compares the arrays by identity ==");
System.out.println(" new Packet({1}).equals(new Packet({1})) : "
+ new Packet(new byte[]{1}).equals(new Packet(new byte[]{1})));
}
}
@@ -0,0 +1,76 @@
package com.ankurm.copying;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InvalidClassException;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Two rules of the serialization copy that surprise people: which constructors run, and what an
* ObjectInputFilter can refuse. Run: scripts/run.sh SerializationRulesDemo
*/
public class SerializationRulesDemo {
/** NOT serializable, with a no-arg constructor: the one deserialization WILL run. */
static class Parent {
static int runs = 0;
Parent() { runs++; }
}
static class Child extends Parent implements Serializable {
static int runs = 0;
Child() { runs++; }
}
record Money(long paise) implements Serializable {
static int runs = 0;
Money { runs++; } // compact canonical constructor
}
@SuppressWarnings("unchecked")
static <T extends Serializable> T deepCopy(T obj, ObjectInputFilter filter) throws IOException, ClassNotFoundException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
out.writeObject(obj);
}
try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
in.setObjectInputFilter(filter);
return (T) in.readObject();
}
}
public static void main(String[] args) throws Exception {
System.out.println("== 1. Classes: only the first non-serializable superclass constructor runs ==");
Child original = new Child();
System.out.println(" after new Child() : Child runs = " + Child.runs + ", Parent runs = " + Parent.runs);
SerializationCopyDemo.deepCopy(original);
System.out.println(" after the copy : Child runs = " + Child.runs + ", Parent runs = " + Parent.runs);
System.out.println();
System.out.println("== 2. Records: the canonical constructor DOES run ==");
Money m = new Money(5000);
System.out.println(" after new Money(..) : canonical runs = " + Money.runs);
SerializationCopyDemo.deepCopy(m);
System.out.println(" after the copy : canonical runs = " + Money.runs);
System.out.println();
System.out.println("== 3. An allow-list filter on the reading side ==");
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter("com.ankurm.copying.*;java.util.*;java.lang.*;!*");
List<Object> fine = new ArrayList<>(List.of("text", new Money(1)));
System.out.println(" list of String and Money : " + deepCopy((ArrayList<Object>) fine, filter));
List<Object> sneaky = new ArrayList<>(List.of("text", new AtomicInteger(1)));
try {
deepCopy((ArrayList<Object>) sneaky, filter);
System.out.println(" list holding an AtomicInteger : copied");
} catch (InvalidClassException e) {
System.out.println(" list holding an AtomicInteger : " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
}
@@ -0,0 +1,101 @@
package com.ankurm.copying;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.ankurm.copying.GraphCopyDemo.Node;
import java.io.InvalidClassException;
import java.io.ObjectInputFilter;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
/** Pins the claims in the "Going further" sections added after the first version of the article. */
class CopyingExtrasTest {
@Test
void copyConstructorDuplicatesSharedNodesButIdentityMapAndSerializationKeepThem() throws Exception {
Node d = GraphCopyDemo.diamond();
assertSame(d.left, d.right);
Node naive = new Node(d);
assertNotSame(naive.left, naive.right);
Node memo = Node.copyWithMemo(d, new IdentityHashMap<>());
assertSame(memo.left, memo.right);
assertNotSame(d.left, memo.left);
Node ser = SerializationCopyDemo.deepCopy(d);
assertSame(ser.left, ser.right);
}
@Test
void copyConstructorOverflowsTheStackOnACycleButIdentityMapDoesNot() {
Node c = GraphCopyDemo.cycle();
assertThrows(StackOverflowError.class, () -> new Node(c));
Node memo = Node.copyWithMemo(c, new IdentityHashMap<>());
assertSame(memo, memo.left.left);
assertNotSame(c, memo);
}
@Test
void deserializationRunsOnlyTheFirstNonSerializableSuperclassConstructor() throws Exception {
var original = new SerializationRulesDemo.Child();
int childRuns = SerializationRulesDemo.Child.runs;
int parentRuns = SerializationRulesDemo.Parent.runs;
SerializationCopyDemo.deepCopy(original);
assertEquals(childRuns, SerializationRulesDemo.Child.runs);
assertEquals(parentRuns + 1, SerializationRulesDemo.Parent.runs);
}
@Test
void deserializingARecordRunsItsCanonicalConstructor() throws Exception {
var m = new SerializationRulesDemo.Money(5000);
int before = SerializationRulesDemo.Money.runs;
var copy = SerializationCopyDemo.deepCopy(m);
assertEquals(before + 1, SerializationRulesDemo.Money.runs);
assertEquals(m, copy);
}
@Test
void allowListFilterRejectsClassesOutsideTheList() throws Exception {
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter("com.ankurm.copying.*;java.util.*;java.lang.*;!*");
var fine = new ArrayList<Object>(List.of("text", new SerializationRulesDemo.Money(1)));
assertEquals(fine, SerializationRulesDemo.deepCopy((ArrayList<Object>) fine, filter));
var sneaky = new ArrayList<Object>(List.of("text", new AtomicInteger(1)));
InvalidClassException e = assertThrows(InvalidClassException.class,
() -> SerializationRulesDemo.deepCopy((Serializable) sneaky, filter));
assertTrue(e.getMessage().contains("REJECTED"), e.getMessage());
}
@Test
void recordArrayComponentLeaksUnlessCopiedInAndOut() {
byte[] raw = {1, 2, 3};
var rp = new RecordArrayDemo.RawPacket(raw);
raw[0] = 99;
rp.data()[1] = 88;
assertArrayEquals(new byte[]{99, 88, 3}, rp.data());
byte[] raw2 = {1, 2, 3};
var pk = new RecordArrayDemo.Packet(raw2);
raw2[0] = 99;
pk.data()[1] = 88;
assertArrayEquals(new byte[]{1, 2, 3}, pk.data());
assertNotEquals(new RecordArrayDemo.Packet(new byte[]{1}), new RecordArrayDemo.Packet(new byte[]{1}));
}
@Test
void cloneIsNotADefensiveCopyOfANonFinalType() {
Date attacker = new DefensiveCloneDemo.EvilDate(3_000);
assertSame(attacker, attacker.clone());
Date safe = new Date(attacker.getTime());
assertNotSame(attacker, safe);
assertEquals(Date.class, safe.getClass());
}
}