Add withTemporaryAllocation using Output(Raw)Span
01 何が問題だったのか
SE-0322 で導入された withUnsafeTemporaryAllocation は、短命なバッファをスタックに確保できる可能性がある低レベルの facility です。クロージャには UnsafeMutableBufferPointer もしくは UnsafeMutableRawBufferPointer が渡されるため、利用者は生ポインタを扱いながら、初期化した要素の数を自分で覚えておき、クロージャを抜けるまでにその範囲を必ず deinitialize する責任を負います。途中でエラーが投げられるケースまで含めて整合性を保つのは手間がかかり、ミスが未定義動作につながりやすい領域でした。
// 従来の書き方は生ポインタ相手にすべて手動
withUnsafeTemporaryAllocation(of: Float.self, capacity: capacity) { buffer in
var initialized = 0
defer {
buffer.baseAddress!.deinitialize(count: initialized)
}
for i in 0..<capacity {
(buffer.baseAddress! + initialized).initialize(to: Float(i))
initialized += 1
}
// 途中で throw されても initialized の範囲だけが deinitialize される必要がある
}
一方で、SE-0485 では未初期化メモリの初期化状態を型として管理する OutputSpan / OutputRawSpan が導入されました。これらは「先頭から count 個だけが初期化済み」という不変条件を自身で保持し、append などの操作で count を安全に増減させます。スコープを抜けるときには初期化済み領域だけが正しく後始末されるため、Array などのコンテナを初期化する API ではすでに利用者がこの安全性の恩恵を受けています。
しかし肝心の一時バッファ側は withUnsafeTemporaryAllocation のまま unsafe な API に留まっていて、せっかくの OutputSpan / OutputRawSpan の安全性を享受しようと思うと、利用者自身が両者を組み合わせる定型コードを毎回書かなければならない状態でした。
02 どのように解決されるのか
withUnsafeTemporaryAllocation を OutputSpan / OutputRawSpan で包んだ、トップレベルの自由関数 withTemporaryAllocation を2種類追加します。スタック確保される可能性がある一時バッファという特性はそのままに、クロージャには生ポインタではなく inout OutputSpan<T> または inout OutputRawSpan が渡されるため、利用者は生ポインタや初期化済み要素数の手動管理から解放されます。
型付きの一時バッファ
特定の型 T の値を一時的に並べたい場合は、of:capacity: を取るオーバーロードを使います。
let capacity = 42
let result = try withTemporaryAllocation(
of: Float.self,
capacity: capacity
) { output -> Int in
// output は inout OutputSpan<Float>
for i in 0..<capacity {
output.append(Float(i))
}
// 初期化済み領域の読み書きには span / mutableSpan を使う
var mutable = output.mutableSpan
updateInPlace(&mutable)
return aggregate(output.span)
}
// クロージャを抜けた時点で、初期化済み要素は自動的に deinitialize され、
// バッファも deallocate される。
クロージャ内では、append で要素を書き込み、途中経過を読むときは OutputSpan.span(Span<T>)、書き換えたいときは OutputSpan.mutableSpan(MutableSpan<T>)を通します。クロージャを抜けると、withTemporaryAllocation が OutputSpan を finalize して初期化済み要素だけを deinitialize し、その後にバッファを解放します。途中で throw された場合も同じ経路で後始末されるため、何個まで初期化したかを自分で追跡する必要はありません。
生バイト列の一時バッファ
エンコーダのように、異なる型の値を連続したバイト列として書き出す用途には、byteCount:alignment: を取るオーバーロードを使います。
let byteCount = 16
let result = try withTemporaryAllocation(
byteCount: byteCount,
alignment: 4
) { rawSpan -> Int in
// rawSpan は inout OutputRawSpan
rawSpan.append(repeating: 0, count: byteCount, as: UInt8.self)
var mutableBytes = rawSpan.mutableBytes
updateInPlace(&mutableBytes)
return aggregate(rawSpan.bytes)
}
// クロージャを抜けるとバッファは解放される。
読み出し・書き換え用のビューがそれぞれ OutputRawSpan.bytes(RawSpan)、OutputRawSpan.mutableBytes(MutableRawSpan)として得られます。OutputRawSpan が扱うのは BitwiseCopyable を前提にした生バイトなので、クロージャを抜けるときは finalize が呼ばれるだけで、個別の deinitialize は不要です。
位置づけとシグネチャ
2つの関数はいずれも、元の withUnsafeTemporaryAllocation をそのまま内側で呼び出し、戻ってきたバッファから OutputSpan / OutputRawSpan を組み立てて defer で finalize するだけの薄いラッパーです。
@available(SwiftCompatibilitySpan 5.0, *)
public func withTemporaryAllocation<T: ~Copyable, R: ~Copyable, E: Error>(
of type: T.Type,
capacity: Int,
_ body: (inout OutputSpan<T>) throws(E) -> R
) throws(E) -> R
@available(SwiftCompatibilitySpan 5.0, *)
public func withTemporaryAllocation<R: ~Copyable, E: Error>(
byteCount: Int,
alignment: Int,
_ body: (inout OutputRawSpan) throws(E) -> R
) throws(E) -> R
~Copyable 要素や typed throws、non-copyable な戻り値にも対応しているため、所有権を伴う値を一時バッファ越しに扱う場面でも、従来の withUnsafeTemporaryAllocation と同じ柔軟さを保てます。一方で利用者から見える世界は、inout OutputSpan<T> あるいは inout OutputRawSpan に対して append していくだけの形に揃い、unsafe なポインタ API を覚えなくてもスタック確保の恩恵を受けられるようになります。
Future Directions(今後の見通し)
提案では、今後の拡張方針として async オーバーロードが挙げられています。ただし、基盤となる withUnsafeTemporaryAllocation にまず async オーバーロードが必要になる上、サスペンドを跨ぐ確保は async スタック(ヒープ確保)に載ってしまい、一時バッファの利点が薄れるという指摘もあります。そのため、これらの関数だけ個別に async 対応を入れるのではなく、標準ライブラリの with-style 関数全体で async 対応をどう扱うかという議論とまとめて行う方向性が示されています(今回のスコープ外で、実現を約束するものではありません)。