Skip to Content
Course content

22: Automatic Reference Counting Explained

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a PR for a junior dev who was building a music streaming app. Everything looked clean, but during the QA phase, we noticed a weird bug: every time a user switched songs, the app's memory usage crept up slightly. It wasn't a massive jump, but after thirty minutes of listening, the app would simply vanish—a classic OOM (Out of Memory) crash. When we looked at the memory graph, we saw hundreds of SongPlayer and Playlist objects that should have been destroyed minutes ago, but they were still hanging around in memory like ghosts. They were trapped in a "strong reference cycle," and because they were holding onto each other, Swift's memory manager couldn't kill either of them.

How Swift Tracks Your Objects

In Swift, classes are reference types. This means when you assign a class instance to a variable, you aren't making a copy of the data; you're just creating another pointer to the same spot in memory. To keep track of when it's safe to delete that memory, Swift uses Automatic Reference Counting, or ARC.

Think of ARC as a tally system. Every time you create a strong reference to a class instance, Swift increments a counter. When that reference goes out of scope or is set to nil, the counter decrements. Once that counter hits zero, Swift knows that nobody is using that object anymore, and it immediately deallocates the memory. It's incredibly efficient because it happens in real-time, unlike the "Garbage Collection" you see in languages like Java or C#, which pauses the program to clean up in batches.

The Death Grip of Strong Reference Cycles

Most of the time, ARC just works. You don't even think about it. But you'll run into trouble the moment two class instances hold strong references to each other. This is the "death grip" I mentioned earlier.

Imagine a Tenant class and an Apartment class. The tenant has a property for their apartment, and the apartment has a property for its tenant. If both are declared as standard properties, they both hold "strong" references. Even if the rest of your app forgets about the tenant and the apartment, they are still holding onto each other. Their reference counts will never hit zero, and they will leak memory until the OS kills your process.

class Tenant {
    let name: String
    var apartment: Apartment?
    init(name: String) { self.name = name }
    deinit { print("\(name) is being deallocated") }
}

class Apartment {
    let unit: String
    var tenant: Tenant?
    init(unit: String) { self.unit = unit }
    deinit { print("Apartment \(unit) is being deallocated") }
}

var john: Tenant? = Tenant(name: "John")
var unit4B: Apartment? = Apartment(unit: "4B")

john?.apartment = unit4B
unit4B?.tenant = john

// Now, even if we do this...
john = nil
unit4B = nil
// Nothing is printed. The memory is leaked.

Breaking the Cycle with Weak and Unowned

To fix this, you have to tell Swift that one of these references shouldn't keep the object alive. You do this using the weak or unowned keywords. A weak reference does not increment the reference count. Because it doesn't guarantee the object will stay alive, Swift forces weak variables to be optionals. If the object it points to is deallocated, the weak variable automatically becomes nil.

Then there is unowned. It's similar to weak in that it doesn't increment the count, but it's non-optional. You use unowned only when you are 100% certain that the other object will exist for as long as the current object does. If you try to access an unowned reference after the object is gone, your app will crash. I usually lean toward weak unless I have a very specific architectural reason to use unowned—crashing is generally worse than dealing with an optional.

In the example above, making the tenant property in the Apartment class weak would solve everything. When john is set to nil, the Tenant instance's count drops to zero. It gets deallocated, which in turn drops the reference count of the Apartment to zero, and both are cleaned up perfectly.




📋 Practical Task

Fixing the Memory Leak in a Customer-Order System

You are working on an e-commerce backend. You've noticed that Customer and Order objects are staying in memory long after the checkout process is finished. Below is the problematic code. Your task is to modify the classes so that when a customer is set to nil, both the customer and their order are properly deallocated from memory.

class Customer {
    let name: String
    var currentOrder: Order?
    
    init(name: String) {
        self.name = name
    }
    
    deinit {
        print("Customer \(name) deleted")
    }
}

class Order {
    let orderId: Int
    var customer: Customer?
    
    init(orderId: Int) {
        self.orderId = orderId
    }
    
    deinit {
        print("Order \(orderId) deleted")
    }
}

// TEST CASE
var activeCustomer: Customer? = Customer(name: "Alice")
var activeOrder: Order? = Order(orderId: 12345)

activeCustomer?.currentOrder = activeOrder
activeOrder?.customer = activeCustomer

print("Setting references to nil...")
activeCustomer = nil
activeOrder = nil
// Currently, nothing prints. Fix the classes so that both deinit messages appear.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.