The (Not-So) Peculiar Case of Function Retain in Swift
Consider the following Swift code:
class A {
let action: () -> Void = {
print("hello world")
}
deinit {
print("A is deinited")
}
let b = B()
init() {
b.action = action
}
}
class B {
var action: (() -> Void)?
deinit {
print("B is deinited")
}
}
var a: A? = A()
a = nil
// [Console Output]
// A is deinited
// B is deinited
This code may look fine—because it is. However, what if we make the following change?
class A {
deinit {
print("A is deinited")
}
let b = B()
init() {
b.action = action
}
func action() -> Void {
print("hello world")
}
}
class B {
var action: (() -> Void)?
deinit {
print("B is deinited")
}
}
var a: A? = A()
a = nil
// [Console Output]
// (nothing)
Okay, so the action of class A
is changed from an instance variable to an instance method. It should be fine, right? However, the problem is: it isn’t. If you exec the code, you will find the execution result different from the former one. There is a clear retain cycle—when a
is set to nil
, both instances are not released.
But why is it the case?
Now let’s complicate the things a little bit to illustrate.
To complicate the thing
Now let’s put an instance variable of a counter into class A
, and we increment the variable in action.
class A {
var counter: Int = 0
lazy var action: () -> Void = {
self.counter += 1
}
deinit {
print("A is deinited")
}
let b = B()
init() {
b.action = action
}
}
// ...
class A {
var counter: Int = 0
deinit {
print("A is deinited")
}
let b = B()
init() {
b.action = action
}
func action() -> Void {
counter += 1
}
}
// ...
With the two implementations above, we can already see some difference. The implementation using a closure would ask us to explicitly refer to self
as it captures it. But why don’t we need to do that it the method version? Well, because an instance method by definition always implicitly captures self
.
If you don’t see why it is retaining self
(I don’t see any self
here?), maybe thinking of it this way can help:
Any instance method can be written as ClassName.methodName(instanceName)
. For instance, the following two method calls are identical, except that one line uses optional-chaining while the the other line uses a forced-unwrap:
// ...
var a: A? = A()
a?.action()
A.action(a!)()
So when passing an action to the instance variable of B
with action
, we are basically writing A.action(self)
:
class A {
var counter: Int = 0
deinit {
print("A is deinited")
}
let b = B()
init() {
b.action = A.action(self)
}
func action() -> Void {
counter += 1
}
}
class B {
var action: (() -> Void)?
deinit {
print("B is deinited")
}
}
var a: A? = A()
a = nil
// [Console Output]
// (nothing)
If you are not convinced yet
Okay, in case that didn’t help, let’s discuss further.
While it would be obvious in the first place when the retain cycle happened, let’s show it with another piece of code. So what is the behaviour when an instance is not released? While, it lives longer in memory when the reference we have is already away.
With a simpler example:
class MyClass {
var printLine: ((_ line: String) -> Void)?
}
class Printer {
var printedLines: [String] = []
func printLine(line: String) {
printedLines.append(line)
print(printedLines)
}
}
var instance: MyClass? = MyClass()
var printer: Printer? = Printer()
instance?.printLine = printer?.printLine
printer = nil
instance?.printLine?("Hello world")
instance?.printLine?("Goodbye world")
// [Console Output]
// ["Hello world"]
// ["Hello world", "Goodbye world"]
We can see we already lose access to the printer when we set it to nil
, but we can still use the instance through the printLine
variable in the instance
.
Consider we use a closure and we weak
the reference to self
.
class MyClass {
var printLine: ((_ line: String) -> Void)?
}
class Printer {
var printedLines: [String] = []
lazy var printLine: (String) -> Void = { [weak self] line in
self?.printedLines.append(line)
print(self?.printedLines ?? [])
}
}
var instance: MyClass? = MyClass()
var printer: Printer? = Printer()
instance?.printLine = printer?.printLine
printer = nil
instance?.printLine?("Hello world")
instance?.printLine?("Goodbye world")
// [Console Output]
// []
// []
What a big surprise! As the closure does not retain self
, when the printer reference is removed, there is no reference left, the instance of printer would be deinitialised.
The Takeaway
An instance method always implicitly captures self
, so NEVER assign it as closure to another class’s instance, especially when self
owns the instance as it would cause a retain cycle.