March 9, 2017

Swiftでdeinit時にメンバ変数(property)のdidSetが呼ばれない気がした

Categories: 技術 | Tags: #Swift #tips


init の時にメンバ変数(property)の didSet が機能しないのは知っていたのですが、
deinit 時に機能しない?のを知らなくて、振り返ってみるとちょっと怯えたのでそのメモ。

initでpropertyのdidSet/willSetは呼ばれない

以下のように init 内ではdidSet/willSetは呼ばれません。

class Foo {
    var a: String? {
        didSet {
            print("didSet!!")
        }
    }

    init() {
        a = "bar"
    }
}

let foo = Foo()
// === output ===

ちなみに、 init内で defer をかますとdidSetが呼ばれるようにはなります。

class Foo {
    var a: String? {
        didSet {
            print("didSet!!")
        }
    }

    init() {
        defer {
            a = "bar"
        }
    }
}

let foo = Foo()
// === output ===
didSet!!

じゃあdeinitでは?

じゃあ deinit では?ということで試してみました。

class Hoge {
    var a: String? {
        didSet {
            b = a
        }
    }
    var b: String?

    deinit {
        print("\(#function)")
        a = nil // これ
        output()
    }

    func output() {
        print("a:", a as Any)
        print("b:", b as Any)
    }
}

do {
    let h = Hoge()
    h.a = "Hoge"
    h.output()
    print("===")
    h.a = nil
    h.output()
    print("===")
    h.a = "Fuga"
    h.output()
}

//============
// didSet!!
// a: Optional("Hoge")
// b: Optional("Hoge")
// ===
// didSet!!
// a: nil
// b: nil
// ===
// didSet!!
// a: Optional("Fuga")
// b: Optional("Fuga")
// deinit
// a: nil
// b: Optional("Fuga") // !!!!!!!

なんと、、今までdeinitで変数に値を入れる(例えばnilを入れる)とかした場合に、 didSet が呼ばれて、
本来してほしかった処理が動いていると思ったら動いていなかったようです。
この例だと、deinitで a = nil をした時に、didSetが呼ばれて b = a (= nil) を期待したのですが、そうはならなかったです。

deferをかますと…?

init と同じように、 deinit でも defer をかますと同様に didSet が機能するようになります。。

class Hoge {
    var a: String? {
        didSet {
            print("didSet!!")
            b = a
        }
    }
    var b: String?

    deinit {
        defer {
            print("\(#function)")
            a = nil
            output()
        }
    }

    func output() {
        print("a:", a as Any)
        print("b:", b as Any)
    }
}

do {
    let h = Hoge()
    h.a = "Hoge"
    h.output()
    print("===")
    h.a = nil
    h.output()
    print("===")
    h.a = "Fuga"
    h.output()
}
// ==================
// didSet!!
// a: Optional("Hoge")
// b: Optional("Hoge")
// ===
// didSet!!
// a: nil
// b: nil
// ===
// didSet!!
// a: Optional("Fuga")
// b: Optional("Fuga")
// deinit
// didSet!! // deinit内での代入で呼び出されている
// a: nil
// b: nil // ちゃんとnilになっている!

謎だ。
動いたとしても、deinit の処理が通った後にdeferで色々やるのは避けたい所。

こんなケースでは意図した動作しないかも…

極端な例ですが、

class Baz: NSObject {
    dynamic var value: String = ""

    override class func automaticallyNotifiesObservers(forKey key: String) -> Bool {
        if key == "value" {
            return true
        }
        return super.automaticallyNotifiesObservers(forKey: key)
    }

}

class Foo: NSObject {
    var baz: Baz? {
        willSet {
            baz?.removeObserver(self, forKeyPath: "value")
        }
        didSet {
            baz?.addObserver(self, forKeyPath: "value", options: [.new, .old], context: nil)
        }
    }

    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        if keyPath == "value" {
            print(keyPath as Any, object as Any)
        }
    }

    deinit {
        baz = nil // willSet/didSetが動くのを期待
    }
}


do {
    let f = Foo()
    let baz = Baz()
    f.baz = baz
    baz.value = "!!!"
}

// doブロックを抜けた時にクラッシュする

のように、baz という変数が代入された時に、add/removeObserverするような設計をしていた場合に、
deinit でnil入れてるから、willSet, didSet 呼ばれるから大丈夫でしょうと油断していると実はremoveできていなくて後にクラッシュする…
なんて可能性もあります。
なので、deinit 時はwillSet,didSetに頼らないように処理を記述するのが良いと思われます。
上記の例では、以下のようにするとdoブロックを抜けた時にクラッシュしなくなります。

deinit {
    baz?.removeObserver(self, forKeyPath: "value")
}

なるべく

defer をかますのはややトリッキーになるので、init/deinit時は didSet/willSet に頼らず自分で必要な処理を記述するようにしましょう。
今回のサンプルはGist1, Gist2にあげてあります。


written by sgr-ksmt