Shallow and deep copy: trace the mutable boundary

Inspect object identities before choosing how to duplicate a playlist for independent edits. Relationships and copying · Object-oriented programming.

You duplicate a playlist before editing it, then a song name changes in both versions. The word “copy” hid an important question: which objects did your operation actually duplicate? Follow each reference before choosing the boundary you need.

Name the objects before copying

P0 is a Playlist object. Its songs field refers to list L0, whose first slot refers to mutable Song S0, initially named Lark. The arrows are ordinary references; they do not say who owns whom. This example asks for edit isolation; it does not claim that every real playlist owns its songs exclusively.

A new root, shared descendants

Read the steps as text

  1. Playlist P0 reaches List L0, whose first slot reaches Song S0 named Lark. The variable original refers to P0, and shallow refers to nothing yet.
  2. The program copies Playlist P0 as Playlist P1, and its songs link still points at List L0. Variable shallow now refers to Playlist P1. A shallow copy builds only the outer Playlist, so nothing below it is duplicated.
  3. List L0 now has two incoming arrows, one from each Playlist, while Song S0 has one.
  4. The program changes the name of Song S0 from "Lark" to "Glow". The path through the copy reaches that same Song, so the shallow Playlist sees Glow too.
  5. One Song holds the new name, and both Playlists reach it through the one shared List.

Unlike an alias, which creates no object, a shallow root copy creates P1 and copies the root’s fields. Its songs reference still points to L0. You now have two Playlist identities but only one shared list and one shared Song along these paths.

When you change original.songs[0].name, you mutate S0. Following the path from P1 reaches S0 too, so the shallow version observes the changed name. Copying the outer object did not place a boundary around the whole mutable object graph.

Give the edit its own mutable graph

Restart from Lark. Our chosen deep copy creates P1, list L1 and Song S1. Renaming original’s S0 now leaves S1 unchanged. The scene uses a single distinct, acyclic Song leaf so the Java constructor policy and Python’s recursive copying describe the same graph.

A chosen deep copy

Read the steps as text

  1. Playlist P0 reaches List L0 and Song S0 named Lark. The variable original refers to P0, and deep refers to nothing yet.
  2. The program copies Playlist P0, List L0 and Song S0 as Playlist P1, List L1 and Song S1. Variable deep now refers to Playlist P1. Every mutable object on the path now has a duplicate, and the new links stay inside the copy.
  3. Only the original's List reaches S0, because the copy's List reaches its own Song.
  4. The program changes the name of Song S0 from "Lark" to "Dusk". The copy's Song is a different object, so it still reads Lark.
  5. The original's Song reads Dusk and the copy's Song still reads Lark, because no link joins the two graphs.

These complete examples create the shallow and deep versions before mutating the original. Both print Glow for the shallow view and Lark for the deep view after clearing the two names that denoted the original root.

Python ships generic copy.copy and copy.deepcopy. Java has no universal built-in deep copy: Object.clone() is shallow by default and needs care to override, so a Java deep copy is a constructor or method you write, which makes its boundary your chosen policy.

import java.util.ArrayList;
import java.util.List;
class Main {
    static final class Song {
        String name;
        Song(String name) { this.name = name; }
    }
    static final class Playlist {
        final List<Song> songs;
        Playlist(List<Song> songs) { this.songs = songs; }
        Playlist deepCopy() {
            List<Song> copied = new ArrayList<>();
            for (Song s : songs) copied.add(new Song(s.name));
            return new Playlist(copied);
        }
    }
    public static void main(String[] args) {
        Playlist original = new Playlist(
            new ArrayList<>(List.of(new Song("Lark"))));
        Playlist alias = original;
        Playlist shallow = new Playlist(original.songs);
        Playlist deep = original.deepCopy();
        original.songs.get(0).name = "Glow";
        original = null;
        alias = null;
        System.out.println(shallow.songs.get(0).name);
        System.out.println(deep.songs.get(0).name);
    }
}
import copy

class Song:
    def __init__(self, name):
        self.name = name

class Playlist:
    def __init__(self, songs):
        self.songs = songs

original = Playlist([Song("Lark")])
alias = original
shallow = copy.copy(original)
deep = copy.deepcopy(original)
original.songs[0].name = "Glow"
del original, alias
print(shallow.songs[0].name)
print(deep.songs[0].name)

Equal contents before an edit do not prove shared identity, and different roots do not prove independent descendants. In Java, compare references with == rather than assuming equals answers the same question. In Python, use is when asking whether two paths reach one object.

The immutable name values need not get new identities. Changing a Song’s name field replaces that field’s reference to a string; it does not mutate the string itself. The mutable Song objects, lists and Playlist roots are the boundaries that matter for the edits shown here.

Clear a binding without inventing destruction

Assigning Java null clears the reference held by each declared local variable, while Python del removes each name binding; in both cases, those names no longer refer to the previous root. Neither operation walks through the graph destroying every object once reachable from the original root. The shallow version still reaches L0 and S0. Their references remain valid, so reading Glow is expected.

Clearing a variable breaks nothing

Read the steps as text

  1. Playlist P1 is a shallow copy of P0. Both reach List L0 and Song S0, now named Glow. The variable original refers to P0, and shallow refers to P1.
  2. Variable original now refers to nothing. Only that one variable arrow is gone, and no link below P0 changed.
  3. The copy's Playlist still reaches the List, and the List still points at the Song.
  4. The program changes the name of Song S0 from "Glow" to "Dawn". The copy edits the Song through its own path, so clearing original did not invalidate it.
  5. Everything the copy reaches is still in place, and the Song reads Dawn along every path.

The scene clears the name by rebinding it to None, where the complete examples use del. It still draws the original root P0 with 0 references and its links unchanged, so List L0 keeps counting P0’s arrow. Drawn is not freed, and the scene does not claim a collection occurred at that instruction. A garbage collector’s timing is a separate issue, and this example has no explicit resource disposal or C++ owning-pointer destruction.

A faded example: only the list is new

Now allocate a new Playlist and a new list, but keep the same Song in both lists. After renaming that shared Song to Glow, replace only the original list’s first slot with a new Song named Mist. Watch which Song the copied list’s slot still reaches after the original slot is replaced.

A new list, the same Songs

Read the steps as text

  1. Playlist P0 reaches List L0 and Song S0 named Wren. The variable original refers to P0, and list_copy refers to nothing yet.
  2. The program copies Playlist P0 as Playlist P2, and its songs link still points at List L0. Variable list_copy now refers to Playlist P2. This new Playlist shares the original's List for now.
  3. The program copies List L0 as List L2, and its slot 0 link still points at Song S0. Playlist P2's songs link now points at List L2 instead of List L0. Now the copy has a List of its own, but no Song was duplicated.
  4. The program changes the name of Song S0 from "Wren" to "Glow". Both Lists reach that Song, so both Playlists see the new name.
  5. The program builds a new Song S1. List L0's slot 0 link now points at Song S1 instead of Song S0. The new Song is named Mist. Only the original's slot 0 moved, and the copy's List still reaches the Song named Glow.
  6. Slot replacement stayed on one side, while the earlier Song edit reached both.

The new list isolates membership changes. It does not isolate earlier mutation of a shared element. “A list copy” and “a shallow Playlist copy” therefore have different boundaries in this object graph. Always name the object to which the copying operation applies.

In real code, a method that hands out or stores a caller’s playlist is a boundary. Copy only as deep as the edits you expect: a new list protects membership, a copied Song protects names. Copying deeper than needed costs time and can break shared identity you wanted.

Recap

  • Assignment preserves root identity; a shallow root copy changes the root while sharing nested references.
  • Our deep policy duplicates the mutable descendants needed for independent Song edits.
  • A copied list separates membership updates but can still share mutable elements.
  • Clearing the original’s variable removes only that reference; objects still reached from a copy stay valid.
  • Python’s copy.deepcopy and a Java copy method you write set the boundary, because Java has no universal deep copy.
Go deeper

The faded scenario below uses the same class definitions from the main tab. Its independent list still contains the original Song reference. The output is Glow, then Mist: the list-only version keeps the old element while the original list receives a new one.

// Inside Main.main, using the classes above:
Playlist original = new Playlist(
    new ArrayList<>(List.of(new Song("Lark"))));
Playlist listOnly = new Playlist(new ArrayList<>(original.songs));
original.songs.get(0).name = "Glow";
original.songs.set(0, new Song("Mist"));
System.out.println(listOnly.songs.get(0).name);
System.out.println(original.songs.get(0).name);
# Using the classes above:
original = Playlist([Song("Lark")])
list_only = Playlist(list(original.songs))
original.songs[0].name = "Glow"
original.songs[0] = Song("Mist")
print(list_only.songs[0].name)
print(original.songs[0].name)

For repeated references or cycles, a deep-copy policy must say whether internal sharing is preserved. Python’s deepcopy memo handles such cases. The small Java loop above does not preserve repeated Song aliases or support arbitrary cyclic graphs; those inputs are outside this example. Custom copy hooks, immutable objects, files and other resources need deliberate policies too. “Deep” is not permission to duplicate every object indiscriminately.

Check your understanding. Next, equality and hash contracts ask when two different objects should count as the same value. To practise identity and link updates first, try Linked List Fundamentals.