Objective-C

Deep Copy and Shallow Copy: The Complete Memory Change Principle

By Seren  |  20 Sep, 2026  |  Leave a comment


I once treated “deep copy” and “shallow copy” as two simple vocabulary items: one duplicates everything, while the other only duplicates an address. That explanation was easy to remember, but it was not useful when a real bug appeared in an iOS project. The outer objects had different addresses, yet editing one screen still changed data shown by another screen.

The bug came from a notification-template editor. A user could duplicate an existing push template, change its tags and delivery metadata, then save it as a new template. We copied the model before opening the editor, so the original was supposed to stay untouched. In testing, changing a mutable tag inside the duplicate also changed that tag in the original. The copy looked independent at the top level, but part of its object graph was still shared.

The Copy Bug That Looked Impossible

The model contained a title, an array of tags, and a metadata dictionary. The title was an immutable string, but each tag was an NSMutableString. Our first implementation created a new template and called mutableCopy on the tags array. Logging confirmed that the old and new arrays had different addresses. That initially convinced us the copy was safe.

Then the editor appended text to the first tag. Both screens displayed the new value. The reason was simple once we inspected each level: the array had been copied, but the mutable strings stored inside it had not. The two arrays were separate containers holding pointers to the same tag objects.

This is why address logging can be misleading. Comparing only the two root addresses proves only that the root objects differ. It says nothing about their children, grandchildren, or any other mutable state reachable from them.

Copy Is a Contract, Not a Universal Memory Operation

In Objective-C, copy does not have one universal depth. It asks an object to perform the behavior defined by that object’s NSCopying implementation. Likewise, mutableCopy follows the type’s mutable-copying behavior. The class decides which state is duplicated and which state may be shared.

That makes statements such as “copy is shallow” or “mutableCopy is deep” unreliable. An immutable object may safely return itself from copy because its observable value cannot be changed. A mutable collection normally returns a different container from mutableCopy, but the objects inside that container are not automatically cloned.

The useful question is not which method name sounds deeper. I ask: after this operation, which mutable objects can still be reached by both models? If a shared mutable object can affect application behavior, it needs an explicit ownership decision.

What Happens When a Collection Is Copied

Imagine an array that contains two mutable strings. Calling mutableCopy gives us a new mutable array. Adding or removing an entry in the new array will not change the old array’s structure. However, both arrays initially refer to the same two string objects. Mutating one of those strings is therefore visible through both arrays.

This distinction between container independence and element independence is the center of the problem. A new container is enough when its elements are immutable values. It may also be enough when shared elements are deliberate, such as cached read-only records. It is not enough when an editor is allowed to mutate an element in place.

APIs that copy collection items can help at one level, but they still do not promise an infinitely recursive clone. Each element receives its own copy operation, and that element’s class chooses what its copy includes. If an element contains another mutable object and its implementation shares that object, the nested state remains shared.

Defining Deep Copy for the Project

“Deep copy” is most useful when it is defined in terms of the editing boundary. In this project, a duplicated notification template must allow independent changes to its tag text, tag order, delivery channel, and locale. The title is immutable, so sharing its stored value is harmless. The mutable tag elements, tag container, and metadata dictionary must be independent.

I prefer writing that rule down before implementing the method. Otherwise it is tempting to copy every reachable object, including services, delegates, caches, or identifiers that should remain shared. Blindly cloning an entire object graph can be as incorrect as copying too little.

The demo therefore has two intentional operations. Its copyWithZone: creates a new template and a new outer tags array, but keeps the tag elements and metadata shared. This reproduces the original bug. Its explicit deepCopy method creates a new template, copies every mutable tag, and creates a new metadata dictionary.

The Core Implementation

- (id)copyWithZone:(NSZone *)zone {
    NotificationTemplate *copy = [[[self class] allocWithZone:zone]
        initWithTitle:self.title
                 tags:[self.tags mutableCopy]
             metadata:self.metadata];
    return copy;
}

- (NotificationTemplate *)deepCopy {
    NSMutableArray<NSMutableString *> *copiedTags =
        [NSMutableArray arrayWithCapacity:self.tags.count];

    for (NSMutableString *tag in self.tags) {
        [copiedTags addObject:[tag mutableCopy]];
    }

    return [[NotificationTemplate alloc]
        initWithTitle:self.title
                 tags:copiedTags
             metadata:[self.metadata mutableCopy]];
}

The explicit loop is not clever, and that is one reason I like it. A future maintainer can see exactly which nested values become independent. If the model later gains mutable actions, localization variants, or attachment settings, the deep-copy method becomes an obvious place to review.

For a production model I would also add tests whenever a new mutable property is introduced. A copy method can be correct today and quietly become incomplete after the model grows. Compilation cannot detect that kind of ownership mistake.

How the Demo Proves the Difference

The runnable program first creates an original template. It makes the top-level copy, verifies that the two arrays are different objects, and then verifies that their first tag is the same object. After appending -edited through the copy, it checks that the original now reads ios-edited. That is the failing behavior from the real editor, reduced to a command-line example.

Next, the program creates an independent copy. It verifies that the template, first tag, and metadata dictionary are different objects. It edits the independent tag and delivery channel, then checks that the original still has its previous tag and still uses the push channel. Seven assertions print PASS, and the process exits immediately if any assumption is false.

I find behavioral checks more valuable than a diagram alone. A diagram can explain the expected graph, while the assertions tell us what the runtime actually did. They also protect the example from future changes.

Why I Do Not Use Archiving as a Generic Clone Button

A serialization round trip is sometimes suggested as an easy deep-copy technique. It can produce a separate graph for types designed for secure coding, but I do not use it as a universal cloning shortcut. Every participating type must support the chosen coding contract, identity relationships need thought, failures must be handled, and the extra encoding work can be expensive.

Archiving is appropriate when serialization is already part of the model’s design, such as persistence or data transfer. For a small in-memory editing model, an explicit copy method is easier to read, test, and maintain. It also prevents an unrelated persistence decision from defining the model’s ownership semantics.

A Practical Review Checklist

  • List every mutable object reachable from the model.
  • Decide which values must be editable without affecting the source.
  • Check nested elements, not only the outer collection address.
  • Treat each class’s copy behavior as its own contract.
  • Test mutations on the copy and assert that the source is unchanged.
  • Review the copy implementation whenever the model gains a property.

The lesson I kept from this bug is that deep and shallow are not labels I can assign from a method name. They describe whether the resulting object graph is independent enough for a particular job. Once I define that job in terms of allowed mutations, the correct implementation becomes much easier to see.

Download and Run the Example

The project requires macOS and the Xcode Command Line Tools. Clone the repository, enter its directory, and run the Make target:

git clone https://github.com/2252408699/objc-deep-shallow-copy-demo.git
cd objc-deep-shallow-copy-demo
make run

A successful run prints seven PASS lines and ends with All copy-semantics checks passed. You can run make clean afterward to remove the executable.

Complete source code: objc-deep-shallow-copy-demo on GitHub

Seren
Seren

A developer exploring iOS, Objective-C, Swift, and other tech stacks. Here to document my learning process, pitfalls, and growing journey across new technologies.

Your email address will not be published. Required fields are marked *