RigidArray and UniqueArray
01 何が問題だったのか
Swift 5.9 で non-copyable な struct / enum が言語に入り、標準ライブラリにも Atomic、Mutex、固定サイズの InlineArray(SE-0453)といった non-copyable に対応した型が揃ってきました。しかし、動的にサイズが変えられるリスト型 としては、相変わらず copyable な Array しか用意されておらず、non-copyable な要素を持つリストを扱う手段がありません。
Array を拡張して non-copyable 要素に対応させるアイデアも考えられますが、現実的ではありません。Array は copy-on-write 値セマンティクス を前提にしており、mutate 時に要素をコピーできない場合の扱いが自然に定まらないためです。要素がコピーできるかを実行時に分岐させる方式も、あらゆる mutate に条件分岐を挿入することになり、Array をいま以上に「性能が読みにくい型」にしてしまいます。これは non-copyable 型を導入した狙い(Ownership Manifesto が示す、性能を静的に見通しやすくする方向性)とも真っ向からぶつかります。
加えて Array には、パフォーマンス重視の用途から見ると扱いづらい 2 つの性質があります。
- copy-on-write 値セマンティクス: 共有されたコピーをうっかり書き換えると、その時点でストレージ全体の複製が走り、定数時間だったはずの操作が突然線形時間に化けます。どこでコピーが起きるかは型情報からは見えません。
- 暗黙の動的リサイズ:
appendはふだんは定数時間ですが、容量を超えた瞬間に新しいバッファを確保して全要素を引っ越す、線形時間の処理に切り替わります。幾何級数的にサイズを伸ばす戦略により均せば償却 O(1) になりますが、最悪ケースの一回のappendは O(count) で、事前に十分な capacity を確保し損ねたときのペナルティがソース上に現れません。
こうした性質は多くの場面では便利ですが、組み込みのようにメモリ予算が厳しかったり、リアルタイム処理のようにレイテンシのスパイクを許容できなかったりする用途では、むしろ邪魔になります。既存の Array にこれ以上機能を足して対応するのではなく、用途に合わせた別の配列型 が必要でした。
整理すると、欲しいのは次の表の「?」にあたる型です。copy-on-write を使いながら固定容量にする意味は乏しいので、右上の 1 枠は空のままです。
| non-copyable 要素対応 | copy-on-write | |
|---|---|---|
| 固定 capacity | ? | — |
| 動的 | ? | Array |
02 どのように解決されるのか
標準ライブラリに新しいモジュール Containers を追加し、先ほどの表の 2 つの「?」を埋める 2 種類の配列型 RigidArray と UniqueArray を提供します。どちらもヒープに確保した連続メモリを 1 つ持ち、要素は常に先頭側に詰められる、古典的な可変長配列のデータ構造です。どちらも自身が non-copyable なので、non-copyable な要素もそのまま格納できます。
@frozen
public struct RigidArray<Element: ~Copyable>: ~Copyable {}
@frozen
public struct UniqueArray<Element: ~Copyable>: ~Copyable {}
extension RigidArray: Sendable where Element: Sendable & ~Copyable {}
extension UniqueArray: Sendable where Element: Sendable & ~Copyable {}
Containers モジュールは今後、リングバッファなど他のデータ構造の受け皿にもなる想定です。
UniqueArray: 一意所有の可変長配列
UniqueArray は、C++ の std::vector や Rust の Vec に相当する、自動リサイズ付きの動的配列です。copy-on-write は持たず、ストレージは常に「ただ一つの変数」から一意に所有されます。これは型レベルで強制されるので、誤って共有コピーを mutate してしまう、という事故は起こりません。
import Containers
struct FileHandle: ~Copyable {
let fd: UInt32
init(reading path: String) throws { fd = try open(path, .read) }
deinit { try! close(fd) }
}
let foo = try FileHandle(reading: "foo.txt")
let bar = try FileHandle(reading: "bar.md")
var a = UniqueArray<FileHandle>()
a.append(foo) // OK, consumes `foo`
a.append(bar) // OK, consumes `bar`
var b = a // `a` を consume して `b` にムーブ
b.append(try FileHandle(reading: "baz.swift")) // OK
a.append(try FileHandle(reading: "Info.plist")) // error: `a` used after consume
UniqueArray 自体が non-copyable なので、var b = a は コピーではなくムーブ になり、以降 a は使用不可になります。要素を受け取るメソッドは consuming / borrowing で所有権の受け渡しが明示されています。
capacity が足りなくなると内部で再確保し、一定の倍率で成長します。成長倍率は実装詳細で、環境・プラットフォーム・Swift のリリースによって変わりうる(ユーザが設定することはできない)ため、「一回の append が確実に定数時間」を要求する用途には向きません。そうした用途には後述の RigidArray を使います。
RigidArray: 固定 capacity の配列
RigidArray は、メモリ予算の厳しい組み込みやリアルタイム用途など、暗黙のアロケーションを一切起こしたくない ケース向けの型です。常に明示的に与えた capacity で動き、その中に収まる限りにおいて UniqueArray とほぼ同じ操作を提供します。名前の通り「融通が効かない(rigid)」のが特徴で、その代わり性能が読みやすくなります。
capacity を超えた追加は復帰可能なエラーではなく、Array の空配列に対する removeLast() と同じ種類の「プログラマ側の誤用」として扱われ、ランタイムトラップ になります。
var c = RigidArray<Int>(capacity: 2)
print(c.isFull) // => false
print(c.freeCapacity) // => 2
c.append(23)
c.append(42)
print(c.isFull) // => true
print(c.freeCapacity) // => 0
c.append(7) // runtime error: RigidArray capacity overflow
isFull と freeCapacity で事前にチェックできます。トラップを避けたい場合には、「満杯なら append せず元の値を返す」非トラップ版の pushLast(_:) も用意されています。
if let rejected = c.pushLast(7) {
// 入らなかった。rejected に 7 が戻ってくる
}
capacity は型の一部ではないので、必要になったら 明示的に 伸ばせます。
var d = RigidArray<Int>(capacity: 2)
d.append(10); d.append(20)
d.reallocate(capacity: 10) // ちょうど 10 に割り当て直す
d.append(30) // OK
d.reserveCapacity(32) // 現在の capacity が 32 未満なら伸ばす
reallocate(capacity:) はリクエストした通りの容量を(多すぎも少なすぎもせず)確保します。UniqueArray はこの RigidArray の上に、幾何級数的なリサイズ戦略を乗せたラッパとして実装されています。
共通の API
RigidArray と UniqueArray は、生成・追加・挿入・削除・置換・インデックスアクセスなど、配列として期待される一通りの操作を備えています。ポイントだけ示します。
- インデックスは
Intで、startIndex/endIndex/indices/subscript/swapAt(_:_:)などのArrayと同じ形のインターフェースを持ちます。 - 連続メモリへのビューとして
span: Span<Element>とmutableSpan: MutableSpan<Element>が O(1) で取れます。Span系 API と組み合わせてそのまま渡せます。 - non-copyable 要素でも使える操作:
append(_:)/insert(_:at:)/popLast()/removeLast()/remove(at:)/removeSubrange(_:)、ムーブ系のappend(moving:)/insert(moving:at:)/replace(removing:moving:)など。要素はconsumingで受け取ります。 - 出力用の
OutputSpanを使って、要素を 初期化の過程で直接書き込む API も用意されます(init(capacity:initializingWith:)、append(addingCount:initializingWith:)、insert(addingCount:at:initializingWith:)、replace(removing:addingCount:initializingWith:)、任意編集を許すedit { ... }など)。これにより、要素の一時コピーや中間バッファを挟まずに済みます。 - 要素が
Copyableのときだけ使える API:clone()/clone(capacity:)(明示的なディープコピー)、init(repeating:count:)、init(capacity:copying:)、append(copying:)/insert(copying:at:)/replace(removing:copying:)の各種オーバーロード(UnsafeBufferPointer/Span/Sequence/Collectionからコピー)。
// Copyable 要素の例
var xs = UniqueArray<Int>()
xs.append(copying: [1, 2, 3]) // Sequence からコピー
let ys = xs.clone() // 明示的ディープコピー
xs.append(copying: ys.span) // Span からコピー
Equatable / Hashable(要素が適合しているとき)、CustomStringConvertible / CustomDebugStringConvertible、および SE-0516 で導入される BorrowingSequence に適合します。BorrowingSequence の iterator は同提案の SpanIterator です。なお ExpressibleByArrayLiteral には適合しません(Array 経由になって性能目標を満たせないため)。
使い分け
- 一意所有で済む・ざっくり高性能が欲しいだけの用途 →
UniqueArray - copy-on-write が必要・要素が copyable で一般用途 → 従来通り
Array - すべてのアロケーションを明示したい最低レベルの用途(組み込み、リアルタイム、OS コアなど)→
RigidArray
Future Directions(今後の見通し)
提案では次のような発展方向が挙げられています。いずれも speculative で、実現を約束するものではありません。
clone()が現状Element: Copyableを要求している制限を外すため、Cloneableのような明示的ディープコピー用プロトコルを導入する案。これが入ればUniqueArray<UniqueArray<Int>>のようなネストも扱いやすくなります。- 同じ「Rigid / Unique」の命名パターンを他のコンテナにも広げる案(
RigidDeque/UniqueDeque/RigidSet/UniqueSet/RigidDictionary/UniqueDictionaryなど)。swift-collections パッケージには既にプロトタイプがあります。 BorrowingSequenceの上に乗る、より一般的な「コンテナプロトコル」の整備。swift-collections 側で設計が検討されています。- 配列リテラル初期化を
ExpressibleByArrayLiteralに代わる形(OutputSpanベースのArrayLiterableなど)で再定義し、RigidArray/UniqueArrayでもリテラルが使えるようにする案。