Swift Digest
SE-0147 | Swift Evolution

Move UnsafeMutablePointer.initialize(from:) to UnsafeMutableBufferPointer

Proposal
SE-0147
Authors
Ben Cohen
Review Manager
Doug Gregor
Status
Implemented (Swift 3.1)

01 何が問題だったのか

UnsafeMutablePointer には、Collection を受け取って連続メモリ上に要素を書き込む initialize(from:) メソッドがありました。このメソッドは Array のようにバッキングストアに連続メモリを持つコレクションの実装を支える重要な部品で、例えば Array.append(contentsOf:) は、渡されたコレクションの count 分だけメモリを確保してから、このメソッドにコレクションを流し込む、という形で実装されていました。

しかしこの設計には、安全性を脅かす大きな穴がありました。コレクションの count は、イテレータが実際に返す要素数と一致するとは限らないのです。例えば、毎回異なる結果を返すように誤用された lazy.filter、あるいは単に実装にバグのあるコレクションでは、count が実際の要素数より小さくなり得ます。

initialize(from:) は受け取るバッファの上限を知らないため、count を信じて確保された領域を超えて書き込んでしまい、未定義動作に陥ります。標準ライブラリの「安全な」構成要素だけを使っていても、次のようなコードでメモリアクセス違反を起こせてしまいます。

var i = 0
let c = repeatElement(42, count: 10_000).lazy.filter { _ in
    // i をキャプチャして、イテレーション間で
    // 一貫しない振る舞いをさせる
    i += 1; return i > 10_000
}
var a: [Int] = []
// a は不足した容量しか確保しないまま
// self._buffer.initialize(from: c) を呼び出す
a.append(contentsOf: c) // メモリアクセス違反

コレクションが一貫しない count を返すこと自体はプログラミングエラーですが、それに対して未定義動作で応えるのは妥当ではありません。Unsafe... 系 API では通常、安全性の保証は呼び出し側の責務です。ところが Array.append(contentsOf:) のようなジェネリックな文脈では、呼び出し側が受け取ったコレクションの実装を知り得ないため、このメソッドを安全に使うこと自体が不可能でした。

加えて、count を前提にした API であるために、count を持たない SequenceunderestimatedCount しかない)からメモリを初期化する用途には使えず、ジェネリックコードの最適化の幅も狭めていました。

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

初期化メソッドを「ポインタ+容量」を表す UnsafeMutableBufferPointer 側へ移し、容量を知った状態で Sequence から初期化できるようにします。既存の UnsafeMutablePointer.initialize(from:)Collection 版)は非推奨化され、Swift 4.0 で削除される予定です。生メモリ版についても同様に、UnsafeMutableRawPointer.initializeMemory(as:from:) を非推奨化し、UnsafeMutableRawBufferPointer 側に新 API が用意されます。

新しい UnsafeMutableBufferPointer.initialize(from:)

新しい API は Sequence を受け取り、バッファ容量を上限として要素をコピーします。バッファに入り切らなかった残りの要素は、戻り値のタプルに含まれるイテレータから取り出せます。バッファが余った場合は、どこまで書き込んだかを示すインデックスが返ります。

extension UnsafeMutableBufferPointer {
    @discardableResult
    public func initialize<S: Sequence>(
        from source: S
    ) -> (unwritten: S.Iterator, initializedUpTo: Index)
        where S.Iterator.Element == Iterator.Element
}

事前条件は「バッファが source.underestimatedCount 要素分のキャパシティを持っていること」です。これを下回る割り当てで呼び出した場合、実行時にトラップする可能性があります(必ずトラップするとは限りません。コレクションによっては O(n) になるため、デバッグビルドでのみ検査される場合があります)。以前のような未定義動作には陥らず、振る舞いは常に定義されたものになります。

使用例は次のようになります。

let buffer = UnsafeMutableBufferPointer<Int>.allocate(capacity: 5)
defer { buffer.deallocate() }

// アンダーフロー: バッファが余る
var (iterator1, endIndex1) = buffer.initialize(from: [1, 2, 3])
// endIndex1 == 3。buffer[0..<3] が初期化済み。
// iterator1.next() は nil。

// オーバーフロー: ソースの方が長い
var (iterator2, endIndex2) = buffer.initialize(from: 1...100)
// endIndex2 == 5。buffer[0..<5] が 1...5 で初期化済み。
// iterator2 から残りの 6, 7, ... を取り出せる。
while let remaining = iterator2.next() {
    // バッファに入り切らなかった要素を処理
    _ = remaining
}

オーバーフローとアンダーフローは多くの場合は互いに排他的ですが、バッファがちょうど使い切られた場合だけは両立し得ます。バッファが埋まっていてもソースにまだ要素が残っているかは、返されたイテレータに対して next() を呼ぶまで分からないという点には注意が必要です(そのため戻り値のイテレータは var で受ける必要があります)。

UnsafeMutableRawBufferPointer.initializeMemory(as:from:)

生メモリ版も同じ考え方で、Sequence を受け取り、残りのイテレータと、初期化済み領域を表す型付きバッファ(UnsafeMutableBufferPointer<S.Iterator.Element>)を返します。

extension UnsafeMutableRawBufferPointer {
    @discardableResult
    public func initializeMemory<S: Sequence>(
        as: S.Iterator.Element.Type, from source: S
    ) -> (unwritten: S.Iterator, initialized: UnsafeMutableBufferPointer<S.Iterator.Element>)
}

Array などへの波及

Array / ArraySlice / ContiguousArray に定義されていた Collection 特化の append(contentsOf:)+= オーバーロードは不要になり、削除されます。Sequence 版の実装を最適化すればコレクション版と同等の性能が得られるためです。+= はジェネリックな一本化された関数に置き換わります。

public func += <
    R : RangeReplaceableCollection, S : Sequence
>(lhs: inout R, rhs: S)
    where R.Iterator.Element == S.Iterator.Element

これにより、numbers += 10...15 のような使い方はそのまま動作しつつ、前述のメモリ安全性の問題は根本から解消されます。