Skip to main content

Swift Reference Cycles

Introduction

When working with Swift, memory management is largely handled automatically through Automatic Reference Counting (ARC). However, there's one important scenario every Swift developer needs to understand: reference cycles. These can cause memory leaks in your applications, leading to increased memory usage and potentially degraded performance.

In this guide, we'll explore what reference cycles are, why they're problematic, and how to prevent them using Swift's built-in solutions.

What is a Reference Cycle?

A reference cycle (also called a "retain cycle" or "strong reference cycle") occurs when two or more class instances hold strong references to each other, creating a cycle that prevents ARC from deallocating them when they're no longer needed.

How ARC Works (Quick Refresher)

Before diving deeper, let's quickly review how ARC works:

  1. Every time you create a reference to a class instance, ARC increases its reference count
  2. When a reference goes out of scope, ARC decreases the reference count
  3. When the reference count reaches zero, the instance is deallocated
swift
class Person {
let name: String

init(name: String) {
self.name = name
print("\(name) is being initialized")
}

deinit {
print("\(name) is being deinitialized")
}
}

// Create and release a Person instance
func createPerson() {
let person = Person(name: "John")
// 'person' will go out of scope when this function ends
}

createPerson()
// Output:
// John is being initialized
// John is being deinitialized

In the example above, the Person instance is properly deinitialized when the function ends because its reference count drops to zero.

Creating a Reference Cycle

Let's now create a reference cycle to understand the problem:

swift
class Person {
let name: String
var apartment: Apartment?

init(name: String) {
self.name = name
print("\(name) is being initialized")
}

deinit {
print("\(name) is being deinitialized")
}
}

class Apartment {
let unit: String
var tenant: Person?

init(unit: String) {
self.unit = unit
print("Apartment \(unit) is being initialized")
}

deinit {
print("Apartment \(unit) is being deinitialized")
}
}

// Create a reference cycle
func createReferenceCycle() {
let john = Person(name: "John")
let unit4A = Apartment(unit: "4A")

// Create strong references to each other
john.apartment = unit4A
unit4A.tenant = john

// Function ends, but instances won't be deallocated!
}

createReferenceCycle()
// Output:
// John is being initialized
// Apartment 4A is being initialized
// No deinitialization messages are printed!

In this example, we've created a reference cycle:

  1. john has a strong reference to unit4A
  2. unit4A has a strong reference to john
  3. Even when our local variables go out of scope, the instances still reference each other
  4. Neither instance's reference count reaches zero, so they're never deallocated

Solving Reference Cycles

Swift provides two ways to break reference cycles:

  1. Weak references (weak)
  2. Unowned references (unowned)

Both tell ARC not to increase the reference count for the relationship, but they handle nil values differently.

Weak References

A weak reference doesn't keep a strong hold on the instance it refers to. When the instance has no strong references, it can be deallocated even if weak references to it still exist. Weak references must be declared as optional variables.

Let's fix our previous example:

swift
class Person {
let name: String
var apartment: Apartment?

init(name: String) {
self.name = name
print("\(name) is being initialized")
}

deinit {
print("\(name) is being deinitialized")
}
}

class Apartment {
let unit: String
weak var tenant: Person? // Using weak reference

init(unit: String) {
self.unit = unit
print("Apartment \(unit) is being initialized")
}

deinit {
print("Apartment \(unit) is being deinitialized")
}
}

// Testing the fixed version
func createPersonAndApartment() {
let john = Person(name: "John")
let unit4A = Apartment(unit: "4A")

john.apartment = unit4A
unit4A.tenant = john

// Function ends, both instances should be deallocated
}

createPersonAndApartment()
// Output:
// John is being initialized
// Apartment 4A is being initialized
// John is being deinitialized
// Apartment 4A is being deinitialized

Now both objects are properly deallocated. When the function ends:

  1. Local variables john and unit4A are released
  2. john has no more strong references, so it's deallocated
  3. unit4A now has no tenant (the weak reference becomes nil)
  4. unit4A has no more strong references, so it's also deallocated

Unowned References

Unowned references are similar to weak references in that they don't create a strong reference. However, unowned references:

  1. Are always expected to have a value
  2. Cannot be optional
  3. Will crash if accessed after the instance they point to is deallocated

Use unowned when:

  • You're sure the reference will always have a value
  • The referenced instance has the same or longer lifetime than the instance holding the reference
swift
class Customer {
let name: String
var card: CreditCard?

init(name: String) {
self.name = name
print("\(name) is being initialized")
}

deinit {
print("\(name) is being deinitialized")
}
}

class CreditCard {
let number: String
unowned let customer: Customer // Using unowned reference

init(number: String, customer: Customer) {
self.number = number
self.customer = customer
print("Credit card \(number) is being initialized")
}

deinit {
print("Credit card \(number) is being deinitialized")
}
}

// Testing unowned references
func createCustomerWithCard() {
let alice = Customer(name: "Alice")
alice.card = CreditCard(number: "1234-5678-9012-3456", customer: alice)

// Function ends, both instances should be deallocated
}

createCustomerWithCard()
// Output:
// Alice is being initialized
// Credit card 1234-5678-9012-3456 is being initialized
// Credit card 1234-5678-9012-3456 is being deinitialized
// Alice is being deinitialized

In this example, we use an unowned reference for the customer property of CreditCard. This approach is appropriate because:

  • A credit card always has a customer
  • The customer will always outlive their credit card

Reference Cycles in Closures

Reference cycles can also occur with closures because closures are reference types. When a closure captures self from a class, it creates a strong reference to that instance.

swift
class Presenter {
let name: String
var onComplete: (() -> Void)?

init(name: String) {
self.name = name
print("\(name) is being initialized")
}

func setupClosure() {
// This creates a reference cycle!
onComplete = {
print("Task completed by \(self.name)")
}
}

deinit {
print("\(name) is being deinitialized")
}
}

func testClosureCycle() {
let presenter = Presenter(name: "Alex")
presenter.setupClosure()
// Function ends, but presenter won't be deallocated!
}

testClosureCycle()
// Output:
// Alex is being initialized
// No deinitialization happens!

Capture Lists to the Rescue

To prevent reference cycles in closures, use a capture list to specify how self should be captured:

swift
class Presenter {
let name: String
var onComplete: (() -> Void)?

init(name: String) {
self.name = name
print("\(name) is being initialized")
}

func setupClosureWithWeakSelf() {
// Using weak self in capture list
onComplete = { [weak self] in
guard let self = self else { return }
print("Task completed by \(self.name)")
}
}

func setupClosureWithUnownedSelf() {
// Using unowned self in capture list
onComplete = { [unowned self] in
print("Task completed by \(self.name)")
}
}

deinit {
print("\(name) is being deinitialized")
}
}

func testFixedClosureCycle() {
let presenter = Presenter(name: "Alex")
presenter.setupClosureWithWeakSelf()
// Function ends, presenter will be deallocated
}

testFixedClosureCycle()
// Output:
// Alex is being initialized
// Alex is being deinitialized

When to use [weak self] vs [unowned self]:

  • Use [weak self] when self might become nil during the closure's lifetime
  • Use [unowned self] when you're sure self will never be nil when the closure is called

Real-World Examples

Example 1: Delegation Pattern

The delegation pattern commonly used in iOS development can lead to reference cycles if not handled properly:

swift
protocol DataProviderDelegate: AnyObject {
func didUpdateData()
}

class DataProvider {
weak var delegate: DataProviderDelegate? // Using weak to avoid cycles

func fetchData() {
// Fetch data...
delegate?.didUpdateData()
}
}

class ViewController: DataProviderDelegate {
let dataProvider = DataProvider()

init() {
dataProvider.delegate = self // Would create a cycle if delegate wasn't weak
}

func didUpdateData() {
print("Data updated")
}
}

Example 2: Notification Observers

swift
class NotificationReceiver {
init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleNotification),
name: .someNotification,
object: nil
)
}

@objc func handleNotification() {
// Handle notification
}

deinit {
// Always remove observers to prevent reference cycles
NotificationCenter.default.removeObserver(self)
print("NotificationReceiver deinitialized")
}
}

Example 3: Asynchronous Callbacks

swift
class NetworkManager {
func fetchData(completion: @escaping (Data?) -> Void) {
// Simulated async operation
DispatchQueue.global().async {
// Some data to return
let data = "Data".data(using: .utf8)

// Back to main thread
DispatchQueue.main.async {
completion(data)
}
}
}
}

class DataController {
let networkManager = NetworkManager()

func loadData() {
// Using capture list to avoid a potential reference cycle
networkManager.fetchData { [weak self] data in
guard let self = self else { return }
self.processData(data)
}
}

func processData(_ data: Data?) {
// Process the data
}
}

Best Practices to Avoid Reference Cycles

  1. Always be mindful of object relationships - Think about ownership and lifetimes
  2. Use weak references for delegate relationships
  3. Use [weak self] or [unowned self] in closure capture lists
  4. Consider the object lifecycle when choosing between weak and unowned
  5. Be extra careful with singletons that might hold references to objects
  6. Use value types (structs, enums) when possible, as they don't use reference counting
  7. Add proper deinit methods with debug prints to verify objects are deallocating properly

Summary

Reference cycles are a common source of memory leaks in Swift applications. They occur when two or more instances maintain strong references to each other, preventing ARC from deallocating them.

To prevent reference cycles:

  • Use weak references when an object can be nil during its relationship
  • Use unowned references when you're sure an object will never be nil during its relationship
  • Use capture lists ([weak self] or [unowned self]) in closures to prevent cycles

Understanding and properly managing reference cycles is essential for creating memory-efficient Swift applications.

Additional Resources

Exercises

  1. Create a class hierarchy with a parent-child relationship that avoids reference cycles.
  2. Modify an existing project to identify and fix any reference cycles.
  3. Write a playground that demonstrates the difference between weak and unowned references.
  4. Implement a notification observer pattern that properly cleans up references.

By mastering how to identify and resolve reference cycles, you'll write more reliable Swift applications with better memory performance!



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)