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は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を行うかは調整すると良い.
参考文献
この記事中のテストコードはここから参照できる.
記事が気に入ったらチップを送ることができます!
You can give me a cup of coffee :)
Kyash ID: soranoba
Amazon: Wish List
GitHub Sponsor: github.com/sponsors/soranoba
PayPal.Me: paypal.me/soranoba
(Updated: )