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.