Deinit question

Just a sanity check here about deinit / arc.

I have a class reference Y. Y contains a property that is a class reference Z. If I make a Y thingy, Z will have a reference count of 1.

An instance of Y has a reference count go to 0. Y's deinit is called.

Within Y's deinit, property Z is explicitly set to nil. ( before Y's deinit is completed) Where the Z deinit happens matters within Y's deinit.

Does the deinit of Z get called immediately when explicitly set to nil? Or is there something odd about this happening within Y's deinit. Would the compiler optimize this away because it thinks deinit of Y is going to set the Z property to nil anyways later when it falls out of scope? Thus, the deinit of Z might not happen where expected?

Thanks in advance.Just a sanity check..

This app:

class Y {
    var z: Z? = Z()
    deinit {
        print("before setting z to nil")
        z = nil
        print("after setting z to nil")
    }
}
class Z {
    deinit { print("Z deinit called") }
}
var y: Y? = Y()
y = nil

produces this output:

before setting z to nil
Z deinit called
after setting z to nil

Now, whether that's explicitly documented and guaranteed to happen – I don't know. Could you depend on this behaviour and expect it to never change? I'd say probably yes, and I would only worry about it when it actually changes in some future OS version (which may never happen, or your code will be rewritten a few times by then, or you won't care anymore, etc, etc. YAGNI).

1 Like

I have a different opinion on this. I think it's a bad idea to base your design on ordering like this, because it will makes it hard to reason about. Also, since the feature (or more likely, implementation detail) is rarely used, it'll be low priority for Apple to fix it if the behavior changes or breaks in future. Why not just use the default behavior where Z.deinit is called after Y.deinit? That's is guaranteed because we know for sure self can be accessed in Y.deinit.

2 Likes

Arguably the compiler must always behave like this - observing the deinit of Z when it it is set to nil - because deinits can have side-effects (as is presumably the case here, otherwise you wouldn't care about the timing) and therefore the compiler must preserve the apparent order of operations (as what's written in the code).

I think it will only reorder deallocations for types with trivial deinits, for this reason (and it's also why not having a 'manual' deinit is generally preferable, for performance).

1 Like

I want to discourage making assumptions about the order of class deinitialization because it is subtle and problematic.

deinit {
    print("before setting z to nil") // <-+
                                     //   |
    z = nil                          // <-+ property assignment is ordered with print
                                     //   | deinit occurs "after" assignment
    print("after setting z to nil")
}

Consider possible output A:

before setting z to nil
Z deinit called
after setting z to nil

and possible output B:

before setting z to nil
after setting z to nil
Z deinit called

The ARC specification says:

By default, local variables of automatic storage duration do not have precise lifetime semantics. Such objects are simply strong references which hold values of retainable object pointer type, and these values are still fully subject to the optimizations on values under local control.

Only output A is legal since no local reference exists, but seemingly insignificant code changes could change the output:

deinit {
    print("before setting z to nil") // <-+
    let tmp = z                      //   | local assignment is unordered
    z = nil                          // <-+ property assignment is ordered with print
                                     //   | deinit occurs "after" assignment
    print("after setting z to nil")  //   | ??
}

Now ARC rules give you either output A or B. Swift's implementation will give you output B, but with subtle caveats. Value types, like arrays, have minimal local lifetimes. And "side effects" that can only be observed synchronously don't carry same weight as those that can be observed asynchronously, like an I/O operation. Programmers who want to rely on ordered deinitialization (precise lifetime semantics) should do so explicitly using either withExtendedLifetime or ~Copyable types.

Arguably the compiler must always behave like this - observing the deinit of Z when it it is set to nil - because deinits can have side-effects

That would arguably be a desirable language behavior, but ARC does not generally give you an ordering between side effects in main code and deinit side effects. When local references exist, class deinitialization is effectively unordered with respect to side effects in the main code. Deinitialization does run synchronously--deinitialization effects are atomic relative to the main code, but the point of execution is not defined relative to side effects. What the deinitializer does is generally irrelevant, as there's no reliable way for the compiler to know what a class deinitializer does (the implementation does have a set of conditions and exceptions, but we can't make such broad statements about deinitialization side effects).

I think it will only reorder deallocations for types with trivial deinits, for this reason (and it's also why not having a 'manual' deinit is generally preferable, for performance).

I don't know of any semantic or performance difference between an explicit ("manual") vs. generated deinit.

5 Likes

Compiler could do that change in principle... It will never happen in practice due to Hyrum's Law...

Appreciate all the comments.

This might also be a contributing factor in my situation -- Assume the property being set to nil is a protocol that is an optional. The protocol does not have AnyObject. ( probably should add that )

Right, but that's essentially user error if it's a problem. It should be known that local variables have indeterminate lifetimes, depending on the compiler's optimisation choices. And there are (I believe) easy and canonical workarounds as necessary, e.g.:

deinit {
    print("before setting z to nil")
    do {
        let tmp = z
        z = nil
    }
    print("after setting z to nil")
}

Ideally, yes, but ~Copyable isn't really here yet. Soon. And I'm not certain it'll be viable in all applicable places anytime soon, if ever - dealing with ~Copyable types is tricky even in isolation, let-alone with the existing APIs that largely don't yet support them (and some likely never will).