Deep dive into Swift memory management with ARC

Deep dive into Swift memory management with ARC

It's been a while since my last post. For past few months I was busy with work, personal life and lot of things happening outside it so couldn't really commit to writing another article. However, I am back now and going to write about Swift and its memory management quirks.

This post is based on recent WWDC  ARC in Swift - Basics and Beyond. I have taken inspiration from it, but added examples and explanation of my own to explain it further and add points that I thought would make understanding Swift memory management better

Today, I am going to talk about how memory management works in Swift, how to avoid pre-mature deallocation of objects and tips to avoid retain cycles and control lifecycle of objects which may still be used.

  1. We will see how to make sure objects are not deallocated before they are used
  2. And how to make sure objects are deallocated when they're no longer used

To begin, let's start with two simple classes Person and Address with following fields

1. Person
  1. id: Int
  2. address: Address

2. Address
  1. street: String
  2. person: Person
class Person {
    var id: Int
    var address: Address
    
    init(id: Int, address: Address) {
        self.id = id
        self.address = address
    }
}


class Address {
    var street: String
    var person: Person?
    
    init(street: String) {
        self.street = street
    }
    
    func printSummary() {
        print("\(street) and \(person?.id)")
    }
}

Now that we have Person and Address classes, let's start using them in our code and see what kind of problem it creates,

// Create an instance of Address
let address = Address(street: "Copley Road")

// Create an instance of Person and associate address to it
let person = Person(id: 100, address: address)

// Associate person to address
address.person = person

// Call printSummary function on Person object
person.printSummary()

This works fine. If you run the app now, it will print following in the console,

Copley Road and Optional(100)

But it also presents the problem of memory leak now that both address and person are referencing each other with strong reference as follows,

// Retain count of address = 1
let address = Address(street: "Copley Road")

// Retain count of person = 1
// Retain count of address = 2 since person strongly refers to address
let person = Person(id: 100, address: address)

// Retain count of person = 2 since address strongly refers to person
address.person = person

// Retain count of address decrements by 1 so new retain count is 1
person.printSummary()

// Retain count of person decrements by 1 so new retain count is 1 

// Retain counts of both address and person is 1 so both of them remain in the memory even if control goes out of their scope so there is a memory leak

If you don't believe, you can start a profiler and see the memory usage goes up. Xcode also tells you there is a memory leak pointing at objects causing leak.

Solving memory leak

So how do we solve the memory leak? We can fix the memory leak by changing one of the references to be weak. That way we can break the retain cycle and have it deallocate at expected time. Let's make reference to person from address a weak reference to break this cycle.

class Address {
    var street: String
    weak var person: Person?
    
    init(street: String) {
        self.street = street
    }
}

Everything else remains the same

Now let's run and profile the app again and see if the code is memory-leak-free.

That's great! And everything works out well including message in debug console.

Copley Road and Optional(100)

What if you change build settings to Optimize Object Lifetimes?

Xcode gives you an option to optimize object lifetimes. With this settings, compiler optimizes how long it keeps the object alive. If object isn't needed, it is deallocated immediately.

You can enable this flag by

Xcode -> Build Settings -> Swift Compiler - Code Generation -> Settings -> Optimize Object Lifetimes -> Yes

Let's enable that flag, run the app again and see what output it prints in the console

Copley Road and nil

That's not good?

Why does it print nil where we expect it to print Person's id?

The problem partially lies in Build Setting we just changed and how reference to person object from Address is defined as weak. Let's try to understand the flow,

let address = Address(street: "Copley Road")
let person = Person(id: 100, address: address)

// Everything is happening as expected until this point
address.person = person

// After this point, compiler thinks we ar no longer 
// using person object, and since it's already defined as
// weak, it immediately deallocates it due to optimization
// Even though it's used in printSummary function
address.printSummary() // Prints Copley Road and nil

How can we extend the lifetime of weak objects?

In order to extend the lifetime of objects defined as weak beyond its set lifetime, we can use withExtendedLifetime API which includes which object we want to extend the lifetime for and be sure that it won't be deallocated within the current scope.

let address = Address(street: "Copley Road")
let person = Person(id: 100, address: address)

address.person = person

withExtendedLifetime(person, {
    address.printSummary() // Prints Copley Road and Optional(100)
})

But is it really good?

Manually extending the object lifetime adds extra burden on developer, reader and future maintainers of code. If any of them miss this subtle point and fail to consistently enforce with extension, possibility is, it can sneakily show up again. So better option is to re-architect the code in such a way that we no longer need to use withExtendedLifetime API.

Better solution

In the earlier example, (Before using withExtendedLifetime) we were using weak instance of Person object to print its id. This code was being called when the printSummary method was being called on Address object. Due to Person object being weak, it was getting deallocated prematurely.

To fix the problem, we can move printSummary function inside Person object. We can access street property through associated Address object and Person already has an id property.

class Person {
    var id: Int
    private var address: Address
    
    init(id: Int, address: Address) {
        self.id = id
        self.address = address
    }
    
    func printSummary() {
        print("\(address.street) and \(id)")
    }
}

class Address {
    var street: String
    weak var person: Person?
    
    init(street: String) {
        self.street = street
    }
}

let address = Address(street: "Copley Road")
let person = Person(id: 100, address: address)
address.person = person
person.printSummary() // Prints "Copley Road and 100"

So that's good.

Even better solution? Avoid retain cycle in the first place

What if we could completely eliminate the problem of retain cycles by completely re-thinking the software's design process? By thinking what caused the creation of retain cycle in the first place and how we can re-architect to completely eliminate it?

Both Person and Address objects need reference to each other, but why?

  1. Person needs access to Address object to access its street property
  2. Address needs access to street property anyways
  3. Address needs access to Person object to access its id property

If both Person and Address objects need access to street property, why not just both of them give access to it through third object? In other words, create a tree-structure rather than cyclic graph?

We will make a new class named StreetInfo which will encapsulate and replace streetInfo property and also removes dependence of Person object on Address.

Please refer the code below. Changed code has been accompanied with relevant comments.

// StreetInfo object encodes street property
class StreetInfo {
    let street: String
    
    init(street: String) {
        self.street = street
    }
}


class Address {
    // Replaced street: String with StreetInfo object
    var streetInfo: StreetInfo
    
    // No longer need to make person weak
    var person: Person?
    
    init(streetInfo: StreetInfo) {
        self.streetInfo = streetInfo
    }
    
    func printSummary() {
        print("\(streetInfo.street) and \(person?.id)")
    }
}

class Person {
    var id: Int
    
    // Replace Address object with StreetInfo
    var streetInfo: StreetInfo
    
    init(id: Int, streetInfo: StreetInfo) {
        self.id = id
        self.streetInfo = streetInfo
    }
    
    // No need to have Person object printSummary function
}

Run the app, and see the difference yourself.

See, no memory leak and correctly prints the summary info in console log

Copley Road and Optional(100)

And that is all. So important thing here is to see where things are not going well, understand why, and apply the solution that balances the architectural style, performance, user-experience and correctness without having to rely on solutions that are temporary and easy-to-break with future iOS versions.

I found the WWDC-21 talk ARC in Swift: Basics and beyond very informative and easy to follow-through. It also explained one of the bugs I was experiencing in the app due to early de-allocation of weak reference.

I hope this article is useful to you whether you're simply learning ARC concepts in Swift, re-architecting the app or simply trying to fix memory leaks. Feel free to contact me if you've any follow-up questions on this post or want to share your own experience dealing with in-app memory leaks