When using Swift, one of the most important things to consider is a difference between value types and reference types. We know that a static type system is useful to avoid type errors, similarly a guarantee that constant values stay immutable is quite handy to avoid errors in stateful code. Unfortunately, for certain reasons immutability and reference types in Swift are mutually exclusive by default. Also, value types and reference types involve different approaches to memory management: value types are implicitly copied while reference types only change their reference count when passed around. Without proper care, it’s easy to create reference cycles with reference types which cause memory leaks. Would it make sense to avoid classes altogether to avoid that and get immutability when needed as a nice bonus?
Not so fast, there might be more problems than one could anticipate. What follows is a result of my trial and error when using Swift and the last few sections describe nuances that I haven’t seen described anywhere, but in my opinion are quite easy to stumble upon.
If you have good knowledge of how to use weak
keyword and how memory
management works for value and reference types, feel free to skip to the
section with more advanced examples. Otherwise, consider this super
simple code:
struct S {
init() { print("S init") }
}
func f1() -> S {
// one allocation for S()
// "S init" is printed here
let original = S()
// copy of `original` that can be optimised by the compiler
let copy = original
return copy
// both `original` and `copy` out of scope here
}
// copy of `original` that can be optimised by the compiler
var result: S? = f1()
// value stored in `s` is dellocated here
result = nil
Here memory management is trivial:
S
is allocated and assigned to original
.original
is copied to copy
.f1
value original
goes out of scope and is
deallocated.f1
is copied to result
.copy
itself is deallocated as out of scope.nil
is assigned to result
, that result
is deallocated.All of these copies can be optimised away by the compiler and in the generated
optimised code you’d only see one allocation, zero copies and one final
deallocation. This means only steps 1 and 6 would execute (with result
and
original
aliasing the same value), but from programmer’s perspective, the
behaviour would stay the same.
Why the same? Note that "S init"
is printed only once. There’s no notion of
“copy initialiser” in Swift, creating a copy of a value only copies its storage
without calling init
. This means we don’t expect any code to be executed
on these copies and some of them can be optimised away by the compiler without
changing the behaviour of resulting executable code.
Now compare this to memory management with classes:
class C {
init() { print("C init") }
deinit { print("C deinit") }
}
func f2() -> C {
// one allocation for C();
// "C init" printed here;
// also increment its reference count by 1
let reference1 = C()
// increment reference count by 1 (2 in total)
let reference2 = reference1
return reference2
// both `reference1` and `reference2` out of scope here;
// decrement reference count by 2;
// no deallocation because of a new reference created
// for the return value outside of this function
}
// increment reference count by 1, 1 in total
var result: C? = f2()
// decrement reference count by 1, 0 in total
result = nil
// "C deinit" printed here
Swift doesn’t allow adding deinitialiser code to a struct
with deinit
, but
it’s possible for class
es, which allows us to better track deallocations. Now
memory management is a bit more involved. With this code,
you’re guaranteed only one allocation and no copies will occur, but this also
involves allocating storage for a counter that tracks all references. There’s
also runtime overhead now for incrementing and decrementing the counter. In
addition, reference counting in Swift is thread-safe, but this involves using
atomic operations. This adds
even more potential runtime overhead when compared to optimised code using
only value types.
Consider a tree-like data structure, where a node in this tree holds a reference to its children. It would be nice for a node to also hold a reference to its parent. Here’s a naive approach that expresses this with a class:
class TreeNode {
private(set) var children = [TreeNode]()
private(set) var parent: TreeNode?
init() { print("TreeNode init") }
deinit { print("TreeNode deinit") }
func add(child: TreeNode) {
children.append(child)
child.parent = self
}
}
func f3() {
let root = TreeNode()
// "TreeNode init" printed here
for _ in 0..<3 {
root.add(child: TreeNode())
// "TreeNode init" printed here
}
}
f3()
// "TreeNode deinit" printed 4 times here, no??? 🤔
Well, here’s a problem: a child and a parent both hold references to each other,
which means their reference counts will never reach 0
. So even if it looks
like all references to TreeNode
instances went out of scope when returning
from f3
, none of those instances were deallocated and you’ll never see
"TreeNode deinit"
printed even once!
Good news is that in Swift any variable of an optional class type can be declared
weak
. It will hold a “weak” reference to the instance without incrementing
its reference count. This also means that this reference becomes nil
when
the instance is deallocated, you can’t have “weak” constants because of this:
// strong reference
var node1: TreeNode? = TreeNode()
// "TreeNode init" printed here
// weak reference, reference count untouched
weak var node2 = node1
// deallocate what's stored in `node1`
node1 = nil
// "TreeNode deinit" printed here
// what's referenced by `node2` then?
print(node2)
// "nil" printed here
Now let’s fix our TreeNode
memory leak with a weak reference:
class TreeNode {
private(set) var children = [TreeNode]()
// note added `weak` added before `var parent`
private(set) weak var parent: TreeNode?
init() { print("TreeNode init") }
deinit { print("TreeNode deinit") }
func add(child: TreeNode) {
children.append(child)
child.parent = self
}
}
func f4() {
let root = TreeNode()
// "TreeNode init" printed here
for _ in 0..<3 {
root.add(child: TreeNode())
// "TreeNode init" printed here
}
}
f4()
// "TreeNode deinit" printed 4 times here, yay! 🎉
You may have seen example code like above multiple times in many educational materials about Swift. Memory management based on compile-time guarantees and optimisations has these downsides that you need to be aware of. On balance it pays off as performance impact is predictable and deterministic. In Swift, you don’t need to worry that a garbage collector will unexpectedly pause everything in the middle of carefully optimised code for deallocations. This becomes critical in code that has anything to do with UI, especially AR/VR: you don’t want to drop rendered frames in the middle of a user interaction, especially when you need to deliver 120 freshly rendered frames per second.
Maybe it’s time to reconsider the situation: classes cause reference cycles, why not stop using classes? And besides, protocol-oriented programming is cool, we can use composition instead of inheritance, use structs, enums and protocols for everything, we don’t need those stinky classes with stupid reference cycles! 😜
Huh, that sounds quite attractive. Except that we forgot about yet another important reference type in Swift: closures! Yes, closures are reference types, they need to capture the environment outside to allow modifying that environment when a closure body is executed. This has the same implications we’ve discussed previously: assigning a closure to a variable will increase the reference count for that closure instead of copying it.
You can create a reference cycle with classes, you can create a reference cycle with closures and classes, but you can’t create a reference cycle with closures and value types, right?
No, it turns out you can even create unbreakable reference cycles with closures and value types! 😱 The big problem here is that capturing a value (not a class) in a closure implicitly creates a new reference to that value for closure’s environment:
var i = 0
let increment = { i += 1 }
increment()
print(i)
// prints "1"
So even though i
is a value, not a class instance, i
inside of the closure
increment
is a reference to i
in closure’s environment. Not only increment
stores a reference to the closure, but the closure itself also creates and
stores a new reference!
You could say “just make these references weak”! Sure, that’s easy with a closure environment that only captures class instances:
class I {
var x = 0
var increment: (() -> ())?
}
let i = I()
// explicit capture list for capturing `i` weakly
i.increment = { [weak i] in i?.x += 1 }
i.increment?()
Cool, now i
holds a strong reference to the closure, but closure holds a weak
reference to i
, no reference cycle. Why not try the same with value types?
struct I {
var x = 0
var increment: (() -> ())?
}
var i = I()
i.increment = { i.x += 1 }
// reference cycle!
i.increment?()
What’s worse, if you add [weak i]
capture list to fix the reference cycle you’d
get a compiler error like 'weak' may only be applied to class and class-bound protocol types, not 'I'
. The only way to break the reference cycle is to
explicitly set i.increment
to nil
when it’s no longer needed, which is less
than ideal. 😞
Can we still avoid using classes with the caveat that we clean up our closures manually? I don’t think so, here’s why:
struct I {
var x = 0
var increment: (() -> ())?
}
var i = I()
i.increment = { i.x += 1 }
// reference cycle!
var copy = i
copy.increment?()
copy.increment?()
copy.increment?()
print(copy.x)
// guess what's printed here? 🤨
// break the reference cycle manually
copy.increment = nil
i.increment = nil
Guess what’s the value of copy.x
printed above? 0
! You’d probably expect 3
and that copy
holds a copy of i
and copy.increment?()
increments copy.x
.
But remember that closures and their environment can’t be copied. That’s why
copy
is a “partial” copy of i
. Value of x
has been copied, but property
increment
of copy
holds a reference to increment
of i
with x
of i
in
the captured environment. Calling copy.increment?()
will follow the reference
and increment value of x
on the original i
instead of copy
.
If you find yourself writing code similar to above with value types holding references to closures, take extreme care to avoid unexpected results.
We’ve compared memory management with value and reference types in Swift and had a look at different ways to create and break reference cycles. Reference cycles between different class instances are trivial, but when you start using closures this can get a bit more complicated. The most unexpected kind of reference cycles involves closures and value types and most frequently you would want to rearchitect code using those in a different way. In these situations breaking the reference cycle requires manual handling and can cause subtle errors due to the fact that the closure environment can’t be copied.
Many thanks to Swift Forums for giving an opportunity to discuss these issues. I really enjoyed learning a lot of interesting details about how references work (and could work in future versions of Swift) in this Swift Forums topic.
Are you interested in other memory management features of Swift? Have you been able to work around these reference cycles in a different way? I look forward to hearing your story, please shoot a message on Mastodon or Twitter.
If you enjoyed this article, please consider becoming a sponsor. My goal is to produce content like this and to work on open-source projects full time, every single contribution brings me closer to it! This also benefits you as a reader and as a user of my open-source projects, ensuring that my blog and projects are constantly maintained and improved.