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.
- We will see how to make sure objects are not deallocated before they are used
- 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?
Person
needs access toAddress
object to access itsstreet
propertyAddress
needs access tostreet
property anywaysAddress
needs access toPerson
object to access itsid
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