Every class instance is eventually destroyed. Swift gives you a way to know exactly when that happens โ and the story of how it decides when to pull the trigger is surprisingly interesting.
Here's something you've probably never thought about: what happens to your objects when you're done with them?
With structs, the answer is simple. A struct lives as long as whatever owns it. Create a struct inside a function, the function ends, the struct is gone. Clean, predictable, boring in the best way.
Classes are messier. Remember โ when you "copy" a class, you're not copying the data, you're copying a signpost pointing to the data. That means multiple parts of your code might be holding onto a reference to the same class instance. So when does that instance actually get destroyed? When the first reference goes away? The last?
The answer is: the last one. And Swift has a whole system behind the scenes to track it. ๐ฅ
Automatic Reference Counting โ The Invisible Accountant
Behind every class instance in Swift, there's a hidden counter. Every time you create a new reference to a class instance โ by assigning it to a variable, passing it to a function, storing it in an array โ Swift adds 1 to that counter. Every time one of those references goes away, Swift subtracts 1.
When the counter hits zero, Swift knows: nobody is looking at this anymore. It's safe to destroy it.
This system is called Automatic Reference Counting, or ARC. You never have to manage it yourself โ Swift handles it invisibly, continuously, for every class instance in your app.
Structs don't need ARC because each copy of a struct is its own independent piece of data. When a struct variable goes away, the data goes with it. Simple. No counting needed.
Classes need ARC precisely because the signpost system means multiple variables can point at the same data. Without counting references, Swift would have no way to know when the last one was gone.
Enter the Deinitializer
When Swift's reference counter hits zero and a class instance is about to be destroyed, Swift will call a special method called the deinitializer โ written as deinit.
It's the counterpart to init. Where init runs when an instance is created, deinit runs when the last reference to it disappears.
Let's see it in action with an anime training camp:
class TrainingCamp {
let name: String
init(name: String) {
self.name = name
print("\(name) has opened! ๐๏ธ")
}
deinit {
print("\(name) has been abandoned. Training is over.")
}
}
Now let's create some camps and watch them get destroyed:
var camp1: TrainingCamp? = TrainingCamp(name: "Konoha Training Grounds")
var camp2: TrainingCamp? = TrainingCamp(name: "Sand Village Camp")
var camp3: TrainingCamp? = camp1 // camp1 and camp3 now point to the same camp
camp1 = nil // reference count for Konoha drops to 1 (camp3 still holds it)
camp2 = nil // reference count for Sand Village drops to 0 โ deinit fires!
camp3 = nil // reference count for Konoha drops to 0 โ deinit fires!
Output:
Konoha Training Grounds has opened! ๐๏ธ
Sand Village Camp has opened! ๐๏ธ
Sand Village Camp has been abandoned. Training is over.
Konoha Training Grounds has been abandoned. Training is over.
Notice: camp2 fires its deinitializer as soon as camp2 = nil because that was the only reference. Konoha's deinitializer doesn't fire until camp3 = nil, because even after camp1 was set to nil, camp3 was still keeping the count at 1.
A Few Things Worth Knowing About deinit
You can't call it yourself. deinit is called by Swift automatically when the reference count hits zero. You can define it, but you can't trigger it manually.
It takes no parameters. Unlike init, deinit doesn't accept any arguments. It runs with no input โ it just gets told "you're being destroyed, do whatever you need to do."
Where to put it? Technically anywhere in the class. But there's a lovely convention: put it at the end of the class body. Code should read like a chapter in a book, and deinit is the final scene โ the fin.
The Other Thing About Variables in Classes
While we're on the topic of classes behaving differently from structs, there's one more quirk worth naming.
Remember from the structs chapter: if you create a let constant struct, nothing about it can change โ not its properties, not anything.
Classes don't work that way. A let class means "this variable will always point to the same instance." It does not mean "the instance can't change."
class Ninja {
var powerLevel: Int
init(powerLevel: Int) {
self.powerLevel = powerLevel
}
}
let naruto = Ninja(powerLevel: 9000)
naruto.powerLevel = 9001 // โ
This is fine!
naruto = Ninja(powerLevel: 1) // โ This isn't โ you can't reassign the signpost
let naruto means naruto's signpost is locked โ it will always point to the same Ninja instance. But the data at the end of that signpost? Completely free to change.
This makes sense once you think about the signpost analogy. let locks the signpost in place. It says nothing about what the signpost points to.
Why This Matters in Real iOS Development
ARC and deinitializers matter most when:
- You're managing resources that need explicit cleanup โ network connections, file handles, observers, timers
- You need to debug memory issues and want to know exactly when objects are being created and destroyed
- You're working with complex object graphs where multiple things hold references to the same data
For everyday SwiftUI development you won't write deinit constantly. But understanding that it exists โ and understanding why it exists (because ARC needs a hook for cleanup) โ helps you understand the memory model of your app rather than just hoping things work out.
The One Thing To Hold Onto
Swift counts how many things are pointing at each class instance. When the count hits zero, it calls deinit and destroys the object. That's ARC in a nutshell.
And let on a class means the signpost is constant โ not the data it points to. That's the quirk that trips people up until the signpost analogy finally clicks. ๐ธ
This article was written by me; AI was used to improve grammar and readability.
Top comments (8)
Fun cross-language note: Swift's ARC is almost exactly CPython's memory model. CPython also keeps a reference count on every object and runs the finalizer (del, the deinit analog here) the moment that count hits zero. It also has the identical blind spot the retain-cycle comment points at: two objects that reference each other keep each other's count above zero forever, so pure reference counting leaks them.
The interesting part is the two languages made opposite bets on who cleans up the cycle. Swift leaves it to the programmer with weak and unowned. CPython bolted a separate tracing cycle collector on top of the refcount to find and reclaim those, and even then finalizers on objects inside a cycle were unsafe until PEP 442 fixed it in 3.4. Same core mechanism, two different answers to the exact problem deinit quietly depends on.
The cross-language comparison is really illuminating โ because it shows the retain cycle problem isn't a Swift quirk but a fundamental limitation of pure reference counting that every language using it has had to solve somehow. The two different bets are interesting too: Swift putting the responsibility on the programmer with weak and unowned keeps the runtime simpler but requires more awareness at the call site, while CPython bolting on a tracing cycle collector trades some runtime overhead for not requiring the programmer to think about it โ until the finalizer ordering issues showed up and required PEP 442 to untangle. Same core mechanism, same blind spot, two philosophies about where the complexity should live. Really appreciate you bringing this in โ it puts the deinit limitation in a much broader context than just a Swift implementation detail.
"Where the complexity should live" is the line, and it is the same fork you hit all over systems design, not just memory management. Retries and idempotency, schema validation, even auth: you either push the burden to the caller and keep the core simple, or absorb it centrally and pay the overhead so nobody downstream has to think. Neither is free, and the bug usually shows up where a system half-committed to one and half to the other. Nice to see garbage collection turn out to be an instance of that. Great thread, Gamya.
"Where a system half-committed to one and half to the other" is where so many bugs live across every layer of a system, and it's such a useful diagnostic frame. The half-commitment problem is hard to spot precisely because each half looks reasonable in isolation, the core looks clean and the caller looks careful, but the gap between them is where the assumption slips through. Nice to see garbage collection turn out to be the same structural problem as retries and schema validation, just wearing different clothes. Really enjoyed this thread, thank you for taking it so many layers deep.
"Each half looks reasonable in isolation" is the diagnostic. That is why it survives code review: nobody reviews the gap, only the two sides of it. The cheap defence I have landed on is writing the contract down in one place, who owns retries, who owns validation, who owns cleanup, so the half-commitment becomes a visible disagreement instead of an inherited assumption.
Thanks for a series worth reading all the way down. The best posts are the ones where the comments end up somewhere neither person started.
"Nobody reviews the gap, only the two sides of it" is exactly why it survives, and writing the contract down in one place is the right defense because it forces the gap to exist somewhere visible rather than in the space between two reasonable-looking decisions. A visible disagreement can be resolved. An inherited assumption just accumulates until it fails.
And thank you, genuinely, for this whole series of threads. You're right that the best ones end up somewhere neither person started, and this one did that repeatedly. That's the version of comments that makes writing worth doing.
Nice explanation, especially the โsignpostโ analogyโit makes ARC much easier to visualize.
One thing Iโd add is that deinit is not guaranteed to run simply because an object goes out of scope. If strong reference cycles exist, the reference count never reaches zero, so deinit wonโt be called. Since retain cycles are one of the most common memory issues in Swift, even a brief mention of weak and unowned references would make this chapter even more complete.
Reference: Swift Programming Language โ Automatic Reference Counting
docs.swift.org/swift-book/document...
Really good addition โ and you're right that the article leaves a gap there. Strong reference cycles are probably the most practically important thing to know about ARC, because a deinit that never fires is one of the sneakier memory issues to track down. The "signpost" model I used implicitly assumes references are always eventually released, which breaks down exactly when two objects hold strong references to each other and the count never reaches zero. weak and unowned are the tools that break that cycle, and they deserve their own proper coverage rather than a footnote. Will make sure that gets addressed when the series gets to memory management in more depth โ thanks for flagging it and for the reference!