InlineArray, a fixed-size array
01 何が問題だったのか
Swift で「順序付きの同じ型の要素の並び」を扱いたくなったとき、これまでの第一の選択肢は Array でした。Array は非常に汎用的で使いやすい一方、常に ヒープ割り当て される可変長コンテナであり、要素数が静的にわかっている場面では明らかに過剰です。ヒープ確保や参照カウントのコストは、組み込み領域のようにメモリや割り当て回数が制約される環境、あるいはパフォーマンスクリティカルなコードでは無視できないオーバーヘッドになります。
スタック上に並べたいだけならタプルしかなかった
N 個の値をスタック上にまとめて置きたいだけなら、これまではタプルで代用できました。
func complexAlgorithm() {
let elements = (first, second, third, fourth)
}
ただしタプルは 動的な添字アクセス も イテレーション もできません。
func complexAlgorithm() {
let elements = (first, second, third, fourth)
for i in 0 ..< 4 {
// error: cannot access element using subscript for tuple type
// '(Int, Int, Int, Int)'; use '.' notation instead
compute(elements[i])
}
}
withUnsafeTemporaryAllocation では unsafe に落ちてしまう
SE-0322 の withUnsafeTemporaryAllocation を使えば、スタック(または必要に応じてヒープ)に確保した領域に対して UnsafeMutableBufferPointer 経由で添字アクセスやイテレーションができます。しかし、これは名前の通り unsafe API であり、安全に使うためには寿命や初期化状態の管理をすべて自分で負う必要があります。メモリ安全性を掲げる Swift において、「サイズが静的に決まっている配列をスタックに置きたい」だけのコードがこの層まで降りなければならないのは不健全です。
noncopyable 要素をインラインに並べる手段が無い
さらに、Atomic や Mutex のような noncopyable な型をそのまま並べた固定サイズの配列は、Array でもタプルでも表現できません。ヒープに逃がさず、コピーもせず、N 個の noncopyable 値を所有する「箱」を作るには、言語・ライブラリ側にそれ専用の型が必要です。
02 どのように解決されるのか
標準ライブラリに、要素数を型パラメータとして持つ固定サイズの配列型 InlineArray を追加します。C の T[N]、C++ の std::array<T, N>、Rust の [T; N] に相当する型で、SE-0452 で導入された整数ジェネリックパラメータを用いて次のように定義されます。
public struct InlineArray<let count: Int, Element: ~Copyable>: ~Copyable {}
extension InlineArray: Copyable where Element: Copyable {}
extension InlineArray: BitwiseCopyable where Element: BitwiseCopyable {}
extension InlineArray: Sendable where Element: Sendable {}
要素が noncopyable である場合にも対応するため、InlineArray 自身も ~Copyable として宣言され、要素がコピー可能なときだけ条件付きで Copyable に適合します。
Proposal 本文中ではレビュー過程での仮名として
Vectorという表記が残っていますが、受理時にInlineArrayにリネームされたため、ここでは一貫してInlineArrayと表記します。
インライン確保でヒープ割り当てが発生しない
InlineArray のストレージは「その値が置かれる場所」に直接インライン確保されます。ローカル変数ならスタック、クラスのプロパティなら他のプロパティと一緒にそのインスタンスのヒープ領域の中、という具合です。ストレージのためだけに暗黙のヒープ割り当てが発生することは一切ありません。
func complexAlgorithm() {
// これはスタック上の割り当て。malloc も参照カウントも発生しない
let elements: InlineArray<4, Int> = [1, 2, 3, 4]
for i in elements.indices {
compute(elements[i]) // OK
}
}
配列リテラルによる初期化
InlineArray は ExpressibleByArrayLiteral には適合しません。そのプロトコルは一度 Array の値を経由するため、スタック上の InlineArray を初期化するためにわざわざ Array を確保するのは本末転倒だからです。そこで、配列リテラルから InlineArray を作るケースだけコンパイラが特別扱いし、各要素をそのスタックスロット上で直接初期化します。中間的な Array 値が作られたり、要素がコピー・ムーブされたりすることはありません。
let numbers: InlineArray<3, Int> = [1, 2, 3]
配列リテラルから InlineArray が生成されるのは、コンテキストから具体的に InlineArray 型だと分かっているときだけです。既存の Array リテラル解決ルールには影響せず、ソースコード互換性も保たれます。
let a = [1, 2, 3] // Swift.Array
let b: InlineArray<3, Int> = [1, 2, 3] // Swift.InlineArray
func generic<T>(_: T) {}
generic([1, 2, 3]) // Array として渡る
generic([1, 2, 3] as InlineArray<3, Int>) // InlineArray として渡る
要素数・要素型はいずれも推論可能で、どちらも省略すればリテラル側から両方が決まります。
let a: InlineArray<_, Int> = [1, 2, 3] // InlineArray<3, Int>
let b: InlineArray<3, _> = [1, 2, 3] // InlineArray<3, Int>
let c: InlineArray = [1, 2, 3] // InlineArray<3, Int>
func takesGenericInlineArray<let N: Int>(_: InlineArray<N, Int>) {}
takesGenericInlineArray([1, 2, 3]) // N は 3 と推論される
リテラルの要素数が型の count と一致しない場合はコンパイルエラーになります。
// error: expected '2' elements in InlineArray literal, but got '3'
let x: InlineArray<2, Int> = [1, 2, 3]
クロージャベースの初期化
リテラル以外にも、インデックスや直前の要素からクロージャで各要素を生成する初期化子が用意されており、noncopyable な要素にも対応します。
extension InlineArray where Element: ~Copyable {
// インデックスを受け取り、その位置に置く値を返すクロージャで初期化
public init<E: Error>(_ next: (Int) throws(E) -> Element) throws(E)
// 先頭の値と、「直前の要素の借用」から次の要素を作るクロージャで初期化
public init<E: Error>(
first: consuming Element,
next: (borrowing Element) throws(E) -> Element
) throws(E)
}
extension InlineArray where Element: Copyable {
// 同じ値のコピーで全要素を埋める
public init(repeating: Element)
}
具体的には次のように使えます。
// [Atomic(0), Atomic(1), Atomic(2), Atomic(3)]
let incrementingAtomics = InlineArray<4, Atomic<Int>> { i in
Atomic(i)
}
// 先頭 Sprite() と、それを順にコピーして作った計 4 要素
let copiedSprites = InlineArray<4, _>(first: Sprite()) { $0.copy() }
// InlineArray<3, Mutex<Int>> として推論される
let literalMutexes: InlineArray = [Mutex(0), Mutex(1), Mutex(2)]
クロージャの途中で throw された場合、すでに初期化済みの要素は順にデイニシャライズされたうえでエラーが呼び出し側に伝播します。値が使われなくなった時点で、各要素は通常通り要素ごとにデイニシャライズされます。
メモリレイアウト
InlineArray<count, Element> のサイズ・ストライドは Element の stride を count 倍した値、アラインメントは Element のものと等しくなります。タプルと違ってパディングが挟まらず、Element の stride そのものが間隔になる点が重要です。
MemoryLayout<UInt8>.stride == 1
MemoryLayout<InlineArray<4, UInt8>>.size == 4
MemoryLayout<InlineArray<4, UInt8>>.stride == 4
MemoryLayout<InlineArray<4, UInt8>>.alignment == 1
コレクション的な API
InlineArray は Sequence や Collection には 適合しません(理由は後述)。ただし、コレクションとして日常的に欲しい API は直接定義されています。
extension InlineArray where Element: ~Copyable {
public typealias Element = Element
public typealias Index = Int
// インスタンスを介さずに静的にも取れる count
public static var count: Int { count }
public var count: Int { count }
public var indices: Range<Int> { 0 ..< count }
public var isEmpty: Bool { count == 0 }
public var startIndex: Int { 0 }
public var endIndex: Int { count }
public borrowing func index(after i: Int) -> Int
public borrowing func index(before i: Int) -> Int
public mutating func swapAt(_ i: Int, _ j: Int)
public subscript(_ index: Int) -> Element
public subscript(unchecked index: Int) -> Element // 境界チェック無し
}
noncopyable 要素を持つ InlineArray も、indices を介したインデックスベースのループで走査できます。
let atomicInts: InlineArray<3, Atomic<Int>> = [Atomic(1), Atomic(2), Atomic(3)]
for i in atomicInts.indices {
print(atomicInts[i].load(ordering: .relaxed))
}
同名の既存型との共存
InlineArray という名前はユーザ側で定義されていることもあり得ますが、標準ライブラリ側は「ユーザ定義型を優先する」シャドーイングルールを持っており、モジュールに InlineArray が定義されていればそちらが常に優先されます。ジェネリック引数の数が合わなくてもユーザ側の定義が選ばれ、明示的に import していないファイルでだけ Swift.InlineArray が見えます。既存プロジェクトへのソース影響は起こりません。
Future Directions(見通し)
今回のスコープはコア型の導入に限られており、周辺機能は今後の提案に委ねられます。現時点で speculative に挙がっているもので、実現が約束されたものではありません。
Equatable/Hashable/CustomStringConvertibleなどへの適合: 要素が適合するときの条件付き適合として追加可能ですが、noncopyable 要素対応のためには各プロトコル自体の一般化が必要であり、availability の取り扱いが複雑になるため、プロトコル側の一般化を待って導入する方針です。Sequence/Collectionへの適合:Arrayと異なりInlineArrayは copy-on-write を持たず、値は eager にコピーされます。この状態でCollectionに適合させると、汎用アルゴリズムや slicing 経由で暗黙のコピーが多発しかねません。将来、noncopyable コンテナにも対応した新しいCollection系プロトコルが導入される見通しで、InlineArrayの適合はそちらに合わせて検討されます。Span系 API: SE-0447 で導入されたSpanをInlineArrayから取得する API(withSpan的なクロージャ版、または lifetime 依存の computed property)は、lifetime アノテーションの Proposal が整うまで保留されています。FixedCapacityArray/SmallArray:InlineArrayは「全要素が常に初期化済み」な固定サイズ型であるのに対し、FixedCapacityArrayは「固定容量の可変長配列」、SmallArrayは「小さい間はインライン、超えたらヒープ」の型として別途提案される可能性があります。初期化済みの範囲を追跡する言語サポートが前提となるため、現時点では検討段階です。[T; N]のようなシンタックスシュガー:InlineArrayがArrayやDictionaryと並ぶ基本型になることを見越した糖衣構文([N x T]/[T; N]など)は、本 Proposal のスコープ外で別途議論されます。- C 配列のインポート:
char[16]のような C の固定長配列を、現在のタプルに代えてInlineArrayとしてインポートする改善も検討されていますが、ソース互換性の兼ね合いで結論は出ておらず、本 Proposal では扱いません。
名前について
当初 Vector として提案されましたが、数学での Vector は固定サイズのベクトルを指すのに対し、C++ の std::vector のように「可変長」を指す用法が広まっているなど、語義の混乱が避けられません。レビューを経て、固定サイズ・インライン確保という特徴を素直に表す InlineArray に改名されました。Swift.Array と Swift.InlineArray の対比は、C++ の std::vector / std::array とは対応関係が逆になる点に注意してください。