More Reference Cycles for Closures Questions

I wanted to experiment with closure cycles some more. So I started with the example in the Swift documentation.

class HTMLElement {
    let name: String
    let text: String?

    lazy var asHTML: () -> String = {
        if let text = self.text {
            return "<\(self.name)>\(text)</\(self.name)>"
        } else {
            return "<\(self.name) />"
        }
    }

    init(name: String, text: String? = nil) {
        self.name = name
        self.text = text
    }

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

var element = HTMLElement(name: "Travis", text: "Griggs")
print(element.asHTML()) // force the cycle
element = HTMLElement(name: "Bat", text: "Man") // encourage first element to deallocate

As expected, no hint of deinit because of the cycle. I can break the cycle if I just initialize asHTML to be a closure that doesn't capture self. E.g.

var element = HTMLElement(name: "Travis", text: "Griggs")
element.asHTML = { "No Cycles Here" }
print(element.asHTML()) // do the print
element = HTMLElement(name: "Bat", text: "Man") // encourage first element to deallocate

OR I can change the asHTML default initialization to include:

{ [ weak self] in
    guard let self = self else { return "-from-the-dead-" }
    ...
}

Also does the right thing. But an approach that I thought would work, does not seem to. Many of my closures often just look like

{ self.doSomething() }

where doSomething basically has the same signature as the closure signature. In that case, it seems that the closure is just an extra wrapper. Imagine if I add a method to HTMLElement which has the () -> String signature:

extension HTMLElement {
    func defaultHTML() -> String {
        return "\(self.name) = \(self.text ?? "(there is no text)")"
    }
}

and then set the asHTML property directly to that:

var element = HTMLElement(name: "Travis", text: "Griggs")
element.asHTML = element.defaultHTML
print(element.asHTML())
element = HTMLElement(name: "Bat", text: "Man") // encourage first element to deallocate

For some reason, I though that this would not create a cycle. But I appear to be wrong? element does NOT deinit. It's a bit counterintuitive because I didn't use braces to make a closure. But it appears to that referencing a method of a live instance does exactly that? Is that what is going on? Assuming that there's not another explanation, is there a way to break the cycle in this case? Or do I always need to be explicit about my closures with something like:

element.asHTML = { [weak element] in element?.defaultHTML() ?? "-yo-text-be-gone-" }

Ok I was all set to reply with good reasons for how everything works, but now I've confused myself too!

If you replace the closure assignment with this:

element.asHTML = { element.defaultHTML() }

Then the deinit is called. However, as you pointed out, using this form doesn't call the deinit as a cycle is created:

element.asHTML = element.defaultHTML

I would have thought that both of these cases would be equivalent and both would lead to a retain cycle but now I'm questioning my life choices.

It must do. Conceptually, all instance methods are passed an implicit extra argument containing self. As a result, to pass self to an instance method, it needs to be stored somewhere, and so you must close over it. After all, if the self of the method you want to call got deallocated, you wouldn’t be able to call the method at all!

2 Likes

As I noted in my reply above, there's apparently different behavior here depending on if you capture the element instance yourself in your own closure or let Swift do it for you. I expected them both to be retain cycles, and yet that is not what I'm seeing in a playground.

With some analysis I think this is the result of the optimiser getting a bit clever. Specifically, the compiler is capable of performing some escape analysis, and seems to be able to observe that either the closure is deterministically dropped after assignment or that the fact that the class was stack allocated means the closure context can be as well, and so deallocates the closure context early. I haven’t dived into the SIL enough to know which is which.

You can restore the expected behaviour by moving the initialisation of the first HTMLElement into a function called foo:

func foo() -> HTMLElement {
    var element = HTMLElement(name: "Travis", text: "Griggs")
    element.asHTML = { element.defaultHTML() }
    return element
}

var element = foo()
element = HTMLElement(name: "Bat", text: "Man")

The above code never invokes deinit. Note that if you compiled with optimisations turned on the reference cycle may well go away again, as the optimiser can work harder to observe the fact that the element has a fixed deterministic lifetime.

1 Like

Ok, this makes a lot of sense. Order has been restored. :slight_smile:

So does the compiler basically generate the same machinery around this type of send as it would for a regularly expressed closure?

I'm guessing there is no way then to do just the self.methodReference and yet annotate/indicate that the self ought to be bound weakly.

<tongueInCheek>Maybe I should write a proposal for one more interpretation of the ? character:

element.asHTML = element?.defaultHTML

And the binding machinery would then capture element weakly as well as dispatch optionally.</tongueInCheek>

Is there any way to "peek under the hood" in cases like this to be able to to really know what's going on? For example, is it possible to get at an object's ref count? Even just for debugging purposes?

CFRetainCount() is available in Swift but of course only use it for debugging. Instruments can display the retain/release history of objects as well (although in some cases the display isn't sensible due to je ne sais pas).

https://developer.apple.com/documentation/corefoundation/1521288-cfgetretaincount

Yes, I have used this in the past (although use at your own risk!):

// Strong refcount
@_silgen_name("swift_retainCount")
public func _getRetainCount(_ Value: AnyObject) -> UInt

// Unowned refcount
@_silgen_name("swift_unownedRetainCount")
public func _getUnownedRetainCount(_ Value: AnyObject) -> UInt

// Weak refcount
@_silgen_name("swift_weakRetainCount")
public func _getWeakRetainCount(_ Value: AnyObject) -> UInt

var object = SomeSwiftObject()
...
print(_getRetainCount(object)) // Strong refcount of 'object'

These runtime methods are defined here.

Yes. In essence there is a closure here, you've just declared it implicitly. This makes sense if you consider it from a low-level perspective. A closure is an object with a specific in-memory representation (namely two pointers, one a function pointer and one a pointer to a closure context) and runtime requirements (closure contexts are refcounted objects and must be retained/released, for example). Anything you assign into a closure variable must meet those requirements, so when you write this shorthand code you have nonetheless written a closure. The machinery is not exactly the same, for some interesting optimiser reasons, but it's as-if they were the same.

Nope, not that I am aware of.

I don't particularly recommend looking at refcounts for this kind of examination. Outside the smallest of test cases, there are only two refcount values that matter: 1 or "more than 1". Swift is still not optimal in its retain/releasing, meaning that there are many cases where you will see transient spikes in refcounts due to Swift not spotting an ARC optimisation it could have made.

Note that knowing the refcount doesn't really tell you "what's really going on", except inasmuch as you learn about refcount changes. Those are good to know, but as we've learned in this thread, knowing that a refcount occurred (or didn't occur) is much less useful than knowing whether, generally speaking, a refcount operation should or should not occur in this situation.

1 Like

I tried these today. I have an object that just will not deinit :/. Unfortunately, they just return static values (e.g. the ref count is at 2 regardless of an objects life cycle). So your "at your own risk" warning was warranted.

Terms of Service

Privacy Policy

Cookie Policy