Search code examples
swiftvariablesscoperx-swift

Implicitly unwrapped optional var destroyed by compiler before end of scope?


With swift compiler optimizations implicitly unwrapped optional variables do not survive the whole scope, but are released immediately after usage.

Here is my environment:

swift --version

outputs

Apple Swift version 5.3.2 (swiftlang-1200.0.45 clang-1200.0.32.28)
Target: x86_64-apple-darwin20.2.0

Xcode version is Version 12.3 (12C33)

Consider this most rudimentary example that shows the issue:

final class SomeClass {
    
    func doSth() {}
    
    deinit {
        print("deinit")
    }
}

func do() {
    var someObject: SomeClass! = SomeClass()

    someObject.doSth()
    
    print("done")
}

This should ouput

done
deinit

However, in release builds (with Swift code optimizations enabled "-O") it prints the other way round:

deinit
done

This is ONLY the case for var someObject: SomeClass!.

The following alterations of that code ALL output correctly (meaning the Object is released when the scope of the function is left):

Define var as constant:

func doSthSpecial() {
    let someObject: SomeClass! = SomeClass()

    someObject.doSth()
    
    print("done")
}

Define var as optional explicitly:

func doSthSpecial() {
    var someObject: SomeClass? = SomeClass()

    someObject.doSth()
    
    print("done")
}

Access like an optional:

func doSthSpecial() {
    var someObject: SomeClass! = SomeClass()

    someObject?.doSth()
    
    print("done")
}

These last three implementations all output

done
deinit

in that order.

Somehow this leaves me speechless 🤷‍♂️. I understand this optimization, it makes sense. But as a programmer we are used to local variables inside of functions being available until leaving the scope.

The problem I have here is about the lifetime of an object that is stored in such an implicitly unwrapped optional variable. If I have code that depends on the lifetime of this object (which is the case with RxSwift and its DisposeBags for example) then I am getting weird behavior, unexpected behavior!

I could consider this as a bug in Swift, but what do you think? Bug or no bug?

Here is a more real-world scenario with RxSwift where you could be using such a construct:

import UIKit
import RxSwift

final class SomeClass {
    
    func doSth() {}
    
    deinit {
        print("deinit")
    }
}

class ViewController: UIViewController {
    
    let providePassword = PublishSubject<String>()
    lazy var askForPassword: Observable<String> = {
        return Observable.create { observer in
            _ = self.providePassword.subscribe(observer)
            
            return Disposables.create()
        }
        .debug(">>> ask for password signal")
    }()

    private func performAsyncSyncTask() {
        DispatchQueue.global().async {
            var disposeBag: DisposeBag! = DisposeBag()
            
            let sema = DispatchSemaphore(value: 0)
            
            self.askForPassword
                .subscribe(onNext: { pw in
                    print(pw)
                    sema.signal()
                })
                .disposed(by: disposeBag)
                    
            _ = sema.wait(timeout: DispatchTime.distantFuture)
            
            disposeBag = nil
        }
    }
    
    @IBAction func startAskPassword(sender: AnyObject) {
        self.performAsyncSyncTask()
    }
    
    @IBAction func sendPassword(sender: AnyObject) {
        self.providePassword.on(.next("hardcoded pw"))
    }
}

The problem here is: When executing self.performAsyncSyncTask() it is subscribed to askForPassword but because in optimized builds the implicitly unwrapped optional variable is purged immediately after using it in .disposed(by: disposeBag).

This destroys the signal immediately after subscribing to it.


Solution

  • But as a programmer we are used to local variables inside of functions being available until leaving the scope.

    This hasn't been the case since ARC was first released for ObjC. ARC has always had the option to release objects after their last use (and very often makes use of this). This is by design, and is not a bug in Swift (or in ObjC, where it's also true).

    In Swift, if you want to extend the lifetime of an object beyond its last use,withExtendedLifetime is explicitly for this purpose.

    var someObject: SomeClass! = SomeClass()
    
    withExtendedLifetime(someObject) {
        someObject.doSth()
        print("done")
    }
    

    Keep in mind that it is legal for objects to have balanced retain/autorelease calls on them, which may cause them to outlive their scope as well. This is much less common in Swift, but still legal and happens if you pass a Swift object to ObjC (which can happen in many places you may not expect).

    You should be very careful relying on when deinit will be called. It can surprise you, and isn't even promised in all cases (for example, deinit is not called during program quit on Mac, which tends to surprise C++ developers).

    IMO performAsyncSyncTask is a dangerous pattern, and should be redesigned with clearer ownership. I don't do enough RxSwift work to immediately redesign it, but blocking the whole thread on a DispatchSemaphore seems the wrong way to integrate with any reactive system. Threads are a finite resource, and this forces the system to create more while this one is blocked doing nothing.