I am experiencing a crash related to lazy vars in Swift. The cause of the crash is straightforward to understand, but I don't know of a good way to prevent it without losing the advantages that I gain by using the lazy var.
I have a class that lazily creates an instance of a service when it is used. The service instance must be stopped if it was started, but it is not necessarily started every time.
class MyClass {
lazy var service: MyService = {
// To init and configure this service,
// we need to reference `self`.
let service = MyService(key: self.key) // Just pretend key exists :)
service.delegate = self
return service
}
func thisGetsCalledSometimes() {
// Calling this function causes the lazy var to
// get initialised.
self.service.start()
}
deinit {
// If `thisGetsCalledSometimes` was NOT called,
// this crashes because the initialising closure
// for `service` references `self`.
self.service.stop()
}
}
How can I avoid this crash, preferably while keeping the lazy var, and without introducing too much new maintenance?
EDIT:
I couldn't represent the crash in a playground, but I could when I built this scenario into a view controller. To reproduce, create a new Xcode project with a single view controller template, and replace the code in ViewController.swift with the following:
import UIKit
// Stuff to create a view stack:
class ViewController: UINavigationController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
let firstController = FirstController()
let navigationController = UINavigationController(rootViewController: firstController)
self.present(navigationController, animated: false, completion: nil)
}
}
class FirstController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let button = UIButton()
button.setTitle("Next screen", for: .normal)
button.addTarget(self, action: #selector(onNextScreen), for: .touchUpInside)
self.view.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
button.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive = true
button.centerYAnchor.constraint(equalTo: self.view.centerYAnchor).isActive = true
}
@objc func onNextScreen() {
let secondController = SecondController()
self.navigationController?.pushViewController(secondController, animated: true)
}
}
// The service and view controller where the crash happens:
protocol ServiceDelegate: class {
func service(_ service: Service, didReceive value: Int)
}
class Service {
weak var delegate: ServiceDelegate?
func start() {
print("Starting")
self.delegate?.service(self, didReceive: 0)
}
func stop() {
print("Stopping")
}
}
class SecondController: UIViewController {
private lazy var service: Service = {
let service = Service()
service.delegate = self
return service
}()
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// service.start() // <- Comment/uncomment to toggle crash
}
deinit {
self.service.stop()
}
}
extension SecondController: ServiceDelegate {
func service(_ service: Service, didReceive value: Int) {
print("Value: \(value)")
}
}
When the app is launced, it will show a view controller with a "Next screen" button. Tapping this button pushes a second view controller to the navigation stack. Tapping the back button in the nav bar will reproduce the issue:
- If
service.start()
(inviewWillAppear
) is left uncommented, the service gets initialised, and no crash occurs during deinit when the back button is tapped. - If
service.start()
is commented out, the service does not get initialised before deinit. Then, when tapping the back button, the app crashes on the lineservice.delegate = self
.
In the minimal example, the crash produces the following error, which I did not see in my actual app:
objc[88348]: Cannot form weak reference to instance (0x7facade14650) of class TestDeinitWithLazyVar.SecondController. It is possible that this object was over-released, or is in the process of deallocation.
It's interesting that the crash only occurs when UIKit is involved, but I think the playground example still specifies the problem: I would like to avoid initialising a lazy variable during deinit. With that problem specification, as @Martin R pointed out, this flag-based solution should suffice.
Now I'm left wondering why it crashes with a view controller though!
EDIT 2:
Seems like it's not UIKit
that causes the scenario to produce a crash, but using an NSObject
-derived class. Here's a minimal example that produces the crash in Playground:
import Foundation
protocol MyServiceDelegate: class {}
class MyService {
weak var delegate: MyServiceDelegate?
func stop() {}
}
class MyClass: NSObject, MyServiceDelegate {
lazy var service: MyService = {
let service = MyService()
service.delegate = self
return service
}()
deinit {
print("Deiniting...")
self.service.stop()
}
}
func test() {
let myClass = MyClass()
}
test()
UPDATE 19 July 2019:
I've just come across this proposal for property wrappers in Swift, which would provide some elegant solutions to the problem. For instance, we could extend the lazy property wrapper to provide the value if it is initialised, or else return nil (note: code is not tested):
extension Lazy<T> {
var ifInitialised: T? {
guard case . initialized(let value) = self else { return nil }
return value
}
}
Then we could simply do
deinit {
self.service.ifInitialised?.stop()
}