6

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() (in viewWillAppear) 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 line service.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.

Screenshot of crash

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()
}
Phlippie Bosman
  • 5,378
  • 3
  • 26
  • 29
  • You are getting crash at this line `self.service.stop()` or under `stop()` method? Can you pls post the screenshot of it? – Sohil R. Memon Jul 10 '19 at 07:17
  • It only crashes if `thisGetsCalledSometimes` was not called, so I'm pretty sure the issue is not with the `stop()` method, but rather with the fact that `service` gets accessed during deinit, which causes the initialisation closure to run, which tries to reference self. This is just example code based off my actual code. I'll build the example and post a screenshot shortly. – Phlippie Bosman Jul 10 '19 at 07:20
  • `MyService` is a class? – Sohil R. Memon Jul 10 '19 at 07:23
  • 1
    Related: https://stackoverflow.com/q/44259194/1187415. – Martin R Jul 10 '19 at 07:25
  • It's a class yes. Dammit, I didn't see that question and answer :) That's the same problem I have. So the solution requires a flag? I was hoping for something more elegant, but that will do fine. Thanks! – Phlippie Bosman Jul 10 '19 at 07:28

1 Answers1

1

I just created of what you said like below:

protocol Hello {
    func thisGetsCalledSometimes()
}

class MyService {

    var delegate: Hello?

    init(key: String) {
        debugPrint("Init")
    }

    func start() {
        debugPrint("Service Started")
    }

    func stop() {
        debugPrint("Service Stopped")
    }
}

class MyClass: Hello {

    lazy var service: MyService = {
        // To init and configure this service,
        // we need to reference `self`.
        let service = MyService(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()
    }
}

and I access like this: var myService: MyClass? = MyClass() which got me the below output:

"Init"
"Service Stopped"

Is that what you are looking for?

Update:

Here is I have edited your class based on tagged answer.

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")
    }

    deinit {
        delegate = nil
    }
}

class SecondController: UIViewController {

    private var isServiceAvailable: Bool = false

    private lazy var service: Service = {
        let service = Service()
        service.delegate = self
        //Make the service available
        self.isServiceAvailable = true
        return service
    }()

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
//                service.start() // <- Comment/uncomment to toggle crash
    }

    deinit {
        if self.isServiceAvailable {
            self.service.stop()
        }
    }
}

extension SecondController: ServiceDelegate {
    func service(_ service: Service, didReceive value: Int) {
        print("Value: \(value)")
    }
}

This is the only option, I think! Let me know if you find anything interesting.

Sohil R. Memon
  • 9,404
  • 1
  • 31
  • 57
  • 2
    Hmm, interestingly, I'm experiencing the same thing in a playground -- no crash. Let me investigate what's actually causing the crash. – Phlippie Bosman Jul 10 '19 at 07:33
  • 1
    Seems like the crash occurs when `MyClass` is a view controller that gets deinited by popping it off the stack, and not when it is a straightforward Swift class. See my edited question for a minimal app that reproduces the crash. – Phlippie Bosman Jul 10 '19 at 08:14
  • @PhlippieBosman add the new answer for others and your proper understanding – Sohil R. Memon Jul 10 '19 at 08:34
  • 1
    > This is the only option, I think! I don't think it's the only one (you could do something similar with optionals, and I'm looking into the Box paradigm), but it is a nice and simple one. – Phlippie Bosman Jul 10 '19 at 08:52
  • I tried with optional too make `Service` class as optional but there also `self` comes in picture, so you can't go with that approach. – Sohil R. Memon Jul 10 '19 at 08:55