Is it normal that lazy var property is initialized twice?
Asked Answered
G

1

21

I have met quite weird case once I used a property with lazy keyword. I know this keyword indicates that an initialization of the property will be deferred until the variable is actually being used. But, It didn't work as I expected. It run twice.

class TestLazyViewController: UIViewController {

    var name: String = "" {
        didSet {
            NSLog("name self = \(self)")
            testLabel.text = name
        }
    }

    lazy var testLabel: UILabel = {
        NSLog("testLabel self = \(self)")
        let label = UILabel()
        label.text = "hello"
        self.view.addSubview(label)
        return label
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        testLabel.setTranslatesAutoresizingMaskIntoConstraints(false)
        NSLayoutConstraint.activateConstraints([NSLayoutConstraint(item: testLabel, attribute: .CenterX, relatedBy: .Equal, toItem: self.view, attribute: .CenterX, multiplier: 1.0, constant: 0.0)])
        NSLayoutConstraint.activateConstraints([NSLayoutConstraint(item: testLabel, attribute: .CenterY, relatedBy: .Equal, toItem: self.view, attribute: .CenterY, multiplier: 1.0, constant: 0.0)])
    }

    @IBAction func testButton(sender: AnyObject) {
        testLabel.text = "world"
    }
}

I wrote a view controller for test. This view controller is presented by another view controller. Then, name property is set in prepareForSegue of the presenting view controller.

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
    let vc = segue.destinationViewController as! TestLazyViewController
    println("vc = \(vc)")
    vc.name = "hello"
}

On running the test, I got the following result.

vc = <testLazy.TestLazyViewController: 0x7fb3d1d16ec0>
2015-05-25 00:26:15.673 testLazy[95577:22267122] name self = <testLazy.TestLazyViewController: 0x7fb3d1d16ec0>
2015-05-25 00:26:15.673 testLazy[95577:22267122] testLabel self = <testLazy.TestLazyViewController: 0x7fb3d1d16ec0>
2015-05-25 00:26:15.674 testLazy[95577:22267122] testLabel self = <testLazy.TestLazyViewController: 0x7fb3d1d16ec0>

As you can see, an initialization code got executed twice. I don't know if it was either a bug or a result from misusing. Is there anybody who can let me know what was wrong?

I am guessing that referencing testLabel with self.view in the initialization code is incorrect.

UPDATE:
I still don't understand why lazy initialization runs twice. Is it really Swift's bug?

FINAL UPDATE:
@matt has made an excellent explanation for this problem being initialized twice. I was able to get a valuable knowledge of how lazy keyword works. Thanks matt.

Greg answered 24/5, 2015 at 16:4 Comment(5)
"I also have a guess that it isn't correct what testLabel is added to self.view in the initialization code" It is incorrect. And it is causing terrible side effects, because you are creating the view too soon. Do not add major side effects to a mere initializer! Just make the label and return it. Add it as a subview in viewDidLoad.Arthritis
I'm ready with further information, but I still don't know whether to call it Swift's bug or your bug. Let me add it to my answer.Arthritis
Okay, all set. I believe I've completely explained the issue. I have not decided whether to call it a Swift bug or not; it is certainly an edge case, but it is an edge you should never have approached in the first place. :) Still, you would be justified in submitting a test project to Apple as a bug report; they might like to know about this.Arthritis
Great! Your explanation is most reasonable. I couldn't catch a point which placed in the call stack. It's obviously recursion making unexpected flow. I'm not sure that Swift lazy have to work against this flow. anyway, I will post it to Apple. Thank you.Greg
The short answer is we should NEVER access view inside lazy block. Do it in viewDidLoad insteadKutch
A
32

The entire conception of your code is wrong.

  • In prepareForSegue, you must not refer to the interface of the destination view controller, because it has no interface. viewDidLoad has not run yet; the view controller has no view, no outlets, no nothing.

  • Your lazy initializer for the label property should not also add the label to the interface. It should just make the label and return it.

Other things to know:

  • Referring to a view controller's view before it has a view will force that view to load prematurely. Doing this wrong can actually cause the view to load twice, which can have terrible consequences.

  • The only way to ask a view controller whether its view has loaded yet, without forcing the view to load prematurely, is with isViewLoaded().

The correct procedure for what you want to do is:

  • In prepareForSegue, assign the name string to a name property and that's all. It can have an observer, but that observer must not refer to view if we have no view at the time, because doing so will cause the view to load prematurely.

  • In viewDidLoad, then and only then do we have a view, and now you may begin populating the interface. viewDidLoad should create the label, put it into the interface, then pick up the name property, and assign it to the label.


EDIT:

Now, having said all that... What does it have to do with your original question? How does what you are doing wrong here explain what Swift is doing, and is what Swift is doing itself wrong?

To see the answer, simply put a breakpoint on:

lazy var testLabel: UILabel = {
    NSLog("testLabel self = \(self)") // breakpoint here
    // ...

What you'll see is that, because of the way you structured your code, we are getting the value of testLabel twice recursively. Here's the call stack, slightly simplified:

prepareForSegue
name.didset
testLabel.getter -> *
viewDidLoad
testLabel.getter -> *

The testLabel getter refers to the view controller's view, which causes the view controller's view to be loaded, and so its viewDidLoad is called and causes the testLabel getter to be called again.

Note that the getter is not merely being called twice in sequence. It is being called twice recursively: it itself is, in effect, calling itself.

It is this recursion that Swift is failing to defend against. If the setter were merely called twice in succession, the lazy initializer would not have been called the second time. But in your case, it is recursive. So it is true that the second time, the lazy initializer has never been run before. It has been started, but it has never been completed. Thus, Swift is justified in running it now - which happens to mean running it again.

So, in a sense, yes, you've caught Swift with its pants down, but what you had to do in order to make that happen is so outrageous that it can be justifiably called your own fault. It might be Swift's bug, but if so, it is a bug that should simply never be encountered in real life.


EDIT:

In the WWDC 2016 video on Swift and concurrency, Apple is explicit about this. In Swift 1 and 2, and even in Swift 3, lazy instance variables are not atomic, and thus the initializer can run twice if called from two contexts simultaneously — which is exactly what your code does.

Arthritis answered 24/5, 2015 at 16:17 Comment(3)
I think your answer is excellent! actually, I also guess what you point out. Thank you. But, I still have unclear point why lazy initialization is run twice. @ArthritisGreg
Yes, I just tried to apply your advice and the problem was solved. an initialization is no longer run twice.Greg
Thank you for giving further information on it. Could you let me know a title of the video you've watched?Greg

© 2022 - 2024 — McMap. All rights reserved.