Swift Digest
SE-0237 | Swift Evolution

Introduce withContiguous{Mutable}StorageIfAvailable methods

Proposal
SE-0237
Authors
Ben Cohen
Review Manager
Doug Gregor
Status
Implemented (Swift 5.0)

01 何が問題だったのか

Array の持つ機能のほとんどは、SequenceCollection といった標準ライブラリのプロトコルを通じて汎用的に利用できます。そのため、Array を前提に書かれたコードの多くは、プロトコルに対する拡張として書き直すことができます。

しかし、withUnsafeBufferPointerwithUnsafeMutableBufferPointer はその例外で、Array などの具体型にしか用意されていませんでした。これらは要素が連続したメモリに並んでいることを前提に、生のバッファポインタを一時的に取り出して高速に処理するための API です。こうした「連続メモリへの直接アクセス」は便利なうえパフォーマンス上も重要ですが、ジェネリックコードからは使えず、プロトコルに対する拡張の中で同じ最適化を行うことができませんでした。

また、SequenceMutableCollection に対してジェネリックに書かれたアルゴリズムの中には、内部のストレージが連続していれば大幅に高速化できるものが数多くあります。たとえば次のようなケースです。

  • Data[UInt8] から初期化する場合、要素が連続メモリ上にあれば memcpy 一発で済ませたい
  • sort はマージソートとして実装されており、補助ストレージとの間で要素を行き来させるときに、連続メモリであれば非トリビアルな型でもメモリムーブで処理できる

こうした最適化を実現するには、「連続メモリが取れるならポインタで受け取り、取れなければ従来どおりの汎用処理にフォールバックする」という形のエントリポイントが必要です。標準ライブラリ内部にはこの目的のためのアンダースコア付きのカスタマイゼーションポイントが既に存在していましたが、一般のユーザーには公開されていませんでした。

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

SequenceMutableCollection に、連続メモリへのアクセスが可能な場合にだけクロージャを呼び出す 2 つのメソッドを追加します。

protocol Sequence {
  func withContiguousStorageIfAvailable<R>(
    _ body: (UnsafeBufferPointer<Element>) throws -> R
  ) rethrows -> R?
}

protocol MutableCollection {
  mutating func withContiguousMutableStorageIfAvailable<R>(
    _ body: (inout UnsafeMutableBufferPointer<Element>) throws -> R
  ) rethrows -> R?
}

どちらも、連続メモリが用意できるときは body にバッファポインタを渡してその戻り値を Optional に包んで返します。連続メモリを提供できない型の場合は body は呼ばれず、戻り値は nil になります。これにより、呼び出し側は「高速パスが使えるか」を実行時に判定できます。

extension Sequence where Element == UInt8 {
    func sum() -> Int {
        // 高速パス: 連続メモリがあればポインタ経由で合計する
        if let total = withContiguousStorageIfAvailable({ buffer -> Int in
            var sum = 0
            for byte in buffer {
                sum &+= Int(byte)
            }
            return sum
        }) {
            return total
        }

        // フォールバック: 連続メモリが得られないシーケンスはそのまま走査
        var sum = 0
        for byte in self {
            sum &+= Int(byte)
        }
        return sum
    }
}

デフォルト実装は常に nil を返します。ArrayArraySlice のように既に withUnsafe{Mutable}BufferPointer を持つ型は、これらの新メソッドに転送する形で実装され、Unsafe{Mutable}BufferPointer 自身は self を使って応答します。Slice<T> は基底コレクションに処理を委ねます。独自に実装を提供するコレクションは、自身の SubSequence についても「startIndex までの距離だけポインタを進めた同等のバッファ」が得られることを保証しなければなりません。

ミュータブル版の注意点

withContiguousMutableStorageIfAvailable で渡されるクロージャの引数は inout UnsafeMutableBufferPointer<Element> です。これは API 上、クロージャ内でバッファ自体を別のものに差し替えられるように見えますが、実際に差し替えるべきではありませんinout にしているのは主にエルゴノミクス上の理由で、Array の既存実装との整合性も踏まえた選択になっています。

また、クロージャがエラーを投げて脱出した場合、ミューテーションの途中結果がコレクションに反映されるかどうかは保証されません。実装によっては、内部ストレージへのポインタを直接渡している場合もあれば、一時バッファを渡してあとから書き戻している場合もあるためです。エラー時のクリーンアップはクロージャ側で責任を持って行う必要があります。

呼び出しごとに同じポインタが返るとは限らない

withUnsafe{Mutable}BufferPointer 系のメソッドは、同じコレクションに対して連続して呼び出したとしても、毎回同じアドレスのポインタが返ってくるとは限りません。たとえば小さな文字列をインラインで保持する実装では、呼び出しのたびに一時バッファへ展開されることがあり、そのバッファのアドレスは呼び出しごとに異なり得ます。ポインタをクロージャの外へ持ち出して保持するような使い方は引き続き避けるべきです。

使いどころ

このエントリポイントの主な目的は、ジェネリックなアルゴリズムから高速パスを解禁することです。標準ライブラリの sort のように、補助ストレージとの間で要素を移動させる処理では、連続メモリが取れるときにメモリムーブベースの実装に切り替えることで大きな高速化が得られます。一般のコードでも、「連続メモリならポインタ版、そうでなければイテレータ版」という二段構えの実装を書くパターンに使えます。