Max Desiatov
Max DesiatovI'm Max Desiatov, a Ukrainian software engineer.

Unbreakable reference cycles in Swift no one is talking about

17 December, 2018

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.

Memory leaks!

Reference cycle = memory leak!

Wait, remind me how memory management works in Swift

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:

  1. A value of type S is allocated and assigned to original.
  2. Storage of original is copied to copy.
  3. On return from function f1 value original goes out of scope and is deallocated.
  4. The return value from f1 is copied to result.
  5. copy itself is deallocated as out of scope.
  6. When 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 classes, 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.

Ok, and what’s a reference cycle then?

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!

How do I avoid reference cycles with classes?

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.

Classes cause reference cycles, reference cycles are bad. Are classes bad?

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.

So what’s that kind of reference cycles no one’s talking about?

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. 😞

Think twice before storing a closure as a property of a value type

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.

Summary

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.