Swift Digest
SE-0256 | Swift Evolution

Introduce {Mutable}ContiguousCollection protocol

Proposal
SE-0256
Authors
Ben Cohen
Review Manager
Ted Kremenek
Status
Rejected

01 何が問題だったのか

先行する SE-0237 では、CollectionwithContiguousStorageIfAvailable(_:)withContiguousMutableStorageIfAvailable(_:) が追加されました。これらは「連続したメモリ領域(contiguous storage)が使えるなら高速パスを使う、なければ通常のパスにフォールバックする」という形の最適化を可能にするものです。sort のように、連続バッファへのポインタが取れれば大きく速くできるが、取れなくても動ける、というアルゴリズムには適した仕組みです。

しかし、用途によっては「連続バッファが取れるときだけ動けばよく、取れないなら呼び出し自体を禁じたい」ケースがあります。典型例は vDSP のような高速な数値演算関数群を Swift から使いやすくラップしたい場合です。

たとえば vDSP_vadd を汎用コレクションに対して使えるようラップしようとすると、次のように書くことになります。

// これは vDSP のラッパーそのものを提案するものではなく、あくまで例です。
func dspAdd<A: Collection, B: Collection>(
  _ a: A, _ b: B, _ result: inout [Float]
) where A.Element == Float, B.Element == Float {
  let n = a.count
  // 連続バッファへのアクセスを試みる
  let wasContiguous: ()?? =
    a.withContiguousStorageIfAvailable { abuf in
      b.withContiguousStorageIfAvailable { bbuf in
        vDSP_vadd(abuf.baseAddress!, 1, bbuf.baseAddress!, 1, &result, 1, UInt(n))
      }
  }
  // 連続バッファが取れなかった場合は、配列にコピーしてから再試行する
  if wasContiguous == nil || wasContiguous! == nil {
    dspAdd(Array(a), Array(b), &result)
  }
}

vDSP 系の関数は、手書きのループに対してわずかな差で勝つタイプの最適化が多く、連続バッファを得るためだけに新しい配列を確保・初期化してコピーしてしまうと、vDSP を使うメリットがそっくり打ち消されるどころか遅くなることさえあります。それでも withContiguousStorageIfAvailable(_:) ベースの API だと、呼び出し側は Range のような連続バッファを持たない型を渡しても一応は動いてしまい、性能劣化にも気付きにくい、というのが問題でした。

「連続バッファが無い入力を渡したら実行時にトラップさせる」という方法もありますが、これは型システムの助けを借りずに実行時エラーへ押し込んでいるだけで、誤用の防止としては筋が悪い、というのが動機です。理想は、型で「この関数は連続バッファを持つコレクションしか受け付けません」と表現できるようにすることです。

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

この提案は Rejected(却下) となりました。したがって、標準ライブラリに ContiguousCollection / MutableContiguousCollection プロトコルは追加されておらず、連続バッファへのアクセスは引き続き withContiguousStorageIfAvailable(_:) 系の API を通じて行う形のままです。

提案されていた内容(却下されたもの)

連続したバッファへのアクセスを「必ず取れる」ものとして保証する2つのプロトコルを、標準ライブラリに追加する案でした。

/// 連続したストレージへのアクセスをサポートするコレクション。
public protocol ContiguousCollection: Collection
where SubSequence: ContiguousCollection {
  func withUnsafeBufferPointer<R>(
    _ body: (UnsafeBufferPointer<Element>) throws -> R
  ) rethrows -> R
}

/// 連続したストレージへのミュータブルなアクセスをサポートするコレクション。
public protocol MutableContiguousCollection: ContiguousCollection, MutableCollection
where SubSequence: MutableContiguousCollection {
  mutating func withUnsafeMutableBufferPointer<R>(
    _ body: (inout UnsafeMutableBufferPointer<Element>) throws -> R
  ) rethrows -> R
}

適合させる標準ライブラリ型は次の通りでした。

  • Array, ArraySlice, ContiguousArrayMutableContiguousCollection
  • UnsafeBufferPointerContiguousCollection
  • UnsafeMutableBufferPointerMutableContiguousCollection
  • Slice → ベースが適合していれば条件付き適合

これらが入ると、先ほどの dspAdd は次のように、型制約だけで「連続バッファを持つコレクションしか渡せない」ことを表現できるようになる想定でした。RangeRepeated のような連続バッファを持たない型を渡すコードはコンパイル時に弾かれ、結果バッファも具象の [Float] ではなく汎用の MutableContiguousCollection で受けられるため、配列のスライスをタイル状に渡すような使い方もきれいに書けます。

func dspAdd<A: ContiguousCollection, B: ContiguousCollection, R: MutableContiguousCollection>(
  _ a: A, _ b: B, _ result: inout R
) where A.Element == Float, B.Element == Float, R.Element == Float {
  let n = a.count
  a.withUnsafeBufferPointer { abuf in
    b.withUnsafeBufferPointer { bbuf in
      result.withUnsafeMutableBufferPointer { rbuf in
        vDSP_vadd(abuf.baseAddress!, 1, bbuf.baseAddress!, 1, rbuf.baseAddress!, 1, UInt(n))
      }
    }
  }
}

却下された理由と残った論点

最大の論点は Array の扱いでした。Array は本来連続したメモリ上に要素を持ちますが、Darwin プラットフォームでは Objective-C 相互運用のため、NSArray(要素がクラスのもの)からブリッジされた直後の Array は裏側で NSArray に要素アクセスを委ねる形になっており、必ずしも連続バッファを提供できるとは限りません。

  • Swift 側で作られた Array は常に連続して格納される。
  • 構造体・enum の Array は常に連続して格納される。
  • Objective-C ランタイムが無いプラットフォームでは常に連続して格納される。
  • 連続して格納されないのは、「要素がクラスで、かつ NSArray からブリッジされた」ケースに限られる(ただしこの場合でも、裏の NSArray 自身が連続なら、償却コストでポインタを返せることが多い)。
  • ミューテートする操作を呼んだ時点で、Array は一意参照性と連続性を確保するので、ブリッジ由来の Array もミュータブル API を通れば連続になる。

提案では「この例外はプロトコルと Array のドキュメントで明示する」とされ、浮動小数点型が Comparable に適合しつつ NaN という例外を抱えているのと同種の割り切りだと位置付けられていました。しかしレビューでは、「コレクションの要素アクセス自体に ObjC ブリッジによる間接コストが乗り得る」という Array 固有の事情を、標準ライブラリのプロトコル設計にそのまま引き込むのはよくない、という判断が強く、結果として却下されました。

実務上のスタンス

現時点では、同等のことをしたいライブラリは次のいずれかを選ぶことになります。

  • ライブラリ側で独自の「連続バッファ保証」プロトコルを定義し、標準ライブラリ型にレトロアクティブに適合させる。ただし他ライブラリと定義が衝突する可能性があり、ユーザー定義型にも手動で適合してもらう必要があります。
  • withContiguousStorageIfAvailable(_:) 系の API を使い、「連続バッファを持たない型を渡すと性能が落ちます」とドキュメントで案内する。誤用の検知は呼び出し側に委ねる形になります。
  • withContiguousStorageIfAvailable(_:) 系で値が取れなかったときにトラップする。初回の誤用を確実に表面化できますが、リングバッファのように「同じ型でも状況次第で連続になったりならなかったり」するケースでは予測不能なクラッシュを招くため、汎用的には勧めにくい方法です。

連続バッファを前提としたジェネリックな高速パスを Swift の型システムで表現する方法は、本提案の時点では標準ライブラリに入らず、その後 Swift の低レベルバッファ抽象(Span / RawSpan など、後続の提案)の方向で別の形で整理されていくことになります。