Okay. This one is going to feel weird at first. I'm going to warn you in advance so you don't think something is broken when you try it.
Here's what's going to happen: you're going to create a class, make a copy of it, change something in the copy, and then discover that the original changed too.
And you're going to say: "That's not how copying works."
And I'm going to say: "You're right. But for classes, that's exactly how it works. Sit down. Let's talk." ๐ฅ
The Moment That Breaks Your Brain
Let's create a simple class for a ninja character:
class Ninja {
var name: String
var powerLevel: Int
init(name: String, powerLevel: Int) {
self.name = name
self.powerLevel = powerLevel
}
}
Now let's create an instance, copy it, and change something in the copy:
var naruto = Ninja(name: "Naruto", powerLevel: 9000)
var naruto2 = naruto
naruto2.name = "Sasuke"
print(naruto.name) // "Sasuke" ๐ฑ
print(naruto2.name) // "Sasuke"
Wait. We changed naruto2, but naruto changed too?
Yes. That's not a bug. That's how classes work.
The Signpost Analogy
Here's the mental model that makes this click.
When you create a struct, Swift stores the actual data directly in the variable. Copying a struct is like photocopying a document โ you get two completely separate pieces of paper. Change one, the other is untouched.
When you create a class, Swift stores the data somewhere in memory and gives your variable a signpost pointing to that location. Copying a class doesn't copy the data โ it copies the signpost. Now you have two signposts pointing at the same piece of data.
var naruto = Ninja(name: "Naruto", powerLevel: 9000)
// naruto is a signpost pointing to data: {name: "Naruto", powerLevel: 9000}
var naruto2 = naruto
// naruto2 is a SECOND signpost pointing to the SAME data
naruto2.name = "Sasuke"
// You followed naruto2's signpost and changed the data it points to
// naruto's signpost points to the SAME data, so naruto.name is also "Sasuke"
There's only one piece of data. Both variables are just different ways of pointing at it.
Why Would Swift Do This On Purpose?
This isn't a mistake or an oversight. It's a deliberate design decision, and it's actually useful.
Think about a real iOS app. Say you have a user profile that shows up on a settings screen, a home screen, and a notification. If all three screens have their own independent copy of the user data, and the user changes their username โ now you have to update three separate copies. Miss one and you have inconsistent data showing in different parts of your app.
With a class, all three screens are looking at the same piece of data through their own signpost. Change the username once, and every screen that holds a reference to that user sees the updated value automatically. That's exactly what you want for shared state.
This is why Swift calls classes reference types โ you're passing around references (signposts) to data, not copies of the data itself. Structs are value types โ the value is the data, and copying creates a fresh independent copy.
So How Do You Make a True Copy?
Sometimes you genuinely do want a separate copy โ two independent ninjas that can change without affecting each other. Swift doesn't give you that automatically for classes, but you can create it yourself by writing a copy() method that creates a fresh instance with the same values:
class Ninja {
var name: String
var powerLevel: Int
init(name: String, powerLevel: Int) {
self.name = name
self.powerLevel = powerLevel
}
func copy() -> Ninja {
Ninja(name: name, powerLevel: powerLevel)
}
}
Now when you want a true independent copy, you call copy() explicitly:
var naruto = Ninja(name: "Naruto", powerLevel: 9000)
var naruto2 = naruto.copy()
naruto2.name = "Sasuke"
print(naruto.name) // "Naruto" โ unchanged โ
print(naruto2.name) // "Sasuke" โ only this one changed โ
Two signposts, two separate pieces of data. The copy() method creates a brand new Ninja instance in memory with the same starting values โ so now each variable has its own data to work with.
Choosing a Class Sends a Message
Here's something worth holding onto that goes beyond the technical mechanics:
When you choose to use a class instead of a struct, you're making a statement about how you expect the data to be used. You're saying: "I want multiple parts of my code to share and observe the same piece of data."
When you choose a struct, you're saying: "Each owner should get their own independent copy."
Most Swift developers lean toward structs by default precisely because the copy-is-a-copy behavior is simpler and easier to reason about. You reach for a class when shared, synchronized data is exactly what you need โ like a data model that needs to be observed and updated across multiple views in a SwiftUI app.
The signpost is a feature, not a bug. You just have to know it's there.
The One Thing To Hold Onto
Copying a class copies the signpost, not the data. Both copies end up pointing at the same underlying object, so changing one changes what the other sees too.
If you want a true independent copy, write a copy() method that creates a new instance. Swift won't do it for you automatically โ which is intentional, because it forces you to be explicit about something that matters. ๐ธ
This article was written by me; AI was used to improve grammar and readability.
Top comments (17)
Good analogy. The tool that makes it concrete is the identity operator ===: naruto === naruto2 is true because they point at the same signpost, and after a real copy() it flips to false (== compares values, === compares identity). In practice the bug is rarely that obvious line, it is a shared instance passed into a function or held in an array that something mutates far from where you assigned it.
The === identity operator is such a clean concrete tool for exactly this โ because == telling you two things are equal still doesn't tell you whether they're the same object or two objects that happen to have the same values, and that distinction is exactly what matters when you're debugging shared mutation. The "far from where you assigned it" point is where it gets genuinely painful in practice too โ the mutation that breaks your view happens three function calls away from the assignment, nothing looks wrong at either end, and the signpost is the invisible thread connecting them.
"Nothing looks wrong at either end" is exactly why these are so slow to find: both ends pass code review in isolation. The trick I lean on is to stop trusting your eyes and make identity observable: print or log the object identifier at the assignment and at the mutation, and if the two match, you have found your invisible thread in one run instead of an afternoon of staring. Turning "same object?" from a guess into a printed fact is usually the whole fix.
"Turning same object? from a guess into a printed fact" is the right move and I think the reason people don't reach for it faster is that the bug doesn't look like an identity problem from either end, so it doesn't occur to you to check identity. You're staring at a mutation that shouldn't have happened and looking for where the bad code is, not looking for whether two variables are accidentally the same variable. Printing the object identifier shifts the question from "what is wrong with this code" to "are these the same thing", which is a much faster path to the answer once you know to ask it.
That is the sharper framing, and it explains why the fix is cheap but the discovery is expensive: you are debugging the mutation, so you search where the write happens, and the write is innocent. The guilty line is an assignment that ran ten minutes earlier and looked like housekeeping.
The habit I built from getting burned by it: whenever a value changes and no local code changed it, ask "same thing or different thing?" before asking "what is wrong here?" Reference types, mutable defaults in Python, shared dict passed into two objects, same shape every time. Cheap question, and it either eliminates a whole class of suspects or hands you the answer.
"Same thing or different thing?" before "what is wrong here?" is such a good first triage question, because it collapses the search space immediately. If the answer is "same thing", you're looking at a shared reference problem and you stop hunting for a buggy write. If it's "different thing", the identity question is closed and you can focus on the logic. Either way you're not spending an hour in the wrong half of the problem. The fact that it covers reference types, mutable defaults, shared dicts, all the same shape across languages makes it genuinely worth building into muscle memory rather than reaching for it only when you're already stuck.
Muscle memory is the one part I would push back on. This question earns its keep maybe three times a year, and the day it matters you are six hours into a bug with tunnel vision, which is exactly when a habit that depends on remembering does not fire. Cheaper not to depend on it: put identity in
__repr__on the few mutable types that actually get passed around, and every log line and pytest diff has answered the question before anyone thought to ask it.One trap, and I should flag it since I was the one who said print the identifier. Sometimes it is one name at two moments rather than two names at one: the mutable default that is the same list on call five, the pandas slice that was a view. In CPython an id is an address and addresses get reused after collection, so the same id in two log lines minutes apart is not proof unless something held a reference the whole way through. When the question spans time, stamp a uuid at construction and log that instead.
The pushback on muscle memory is fair, three times a year under tunnel vision is exactly the worst condition for a habit that depends on remembering to ask. Baking identity into repr so the question gets answered in every log line before anyone thought to ask it is a much more robust solution, it doesn't depend on mental state at all.
The uuid-at-construction point is the right correction to the id advice too. Id as address works when you're looking at two names at the same moment, but it breaks across time because the address gets reused after collection. Stamping a uuid at construction means the identity travels with the object for its whole life regardless of what the runtime does with memory in between, which is the version that actually holds up when the question spans multiple log lines. Really appreciate you flagging both of those.
Worth closing the loop on the Swift side, since that is where your post lives:
ObjectIdentifierhas exactly the same caveat. It wraps the address, so it is unique only for as long as the object is alive, and a later object can be handed the identifier of one that was deallocated. Same fix applies, if the question spans time rather than one moment, stamp your own id at init and log that.The cost is real though, so I would not put it on everything. Only the few reference types that actually get passed around and mutated, which is usually a much shorter list than it feels like at 2am.
Really useful to have the Swift-specific version closed out, and the caveat carries over exactly as expected since ObjectIdentifier wrapping the address means the same reuse problem applies after deallocation. The fix is the same shape, stamp a uuid at init and log that instead of the address when the question spans time.
The cost qualifier is the right one too. Most reference types in a SwiftUI app aren't actually being passed around and mutated in ways that generate this kind of confusion, and adding identity logging to everything would be noise that buries the signal. The short list of types that actually get shared and mutated is usually obvious in hindsight, even if it doesn't feel obvious at 2am when you're in the middle of debugging it.
Great explanation. The signpost analogy makes reference semantics much easier to visualize.
One thing Iโd add is that implementing copy() becomes much more complex once a class contains nested reference types. A shallow copy may still share internal objects, while a deep copy creates independent copies of the entire object graph. Thatโs where copying semantics become an architectural decision rather than just a convenience method. Nice article!
Really good addition โ and you're right that the copy() method I showed is a shallow copy, which works cleanly for the simple case but quietly breaks down the moment the class holds references to other objects. A deep copy requiring you to recursively copy the entire object graph is a genuinely different problem, and one where the decision about how deep to go becomes architectural rather than mechanical. Worth a follow-up once the series gets to more complex data models โ glad you flagged it! ๐ธ
Exactly. Once object graphs become more complex, โcopyโ stops being a language feature and becomes part of the domain model. Different applications can legitimately require different copying semantics, so there rarely is a single correct implementation.
"Part of the domain model" is the right framing โ because once you're making decisions about how deep to copy, which references to share and which to clone, you're not just implementing a Swift pattern, you're encoding assumptions about your application's data ownership model. Two different apps with the same class structure could legitimately need completely different copy semantics depending on what "a copy" means in their specific context. That's why there's no universal Copyable that Swift can just provide โ the language can give you the tools, but the semantics have to come from you.
Glad weโre on the same page. I think thatโs the key takeaway: the language can define the mechanics, but the domain defines the meaning.
Thanks for the thoughtful discussion. I really enjoyed seeing the conversation evolve beyond Swift syntax into API and architectural design. Looking forward to the rest of the series!
"The language defines the mechanics, but the domain defines the meaning" is a great line to close on, and honestly one of the best takeaways from this whole thread. Really enjoyed the conversation too, it pushed the ideas a lot further than the article itself did. Looking forward to more of these as the series continues!
Some comments may only be visible to logged-in visitors. Sign in to view all comments.