Swift Digest
SE-0485 | Swift Evolution

OutputSpan: delegate initialization of contiguous memory

Proposal
SE-0485
Authors
Guillaume Lessard
Review Manager
Doug Gregor
Status
Implemented (Swift 6.2)

01 何が問題だったのか

SE-0447 で導入された Span と、SE-0467MutableSpan によって、すでに初期化されている連続メモリへの安全なアクセス手段は揃いました。Span は読み取り専用、MutableSpan は要素数固定の書き換え用で、いずれも「初期化済み要素がちょうど count 個ある」状態を表します。

一方で、コンテナの初期化を呼び出し側のコードに委ねたい、というユースケースは別物です。Array.init(unsafeUninitializedCapacity:initializingWith:)String.init(unsafeUninitializedCapacity:initializingUTF8With:) といった既存 API は、まさにこの用途のために用意されていますが、いずれも UnsafeMutableBufferPointer を露出する unsafe な API です。

let array = Array<Int>(unsafeUninitializedCapacity: 10) { buffer, initializedCount in
    // buffer は UnsafeMutableBufferPointer<Int>
    // initializedCount を正しく更新しないと未定義動作
    for i in 0..<10 {
        buffer.initializeElement(at: i, to: i * i)
    }
    initializedCount = 10
}

このパターンには次のような問題があります。

  • UnsafeMutableBufferPointer を扱うため、境界チェックも初期化済み要素数の整合性チェックもなく、誤れば未定義動作に直結します。
  • initializedCount の更新を忘れたり間違えたりすると、未初期化メモリを「初期化済み」と扱ってしまう危険があります。
  • セキュリティを重視する環境では unsafe 型に触れること自体が忌避されます。
  • エンコーダのように 型のないバイト列 を順番に書き込んでいくユースケースには、対応する安全な API が存在しません。

要するに、「未初期化のバッファを部分的に初期化していく」という操作を、Span / MutableSpan と同じ水準の安全性で表現できる型がありませんでした。

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

未初期化メモリへの初期化を委譲するための型として OutputSpan<Element>OutputRawSpan を導入します。どちらも non-copyable かつ non-escapable で、Span / MutableSpan と同じく排他アクセス・ライフタイム安全・境界チェックを保証します。

OutputSpan が表すメモリは「先頭から count 個の初期化済み要素」と「その後ろの未初期化領域(capacity - count 個分)」の二領域で構成され、操作のたびに count が増減して常にこの不変条件が保たれます。一方の MutableSpan が「初期化済み要素数を変えずに値を書き換える」型だったのに対し、OutputSpan は「初期化済み要素数そのものを増減させる」型である点が違いです。

コンテナを初期化する

利用者がもっとも触れるのは、Array などのコンテナに追加される、OutputSpan を引数に取るクロージャベースのイニシャライザです。

let squares = Array<Int>(capacity: 10) { span in
    // span は inout OutputSpan<Int>
    for i in 0..<10 {
        span.append(i * i)
    }
}
// squares == [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

capacity が確保された後、クロージャに渡される OutputSpanappend していくだけでコンテナが構築されます。UnsafeMutableBufferPointerinitializedCount の手動更新も登場せず、容量を超えた append は trap します。クロージャを抜けた時点で OutputSpan 内の初期化済み要素数がそのままコンテナの要素数になります。

末尾に追記するための append(addingCapacity:initializingWith:) も用意されます。

var buffer: [UInt8] = [0x89, 0x50, 0x4E, 0x47]
buffer.append(addingCapacity: 4) { span in
    span.append(0x0D)
    span.append(0x0A)
    span.append(0x1A)
    span.append(0x0A)
}

同様のイニシャライザは ContiguousArray / ArraySlice / String / String.UnicodeScalarView / InlineArray にも追加されます。String 系には UTF-8 不正シーケンスを置換文字に修復する repairingUTF8WithCapacity: と、不正があれば nil を返す validatingUTF8WithCapacity: の2系統が用意されます。InlineArray は要素数が型レベルで決まっているため、クロージャは すべての要素を初期化しなければならず、漏れがあれば trap します。

OutputSpan の主な API

OutputSpan の API は、Element: ~Copyable でも使える形でほぼ揃っています。

extension OutputSpan where Element: ~Copyable {
    var count: Int { get }          // 初期化済み要素数
    var capacity: Int { get }       // 確保済み要素数
    var freeCapacity: Int { get }   // capacity - count
    var isEmpty: Bool { get }
    var isFull: Bool { get }

    mutating func append(_ value: consuming Element)
    mutating func removeLast() -> Element
    mutating func removeLast(_ n: Int)
    mutating func removeAll()

    // 初期化済み領域への安全なビュー
    var span: Span<Element> { get }                 // 読み取り専用
    var mutableSpan: MutableSpan<Element> { mutating get } // 書き換え

    // 初期化済み領域へのインデックスアクセス
    typealias Index = Int
    var indices: Range<Index> { get }
    subscript(_ index: Index) -> Element { borrow; mutate }
    mutating func swapAt(_ i: Index, _ j: Index)
}

Element: Copyable のときは append(repeating:count:) も使えます。removeLast 系で削除された領域は再び未初期化状態に戻ります。

OutputRawSpan:型のないバイト列向け

エンコーダのように、異なる型の値を連続したバイト列として書き出していく用途には OutputRawSpan を使います。アライメントは扱わず、純粋にバイト単位の追記を提供します。

extension OutputRawSpan {
    var byteCount: Int { get }
    var capacity: Int { get }
    var freeCapacity: Int { get }

    mutating func append(_ value: UInt8)
    mutating func append<T: BitwiseCopyable>(_ value: T, as type: T.Type)
    mutating func append<T: BitwiseCopyable>(
        repeating repeatedValue: T, count: Int, as type: T.Type
    )

    var bytes: RawSpan { get }
    var mutableBytes: MutableRawSpan { mutating get }

    mutating func removeLast() -> UInt8
    mutating func removeLast(_ n: Int)
    mutating func removeAll()
}

Foundation.Data にも、OutputSpan<UInt8> あるいは OutputRawSpan を受け取るイニシャライザと append API が追加される予定です(こちらは Swift Evolution 外の追加です)。

unsafe コードとの相互運用

C 由来の API などに既存のバッファを渡したい場合や、順不同で初期化したい場合のために、unsafe な脱出口も用意されています。

extension OutputSpan where Element: ~Copyable {
    mutating func withUnsafeMutableBufferPointer<E: Error, R: ~Copyable>(
        _ body: (
            UnsafeMutableBufferPointer<Element>,
            _ initializedCount: inout Int
        ) throws(E) -> R
    ) throws(E) -> R
}

クロージャを抜けるときには「initializedCount がバッファ先頭から数えた初期化済み要素数と一致し、その範囲が単一の連続領域である」という不変条件を呼び出し側が守る必要があります。これを破ると未定義動作になるため @unsafe 相当の扱いです。

自前で OutputSpan を生成する

コンテナ作者が自分の型に同様の API を生やす場合は、UnsafeMutableBufferPointer から OutputSpan を作り、最後に finalize(for:) で確定するパターンを使います。

let span = OutputSpan(buffer: buffer, initializedCount: 0) // @unsafe
// ... span に append していく ...
let initializedCount = span.finalize(for: buffer) // OutputSpan を consume

finalize(for:)OutputSpan を consume して初期化済み要素数を返します。渡すバッファは生成時と同一でなければならず、間違えると trap します。finalize を呼ばずに OutputSpan がスコープを抜けた場合は、初期化済み領域が自動的にデイニシャライズされます。

ゼロ容量の OutputSpan を作る引数なしイニシャライザも提供され、Optionalnil ケース用のプレースホルダなどに使えます。同じ目的で Span / RawSpan / MutableSpan / MutableRawSpan にも空イニシャライザが追加されます。

MutableSpan / MutableRawSpan への影響

SE-0467 で提案されていたバルク update() 系メソッドは、OutputSpan のバルク初期化メソッドと命名・設計を揃えるため、いったん削除されます。当面は append を繰り返す方法、もしくは withUnsafeBufferPointer 経由のワークアラウンドで対応し、命名が固まり次第パッケージで再提供される予定です。

Future Directions(今後の見通し)

提案では、OutputSpan の今後の拡張方針として次のような方向性が示唆されています。いずれも今回のスコープ外で、別 Proposal で改めて議論されます。

  • 任意の順序での初期化を支援するヘルパ(unsafe な軽量版から、ビットマップで初期化状態を追跡する safe な重量版まで)
  • 末尾以外への挿入と、任意位置からの削除
  • Array.append(addingCapacity:initializingWith:) の派生として、初期化済み領域もまとめて編集できる edit() のような API
  • append(contentsOf:) 相当のバルクコピー/ムーブ API。non-copyable / non-escapable な要素や将来の Container プロトコルまで見据えた命名を詰める必要があるため、まずはパッケージで先行検証する予定です。