ARC in Swift — A Detailed Explanation

Ajaya Mati
9 min readJul 2, 2023

--

Photo by Gary Chan on Unsplash

Intro

The memory management mechanism is different for every language. But it’s the very core of programming and building robust, feature-rich applications. In Java, we have GC (garbage collector), but in languages like Obj-c or C++, we developers are responsible for the allocation and deallocation of memory manually. But thanks to Apple, we have ARC in Swift which automatically tracks and manages the allocation and deallocation of objects.

In most cases, we don't have to do anything for ARC to work, but there can be cases where ARC will need our help.

How ARC (Automatic Reference Counting) works?

Structs and Enums are value types, whereas Classes are reference types. So reference counting only applies to Class Instances.

Value-type instances are typically stored directly on the stack or as part of another value, and their memory is automatically managed by the compiler. When a value type goes out of scope or is no longer needed, it is automatically deallocated without the need for reference counting or manual memory management.

Okay, Let’s take an example.

// Created a class
class SomeClass {
// Instance Property
var id: String

// Intializer
init(id: String) {
self.id = id
print("SomeClass with id \(id) has been initialized")
}

deinit {
print("SomeClass with id \(id) has been de-initialized")
}
}

var a: SomeClass? = SomeClass(id: "A") // Reference count of instance is now 1
// Prints "SomeClass with id A has been initialized"
var b: SomeClass? = a // Reference count inscraeses to 2
var c: SomeClass? = a // Reference count inscraeses to 3

We declared a class named SomeClass and created three references to the same instance. As they are all referring to the same instance the initializer will get called only once.

Now the instance is being referred by a, b, and c, so the reference count is 3.

a = nil // decreases the reference count by 1, remaining 2
b = nil // decreases the reference count by 1, remaining 1
c = nil // decreases the reference count by 1, remaining 0

// Prints "SomeClass with id A has been de-initialized"

Setting a, b, and c to nil will break the reference and upon reaching a reference count of 0, the instance will get de-initialized, thus calling the deinit.

Okay. So now we know how ARC works. But will this always be simple?

Let’s take another example.

class A {
var b: B?

deinit {
print("Deinitialized A")
}
}

class B {
var a: A?

deinit {
print("Deinitialized B")
}
}

var a: A? = A()
var b: B? = B()

a?.b = b
b?.a = a

a = nil
b = nil

If you run the above code, you won’t see any print statement. i.e. both the instance will not get deinitialized. Let’s assess what is happening here.

Class A & B both refer to each other and var a and b refer to respective instances.

So instances of class A and B are both having a reference count of 2.

When I set the var a to nil, instance A will not get de-initialized as instance B still holds a reference to instance A.

Setting b to nil won’t be able to de-initialize instance B as instance A which has not yet been de-initialized still holds a reference to instance B.

The above case is an example of Retain Cycle. Here both instances will never get de-allocated and thus will cause a memory leak and we don’t want that.

Resolving Retain Cycle

Well, not to worry ARC provides us with 3 types of reference with which we can solve Retain Cycle.

The three types of references are

  1. Strong (default)
  2. Weak
  3. Unowned

By default, references are strong which increases the ARC. Whereas weak & unowned will not increase the ARC.

The use of both weak and unowned can solve the Retain Cycle issue. But the selection of weak or unowned depends upon the relation and life cycle of both instances.

Weak Reference

A weak reference doesn’t keep a strong hold on the instance. A weak reference is always defined as an optional type as ARC automatically sets the reference to nil when the instance gets deallocated.

Property Observers don’t get called when ARC sets the value to nil.

class A {
var b: B? // A holds a strong reference to B

deinit {
print("Deinitialized A")
}
}

class B {
weak var a: A? // Now B holds a weak reference to A

deinit {
print("Deinitialized B")
}
}

var a: A? = A() // Initalizing A
var b: B? = B() // Initializing B

// setting the references
a?.b = b
b?.a = a

Here in this example, we made the reference to A inside B class a weak reference. Let’s visualize what's happening.

Instance B has 2 strong references, so the ARC for instance B is 2. But Instance A only has 1 strong reference so the ARC is 1.

a = nil // Sets the reference to nil will break the reference
// prints Deinitialized A
b = nil
// prints Deinitialized B

Setting a to nil breaks the only strong reference to instance A, so ARC will deallocate this instance. Now instance B is left with a single reference which is var b. So when we set it to nil, ARC will deallocate the instance B.

b = nil // Sets the reference to nil will break the reference
// still instance B has another strong reference which is
// from the instance A so deinit will not get called here

a = nil // This breaks the only reference to the instace A
// prints Deinitialized A

// Now instance B has no strong reference
// prints Deinitialized B

Changing the order in which we are setting the value of variables to nil, will not change the order of deallocation of the objects. In both scenarios, A will get deallocated first, and then B.

a = nil // prints Deinitialized A
print(b?.a) // Prints nil

b = nil // prints Deinitialized B

Here we can see when instance A gets de-initialized, the reference to this instance inside B is automatically updated to nil by ARC.

Unowned Reference

The’s two major differences between weak and unowned.

  1. Weak is always declared as optional, but there are no such constraints with unowned.
  2. For weak references, ARC automatically sets the reference to nil. But for unowned references, we are responsible to manage the instance references. Accessing an already de-initialized instance will cause a run-time exception.

Weak references are always used when the referred instance has a lesser lifetime and unowned is used when the referred instance has same or longer lif-time.

class A {
var b: B? // A holds a strong reference to B

deinit {
print("Deinitialized A")
}
}

class B {
unowned var a: A // Now B holds a unowned reference to A

init(a: A) {
self.a = a
}

deinit {
print("Deinitialized B")
}
}

var a: A? = A() // Initalizing A
var b: B? = B(a: a!) // Initializing B

// setting the references
a?.b = b

We changed the reference type to unowned.

b = nil
print(a?.b) // prints instance B
a = nil
// prints Deinitialized A
// prints Deinitialized B

The above code will work just fine. But what if we change the order?

When we set a to nil, instance A gets de-initialized. Now if we try to access the reference from instance B will get a run time exception. So whenever we are using unowned we have to be a little extra careful.

But unowned can have its own use cases where we are very sure of the instance lifetime and we want to avoid dealing with optional. Such use cases are seen with closure very often.

Closure And ARC

The retention cycle issue can also be caused between a closure and a class instance as closures are also reference types. Similar methods are applied to solve this issue. Closures use a capture list to specify the references.

class SomeClass {
var x: Int

lazy var displayValue: () -> Void = {
print("The instance has a value of ", self.x)
}

init(x: Int) {
self.x = x
}

deinit {
print("De initalized SomeClass")
}
}


var instance: SomeClass? = SomeClass(x: 6)
instance?.displayValue() // prints: The instance has a value of 6

instance = nil

Declaring displayValue as lazy lets us useself inside the closure. lazy property will not be accessed until after initialization has been completed and self is known to exist.

See, in the above code SomeClass instance holds a strong reference to displayValue closure and the closure also holds a strong reference to the self, which is the current instance of the class.

To solve this issue we have to change the reference to self as weak/unowned. Here, as the closure and class have the same lifetime unowned should be preferred.

lazy var displayValue: () -> Void  = { [unowned self] in
print("The instance has a value of ", self.x)
}

Setting instance to nil, will now de-allocate the instance calling the de-initializer.

instance = nil
// prints De initalized SomeClass

For the sake of example, we took the closure but for this particular use case class method could have been better.

  func displayValue() {
print("The instance has a value of ", self.x)
}

We don't need to capture self for methods and ARC works just fine as the method doesn't affect the strong reference count. But what if, we call a closure inside this function which has a longer lifetime than the class instance?

class SomeClass{
....
func displayValue() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
print("The instance has a value of ", self.x)
}
}
....
}


var instance: SomeClass? = SomeClass(x: 6)
instance?.displayValue()
instance = nil

The closure inside displayValue has a strong reference toself and is getting called after 2 sec. So even if we set instance = nil , the de-initializer will get called when it’s finished execution of the closure, i.e

// After 2.0 sec
// prints The instance has a value of 6
// prints De initalized SomeClass

But if we use weak self?.x inside the closure will return nil as the instance gets de-allocated and doesn’t wait for the closure to finish execution.

....
func displayValue() {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
print("The instance has a value of ", self?.x)
}
}
....

instance?.displayValue()
instance = nil
// prints De initalized SomeClass
// After 2.0 sec
// prints: The instance has a value of nil

In contrast, if we use unowned, self.x will throw a runtime exception as the instance gets de-allocated as soon as we set instance = nil.

....    
func displayValue() {
DispatchQueue.global().asyncAfter(deadline: .now() + 2.0) { [unowned self] in
print("The instance has a value of ", self.x)
}
}
....
instance?.displayValue()
instance = nil
// prints De initalized SomeClass
// After 2.0 sec
//Thread 3: signal SIGABRT

Bonus Section

If a class Instance doesn't have any strong reference it’ll immediately get de-allocated.

autoreleasepool

For swift classes, ARC is able to properly manage the allocation and deallocation. But for pure Obj-c legacy classes sometimes can cause issues, like writing a loop that creates many temporary objects.

autoreleasepool block can solve this issue. The objects which get created inside this block will get de-allocated once the block completes execution.

In Obj-c autoreleasepool blocks are marked with @autoreleasepool, and in Swift, we can use autoreleasepool with a closure.

for i in 0...10000 {
autoreleasepool {
let file = "file_path_\(i)" // file path for ith image
let url = URL(fileURLWithPath: file)
let image = try! Data(contentsOf: url)
}
}

--

--

Ajaya Mati

iOS Engineer@PhonePe, IITR@2022 | Swift, UIKit, Swift UI