soranoba
soranoba Author of soranoba.net
programming

NSPointerArray.compactでnilがなくなるとは限らない

CI実行時だけ一定確率で失敗するというテストがあり, 長らく原因が分からず仕舞いだった.
たまたま実機で特定の動作手順で確実に再現するバグがあり, それを直したところ上記の問題が解消されるということがあった.
今回はそれについて紹介する.

TL; DR

NSPointerArray.compactを実行する前に, addPointer(nil)を実行しよう

NSPointerArrayとは

まず, NSPointerArrayについて軽く説明する.
このクラスは要素がweakである配列が欲しい時に利用するクラスだ.

private var observers = NSPointerArray.weakObjects()

public func observe(_ observer: Observer) {
    let pointer = Unmanaged<AnyObject>.passUnretained(observer).toOpaque()
    observers.addPointer(pointer)
}

public func unobserve(_ observer: Observer) {
    // ATTENTION: このコードにはバグがあります
    observers.compact()
    if let index = observers.allObjects.firstIndex(where: { ($0 as? Observer) === observer }) {
        observers.removePointer(at: index)
    }
}

private func notificate(closure: (Observer) -> Void) {
    observers.allObjects.compactMap { $0 as? Observer }.forEach { closure($0) }
}

例えば上記のように実装すると, observerの参照カウンタを保持せず, notificate実行時に解放されているobserverには呼び出しを行わないことができる.
複数に対してdelegateを実行しない場合に有効な書き方である.

NSPointerArray.compact

NSPointerArrayはnilも保持できるようになっており, 解放された場合でも自動的にNSPointerArrayから削除される訳ではない.
つまり, すぐに解放されるようなポインタを大量に追加した場合, 内部配列の要素数が増え続けることになる.
これを解消する為に, compactというメソッドが用意されている.

Removes NULL values from the receiver.

とあるように, 内部配列内に含まれるnilを取り除き, 内部配列の要素数を必要なサイズにすることができる……はずだった.
このcompactにはアンドキュメントな仕様がある為, 必ずしもnilを取り除いてくれるとは限らない.

確実にcompactionを実行する方法

NSPointerArray

NSPointerArrayはneedsCompactionという非公開のプロパティを持っている.
この値がtrueになっている状態でcompactを実行した時のみcompactionが行われる.
そして, 内部配列に追加されたインスタンスが解放されてもこの値はtrueにならない.

let arr = NSPointerArray.weakObjects()

autoreleasepool {
    let values = (0..<50).map { _ in NSObject() }
    values.forEach { value in
        arr.addPointer(Unmanaged<AnyObject>.passUnretained(value).toOpaque())
    }
    XCTAssertEqual(arr.count, 50)
}
arr.compact()
XCTAssertEqual(arr.count, 50)
XCTAssertEqual(arr.allObjects.count, 0)

実際に上記のコードを実行すると, compact後にcountが減っていないことが分かる.
これを以下のように修正することでcompactionを確実に実行することができる.

let arr = NSPointerArray.weakObjects()

autoreleasepool {
    let values = (0..<50).map { _ in NSObject() }
    values.forEach { value in
        arr.addPointer(Unmanaged<AnyObject>.passUnretained(value).toOpaque())
    }
    XCTAssertEqual(arr.count, 50)
}

// addPointer(nil)を実行するとneedsCompactionがtrueになり, compactionが確実に実行される.
arr.addPointer(nil)
arr.compact()
XCTAssertEqual(arr.count, 0)
XCTAssertEqual(arr.allObjects.count, 0)

NSPointerArrayを使った正しい実装

上記を踏まえて, 先程のコードを正しいコードに修正する.

private var observers = NSPointerArray.weakObjects()

public func observe(_ observer: Observer) {
    let pointer = Unmanaged<AnyObject>.passUnretained(observer).toOpaque()
    observers.addPointer(pointer)
}

public func unobserve(_ observer: Observer) {
    // addPointer(nil)を実行するとneedsCompactionがtrueになり, compactionが確実に実行される.
    observers.addPointer(nil)
    observers.compact()
    if let index = observers.allObjects.firstIndex(where: { ($0 as? Observer) === observer }) {
        observers.removePointer(at: index)
    }
}

private func notificate(closure: (Observer) -> Void) {
    observers.allObjects.compactMap { $0 as? Observer }.forEach { closure($0) }
}

呼び出し頻度に応じて, どこでcompactを行うかは調整すると良い.

参考文献


この記事中のテストコードはここから参照できる.

(Updated: )

comments powered by Disqus