Swift Digest
SE-0494 | Swift Evolution

Add isTriviallyIdentical(to:) Methods for Quick Comparisons to Concrete Types

Proposal
SE-0494
Authors
Rick van Voorden, Karoy Lorentey
Review Manager
John McCall
Status
Implemented (Swift Next)

01 何が問題だったのか

ArrayDictionaryString のような Standard Library のコレクション型は、値が等しいかを == で比較できます。しかしこの比較は最悪計算量 O(n) で、要素数が多くなるほど高くつきます。

たとえば、入力が変わらなければ前回の結果を使い回したい、というメモ化のような場面では、この O(n) の等価比較がそのままボトルネックになります。

final class Memoizer {
  private var input: [Int]?
  private var result: [Int]?

  func result(for input: [Int]) -> [Int] {
    if let result = self.result,
       self.input == input {  // O(n) の等価比較
      return result
    } else {
      print("computing new result")
      self.input = input
      let result = input.filter { $0 % 2 == 0 }
      self.result = result
      return result
    }
  }
}

O(n) の計算を避けるためにキャッシュしたい」のに、キャッシュの判定自体が O(n) であれば、結果を毎回計算し直すのと大差ないことになります。

SwiftUI の body のように、状態が少しでも変わるたびに再計算され、その中で filter などの O(n) 処理が走るような場面でも事情は同じです。「同じ配列が来たら計算をスキップしたい」と思っても、その判定に == を使ってしまうと、結局 O(n) のコストを払い続けることになります。

copy-on-write が持つ「速い経路」

ArrayDictionaryString などはいわゆる copy-on-write 型で、値型として振る舞いつつ内部に共有ストレージへの参照を持ちます。値をコピーしただけでは中身は複製されず、書き込みが起きるタイミングで初めてストレージが複製されます。

つまり、2つの値が同じストレージを参照していれば、その時点では中身が必ず一致しているはずで、これは O(1) で判定できます。実際 String には公開非公式(アンダースコア付き)の _isIdentical(to:) がすでに存在していて、Span / RawSpan では公式に isIdentical(to:) が提供されています。Swift Collections や Swift Markdown など外部ライブラリでも同様の API が広く使われています。

これまで一般のユーザーがこの「速い経路」を踏むには、withUnsafeBufferPointer で内部バッファのアドレスを比較したり、ArraySpan に橋渡ししたりといった、回りくどく制約の多い方法しかありませんでした。連続したストレージが存在しなければ追加で O(n) のコピーが発生してしまうこともあり、本来の目的である「定数時間で速く判定する」ことが達成できないケースもありました。

メモ化の判定のように、「等しいかどうか」までは要らないが、「明らかに同じ値である」ことだけ高速に確かめたい という用途のために、Standard Library の主要な型に対して、定数時間で完了する公式の API が必要でした。

02 どのように解決されるのか

Standard Library の主要な具象型に、定数時間 O(1) で2つの値が「自明に同一」かを判定する isTriviallyIdentical(to:) メソッドを追加します。true を返したときは「2つの値は中身まで等しいことが保証される」、false のときは「等しいかもしれないし違うかもしれない(それ以上のことは何も言わない)」というセマンティクスです。

final class Memoizer {
  private var input: [Int]?
  private var result: [Int]?

  func result(for input: [Int]) -> [Int] {
    if let result = self.result,
       self.input?.isTriviallyIdentical(to: input) == true {
      return result // O(1) で判定して即返す
    } else {
      print("computing new result")
      self.input = input
      let result = input.filter { $0 % 2 == 0 }
      self.result = result
      return result
    }
  }
}

同じ Array を別変数に代入しただけ(コピー直後でストレージを共有している)なら isTriviallyIdentical(to:)true を返し、メモ化された結果がそのまま使われます。一方、要素はまったく同じだが別途リテラルから作られた Array のように、ストレージが共有されていない場合は false を返すことがあり、その場合は通常通り計算がやり直されます。「等しい値に対して false を返しうる」ことは仕様であり、その代わりに常に O(1) で済むことが保証されます。

セマンティクス

isTriviallyIdentical(to:) は次の性質を満たすように設計されています。

  • a.isTriviallyIdentical(to: a) は常に true(反射律)。
  • a.isTriviallyIdentical(to: b) ならば b.isTriviallyIdentical(to: a)(対称律)。
  • a.isTriviallyIdentical(to: b) かつ b.isTriviallyIdentical(to: c) ならば a.isTriviallyIdentical(to: c)(推移律)。
  • 型が Equatable の場合、a.isTriviallyIdentical(to: b) ならば a == b(ただし Float.nan のような例外的な値は除く)。逆は成り立たない(== でも isTriviallyIdentical(to:)false でありうる)。

導入される型では、内部に「ストレージへの参照」のような速い同一性判定の手がかりがあり、isTriviallyIdentical(to:)== よりも 意味のあるレベルで 速いことが前提になっています。逆に言えば、IntBool のような型にはこの API は導入されません(== 自体が十分速いため)。

対象となる型

このメソッドは Standard Library の以下の具象型に追加されます。型ごとに新たなプロトコルを導入するのではなく、各型のインスタンスメソッドとして個別に追加される形です。

  • StringSubstring、およびそれぞれの UnicodeScalarView / UTF16View / UTF8View
  • ArrayArraySliceContiguousArray
  • DictionarySet
  • UnsafeBufferPointerUnsafeMutableBufferPointerUnsafeRawBufferPointerUnsafeMutableRawBufferPointer
  • UTF8SpanSpanRawSpan

UnsafeBufferPointer 系や UTF8Span / Span / RawSpan のように Equatable でない(あるいは値そのものではなくメモリ領域を指す)型の場合は、「2つのインスタンスがメモリ上の同じ領域を指しているか」というより低レベルなセマンティクスになります。

SwiftUI のメモ化での使用例

冒頭で挙げたような、SwiftUI で body のたびに filter が走ってしまうケースも、同じ要領で書けます。

extension Favorites {
  private final class Storage {
    private var contacts: [Contact]
    private var favorites: [Contact]?

    func update(_ contacts: [Contact]) {
      // == の代わりに isTriviallyIdentical(to:) で O(1) 判定
      if self.contacts.isTriviallyIdentical(to: contacts) == false {
        self.contacts = contacts
        self.favorites = nil
      }
    }

    var wrappedValue: [Contact] {
      if let favorites = self.favorites {
        return favorites
      }
      let favorites = self.contacts.filter { $0.isFavorite }
      self.favorites = favorites
      return favorites
    }
  }
}

ユーザーが選択を変えた程度では contacts 自体の値は変わらず、@State として渡される配列はストレージを共有したままなので、isTriviallyIdentical(to:)true を返してメモ化が効き続けます。

想定される使い所

このメソッドは「とりあえず == の代わりに使う」ための API ではありません。あくまで 値の等価比較が高くつく場面で、まず O(1) の高速判定を試し、ヒットしたら処理をスキップする といった、性能最適化のための補助フックです。

  • メモ化や不要な再計算の抑制
  • 大きなコレクションを引数として渡す API での「変更があったときだけ再処理」
  • SwiftUI / Observation など、状態変化のたびに派生値の計算が走るレイヤーでの最適化

isTriviallyIdentical(to:)false を返した場合に、どうしても正確な判定が必要なら従来通り == にフォールバックする、という二段構えになります。

Future Directions(参考)

提案では、今回の対象は影響の大きい型に絞られており、CharacterDictionary.Keys / Dictionary.ValuesKeyValuePairsStaticString といった他の copy-on-write 的な型にも将来的に同じメソッドを追加していける、と speculative に触れられています。あくまで今後の方向性を示すもので、実現を約束するものではありません。