Refine didSet Semantics
01 何が問題だったのか
Swift のプロパティに didSet オブザーバを付けると、値を書き換えるたびに常に oldValue を取得するためにそのプロパティのゲッタが呼ばれていました。didSet 本体の中で oldValue をまったく参照していない場合でも、この取得は省略されません。
class Foo {
var bar: Int {
didSet { print("didSet called") }
}
init(bar: Int) { self.bar = bar }
}
let foo = Foo(bar: 0)
// didSet の本体では oldValue を参照していないのに、
// bar のゲッタが呼ばれて oldValue が読み出される
foo.bar = 1
見た目には害の無いように思えますが、oldValue を格納するための領域を確保し、使われもしない値をロードするという無駄が発生しています。ゲッタが重い処理をしていたり大きな値を返したりする場合には、このコストは無視できません。
たとえば次のような配列プロパティでは、didSet 側が oldValue を使っていなくても、要素を更新するたびに配列全体のコピーが作られてしまいます。
struct Container {
var items: [Int] = .init(repeating: 1, count: 100) {
didSet {
// oldValue にはアクセスしないが、なにか処理をする
}
}
mutating func update() {
for index in 0..<items.count {
items[index] = index + 1
}
}
}
var container = Container()
container.update()
このコードは 100 回の代入ごとに oldValue 用の配列コピーを 100 個作ってしまいます。
この挙動は一部のプロパティラッパの実装も難しくしていました。たとえば「最初の代入までは値を持たない」ことを表す @Delayed のようなラッパを考えると、
@propertyWrapper
struct Delayed<Value> {
var wrappedValue: Value {
get {
guard let value = value else {
preconditionFailure("Property \(String(describing: self)) has not been set yet")
}
return value
}
set {
guard value == nil else {
preconditionFailure("Property \(String(describing: self)) has already been set")
}
value = newValue
}
}
var value: Value?
}
class Foo {
@Delayed var bar: Int {
didSet { print("didSet called") }
}
}
let foo = Foo()
foo.bar = 1
最初に foo.bar = 1 と代入した時点で、didSet のために oldValue を読み出そうとしてゲッタが呼ばれ、まだ値がセットされていないのに preconditionFailure でクラッシュしてしまいます。didSet 本体で oldValue を使っていないにもかかわらず、ゲッタが呼ばれることで正しく動かせないのです。
02 どのように解決されるのか
didSet の本体で oldValue を参照していない場合、プロパティのゲッタを呼んで oldValue を取得する処理そのものを省略するようにします。参照しているときだけ従来どおりゲッタが呼ばれます。
class Foo {
var bar = 0 {
didSet { print("didSet called") }
}
var baz = 0 {
didSet { print(oldValue) }
}
}
let foo = Foo()
// oldValue を参照していないので、ゲッタは呼ばれない
foo.bar = 1
// oldValue を参照しているので、ゲッタが呼ばれて oldValue が取得される
foo.baz = 2
これはオーバーライドされたプロパティの didSet にも同様に適用されます。オーバーライド側の didSet 本体で oldValue を参照していなければ、スーパークラスのゲッタ呼び出しもスキップされます。
didSet 本体で oldValue を参照していないプロパティを、この提案では「simple な didSet」と呼びます。
in-place な変更による高速化
simple な didSet で、さらに willSet も無いプロパティについては、値をいったんコピーして書き戻すのではなく、ストレージを直接書き換えるように実装が変わります。概念的には、従来は次のように合成されていた _modify コルーチンが、
// 従来の _modify の合成
_modify {
var newValue = underlyingStorage
yield &newValue
// セッタ経由で willSet(あれば)と didSet を呼ぶ
observedStorage = newValue
}
次のように、ストレージを直接 yield してから didSet を呼ぶ形になります。
// 新しい _modify の合成
_modify {
// willSet は無く、didSet は simple なので、
// ストレージを直接 yield してから didSet を呼べる
yield &underlyingStorage
didSet()
}
これにより、先ほどの配列の update() のような例でも、要素を書き換えるたびに配列全体をコピーする必要がなくなり、大きな性能改善が得られます。また、@Delayed のようなプロパティラッパも、didSet を付けたまま期待通りに動くようになります。
従来どおりの挙動にしたいとき
didSet 本体で oldValue を使っていなくても、どうしてもゲッタを呼ばせたい場合は、次のいずれかの方法で明示的に取得させられます。
// 方法 1: oldValue を仮引数として明示する
didSet(oldValue) {
// 本体で oldValue を使わなくてもゲッタが呼ばれる
}
// 方法 2: 本体の中で oldValue を評価だけする
didSet {
_ = oldValue
}
逆に言えば、これらの書き方をしない限り、didSet 本体が oldValue を参照しないプロパティでは oldValue 取得のコストが掛からなくなります。
今後の見通し
今回の提案のスコープ外ですが、同様の最適化を willSet にも適用し、newValue を参照していないときは渡さないようにする、といった拡張も考えられます。ただし willSet の場合は newValue をロードするためのコストがそもそも掛からないため、得られるメリットは didSet の場合ほど大きくはありません。あわせて、oldValue を暗黙に使えるのをやめて didSet(oldValue) { ... } のように明示的に書くことを推奨していく方向も議論の対象になっています。いずれも将来の検討課題で、本提案で実現を約束するものではありません。