Swift Digest
SE-0467 | Swift Evolution

MutableSpan and MutableRawSpan: delegate mutations of contiguous memory

Proposal
SE-0467
Authors
Guillaume Lessard
Review Manager
Joe Groff
Status
Implemented (Swift 6.2)

01 何が問題だったのか

SE-0447 で導入された Span / RawSpan は、コンテナの連続メモリを安全に「読み取り専用で借用」するための型でした。一方で、同じようにコンテナの内部ストレージを書き換える安全な方法は用意されておらず、標準ライブラリでも Array.withUnsafeMutableBufferPointer(_:)withContiguousMutableStorageIfAvailable(_:) といったクロージャ+UnsafeMutableBufferPointer ベースの API に頼るしかありませんでした。

これらの API にはいくつかの課題があります。

  • UnsafeMutableBufferPointer を経由するため、型としてはバッファの範囲外アクセスや寿命切れを防げません。安全性に敏感なコード(たとえばセキュリティ上厳しい要件を持つプロジェクト)では使いにくい道具です。
  • クロージャ形式の API は合成しづらく、non-copyable 値や typed throws などの新機能に対応させる度に、同じ形の API を何種類もメンテナンスすることになります。
  • Span をそのまま varinout に束ねて書き換えに使う、というアプローチも取れません。Span は copyable なので、書き換えを許すとSwift の排他制御則(law of exclusivity)を破ってしまいます。読み取りは複数同時に成り立つ必要があり、Span が copyable であることは正しい設計ですが、そのぶん「変更」を表す型としては使えません。

つまり、排他借用されたメモリに対する変更を、Span と同じくらい安全に委譲できる型がまだ存在しない、という状態でした。ArrayFoundation.Data の内部ストレージを書き換える API を、安全で合成可能な形で公開していくためには、Span の「書き換え版」に相当する専用の型が必要になります。

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

Span の書き換え版として、排他借用された連続メモリへのビューを表す MutableSpan と、その型なし版の MutableRawSpan を標準ライブラリに追加します。どちらも ~Copyable かつ ~Escapable で、Array などのコンテナが自身の内部ストレージの変更を安全に委譲するための窓口になります。

MutableSpan の基本設計

MutableSpan<Element> は、初期化済みの連続メモリへの排他借用ビューです。

@frozen
public struct MutableSpan<Element: ~Copyable>: ~Copyable, ~Escapable {
  // 内部的には開始ポインタと要素数を保持
}

extension MutableSpan: @unchecked Sendable
  where Element: Sendable & ~Copyable {}

~Copyable で排他アクセス(law of exclusivity)を型として強制し、~Escapable で「メモリの有効期間を越えて持ち出されない」ことを保証します。要素アクセスは添字で行い、すべて境界チェック付きです。

for i in myMutableSpan.indices {
  mutatingFunction(&myMutableSpan[i])
}

MutableSpanCollection / MutableCollection には適合しません(これらは要素もコンテナも copyable であることを前提としているため)。そのため for 文でも上のように indices を介す形になります。添字は 0 始まりのオフセットです。

読み取り専用で触りたい場合のために、元の範囲を借用する span: Span<Element> プロパティも用意され、MutableSpan から Span の世界に戻れます。また、要素が swapAt(_:_:) で入れ替えられるなど、よく使う破壊的操作も提供されます。

コンテナからの取得と寿命関係

Array / ContiguousArray / ArraySlice / InlineArray / CollectionOfOne、および Foundation.Data に、次のような mutableSpan 計算プロパティが追加されます。

extension Array {
  public var mutableSpan: MutableSpan<Element> {
    mutating get { ... } // 返り値の寿命は inout self に紐付く
  }
}

このプロパティは「呼び出し側のArrayに対する一回の変更アクセス」を表します。返された MutableSpan が生きている間は、元の Array へのあらゆるアクセスがコンパイル時に禁止されます。

func modify(_ ms: inout MutableSpan<Int>) { /* ... */ }

func sample(_ array: inout Array<Int>) {
  var ms = array.mutableSpan
  modify(&ms)
  // array.append(2) // ここで array を触るのはエラー
  _ = consume ms     // ここで ms を消費し、array への変更アクセスが終わる
  array.append(1)    // これ以降は array を自由に使える
}

MutableSpan の寿命はコンテナへの inout アクセスそのものであり、MutableSpan を明示的に consume するか、スコープを抜けて破棄されたタイミングで、元のコンテナに再びアクセスできるようになります。

なお、この寿命関係は今回のProposal内では仮の @_lifetime(inout self) という書き方で説明されていますが、これはあくまで説明用のプレースホルダです。実際の Swift の寿命アノテーション構文は別のProposalで確定する予定で、標準ライブラリはその構文が入り次第切り替わる見込みです。

copy-on-write なコンテナでは、mutableSpan を取り出す時点がすでに「変更の開始」です。バッキングストレージが一意に参照されていなければ、そのタイミングで必要なコピーが行われ、以降は一意化されたバッファへのビューが返されます。

部分範囲の切り出し(extracting

バルクコピーなどでは「先頭からではなく、途中の範囲だけを書き換えたい」という要求がよく出てきます。MutableSpan では extracting(_:) 系のメソッドでサブスパンを取り出します。

extension MutableSpan where Element: ~Copyable {
  public mutating func extracting(_ range: Range<Index>) -> Self
  public mutating func extracting(_ bounds: some RangeExpression<Index>) -> Self
  public mutating func extracting(first maxLength: Int) -> Self
  public mutating func extracting(droppingLast k: Int) -> Self
  public mutating func extracting(last maxLength: Int) -> Self
  public mutating func extracting(droppingFirst k: Int) -> Self
}

返された MutableSpan は、呼び出し元の MutableSpan と同じメモリに対する変更アクセスを引き継ぎます。そのため、サブスパンが生きている間は元の MutableSpan にも触れません。

var array = [1, 2, 3, 4, 5]
var span1 = array.mutableSpan
var span2 = span1.extracting(3..<5)
// ここでは array も span1 もアクセス不可
span2.swapAt(0, 1)
_ = consume span2 // span2 のスコープを明示的に終える
span1.swapAt(0, 1)
_ = consume span1
print(array) // [2, 1, 3, 5, 4]

また、SE-0437 の方針に沿って、extracting で得たサブスパンは親とインデックスを共有しません。サブスパン側ではインデックスは 0 から振り直されます。

バルク更新

MutableSpan の中身をまとめて上書きするためのメソッドが用意されます。サイズが既知のソース(CollectionSpan)から更新する場合、元のソースより短ければエラーになります。Sequence / IteratorProtocol から更新する場合は、ソースが尽きるか MutableSpan を埋め切るまでコピーされ、最後に書き込んだ次の位置が返ります。

extension MutableSpan where Element: Copyable {
  public mutating func update(repeating repeatedValue: Element)
  public mutating func update<S: Sequence>(from source: S)
    -> (unwritten: S.Iterator, index: Index) where S.Element == Element
  public mutating func update(fromContentsOf source: some Collection<Element>)
    -> Index
}

extension MutableSpan where Element: ~Copyable {
  public mutating func update(fromContentsOf source: Span<Element>) -> Index
  public mutating func update(fromContentsOf source: borrowing MutableSpan<Element>)
    -> Index
  public mutating func moveUpdate(
    fromContentsOf source: UnsafeMutableBufferPointer<Element>
  ) -> Index
}

moveUpdate(fromContentsOf:) はソース側を未初期化状態にしつつ移動コピーを行います。~Copyable な要素でも扱えるのがポイントです。

MutableRawSpan

MutableRawSpanMutableSpan<T> の型なし・バイト単位版で、エンコーダやデコーダのように複数の型のビットパターンを書き込むような用途を想定しています。

@frozen
public struct MutableRawSpan: ~Copyable, ~Escapable { ... }

extension MutableRawSpan: @unchecked Sendable

byteCount / byteOffsets といったバイト単位のプロパティに加えて、

  • BitwiseCopyable な値のビット列を指定オフセットに書き込む storeBytes(of:toByteOffset:as:)
  • 指定オフセットから任意の型の値として読み出す unsafeLoad(fromByteOffset:as:) / unsafeLoadUnaligned(_:as:)
  • Sequence / Collection / Span / MutableSpan / RawSpan / MutableRawSpan からのバルク更新 update(from:) / update(fromContentsOf:)
  • extracting(_:) によるサブスパン切り出し

といった API を持ちます。unsafeLoad(fromByteOffset:as:) はメモリ上のビットパターンが読み出す型の不変条件を満たすかどうかをチェックしないため、@unsafe が付いています。

MutableSpan<Element> から MutableRawSpan を取り出す経路も用意されます。ただし、要素の型の不変条件をビット単位の変更で壊し得るため、こちらも @unsafe です。

extension MutableSpan where Element: BitwiseCopyable {
  @unsafe
  public var mutableBytes: MutableRawSpan { mutating get }
}

Foundation.Data にも、UInt8 の要素を見る mutableSpan: MutableSpan<UInt8> と、バイト列として見る mutableBytes: MutableRawSpan の両方が追加される予定です。

境界チェックを外す unchecked

通常の添字・swapAtextracting はすべて境界チェック付きですが、事前に境界が保証されている場面ではコストを避けたいこともあります。そのため、同じ操作の unchecked 版(subscript(unchecked:)swapAt(unchecked:unchecked:)extracting(unchecked:) など)が @unsafe として提供されます。境界チェックを外すので、範囲外を渡すと未定義動作になります。

unsafe な世界との相互運用

既存の UnsafeMutableBufferPointer ベースの API と橋渡しするためのクロージャ型 API も用意されます。

extension MutableSpan where Element: ~Copyable {
  public func withUnsafeBufferPointer<E: Error, Result: ~Copyable>(
    _ body: (_ buffer: UnsafeBufferPointer<Element>) throws(E) -> Result
  ) throws(E) -> Result

  public mutating func withUnsafeMutableBufferPointer<E: Error, Result: ~Copyable>(
    _ body: (_ buffer: UnsafeMutableBufferPointer<Element>) throws(E) -> Result
  ) throws(E) -> Result
}

MutableRawSpan にも同様に withUnsafeBytes(_:) / withUnsafeMutableBytes(_:) が用意されます。これらは Array の同名メソッドと同じ役割で、クロージャのスコープ内で裏にあるバインディングを生かし続けるために使います。

逆向きの橋渡しとして、UnsafeMutableBufferPointer / UnsafeMutableRawBufferPointer からそれぞれ MutableSpan / MutableRawSpan を取り出す @unsafemutableSpan / mutableBytes プロパティも追加されます。返り値の寿命はポインタのバインディングに紐付くだけで、指しているメモリ自体の生存は保証しません。UnsafePointer 族を扱うときと同様に、プログラマ側で以下を守る必要があります。

  • スパンを使っている間、メモリは初期化されたまま保たれていること
  • スパンを使っている間、そのメモリに他の経路からアクセスしないこと

これを守らないと未定義動作になります。

性能

mutableSpan / mutableBytes は基本的に O(1) で、ポインタと長さを詰めた値を返すだけの軽い操作になる想定です。ただし copy-on-write 型では、バッキングストレージが一意でなければ、MutableSpan を返す直前にフルコピーが発生します。ブリッジされたコンテナ(Objective-C 由来の構造体など)については、もともと変更アクセスの度に防御的コピーが必要なので、MutableSpan に固有のオーバーヘッドはありません。

Future Directions

これらは speculative な見通しで、今回の提案に含まれるものではなく、実現を約束するものでもありません。

  • MutableSpan を 2 つに分割する split(at:) のような API。ただし non-copyable / non-escapable をタプルで返すのがまだ難しく、解法の検討が必要です。
  • MutableCollection で定義されている sort(by:) / partition(by:) といったアルゴリズム群を、一般化されたコンテナプロトコルの上で MutableSpan にも提供すること。
  • Array.init(unsafeUninitializedCapacity:initializingWith:) の安全版となる OutputSpan<T>。部分的に初期化された連続メモリを表し、追記によって初期化領域を伸ばしていくモデルを想定しています。
  • 寿命アノテーション構文が確定した後の、MutableSpan / MutableRawSpan の初期化 API の整備。