Add indexed() and Collection conformances for enumerated() and zip(_:_:)
01 何が問題だったのか
zip(_:_:) が返す Zip2Sequence と enumerated() が返す EnumeratedSequence は、標準ライブラリで長らく Sequence にしか適合していませんでした。このため、これらの結果を Collection 以上を要求する文脈で使えず、たびたび不便さや非効率が生じていました。
Collection に適合していない不便さ
たとえば次のような処理が本来より重くなります。
(1000..<2000).enumerated().dropFirst(500)は、Collectionであれば定数時間で済むところを、Sequenceしか持たないため先頭から 500 要素を走査する必要があります。zip("abc", [1, 2, 3]).reversed()は、BidirectionalCollectionであればReversedCollectionを返すだけで済むのに、新しい配列を確保して要素を詰め直してから反転する実装になります。- SwiftUI の
ListやForEachのようにRandomAccessCollectionを受け取る API に、enumerate した結果や zip した結果をそのまま渡すことができません。
歴史的には Zip2Sequence や EnumeratedSequence に Collection 適合を後付けするのは難しく、SE-0234 Remove Sequence.SubSequence の前は型の制約上不可能で、さらにプロトコル適合に @available を付けられるようになるまでは ABI 互換性の観点からも追加できませんでした。これらの障害が取り除かれた今なら、適合を追加できる状況になっています。
enumerated() の「オフセット」はインデックスではない
enumerated() は (offset: Int, element: Element) のペアを返しますが、この offset はコレクションの先頭から数えた 0 始まりの整数にすぎません。コレクションが持つ本来の Index ではないので、次のような取り違えが起こります。
let numbers = [10, 20, 30, 40, 50]
let slice = numbers[2...] // ArraySlice: startIndex は 2
for (offset, element) in slice.enumerated() {
// offset は 0, 1, 2, ... と進む
// しかし slice[offset] のつもりで使うと添字が合わない
// (slice の有効な Index は 2, 3, 4, ...)
}
特に ArraySlice のようにスライスの startIndex が 0 でないコレクションでは、offset をそのまま添字として使うとランタイムで範囲外アクセスになりやすく、バグの温床になっていました。
代替として zip(c.indices, c) を使えば本物の Index とのペアを得られますが、記述が冗長で、indices の走査が高コストなコレクションでは性能的にも不利です。この「インデックスと要素を同時に取り出す」という頻出ユースケースに適した API が標準ライブラリに欠けている、という問題もありました。
02 どのように解決されるのか
Zip2Sequence と EnumeratedSequence に条件付きで Collection 系プロトコルへの適合を追加し、あわせて Collection に indexed() メソッドを新設します。zip(_:_:) には、両方が RandomAccessCollection のときに RandomAccessCollection を返す専用のオーバーロードも追加します。
Zip2Sequence の Collection 適合
Zip2Sequence は、両側のベースがそれぞれ Collection / BidirectionalCollection のとき、それぞれに条件付きで適合するようになります。
extension Zip2Sequence: Collection
where Sequence1: Collection, Sequence2: Collection { /* ... */ }
extension Zip2Sequence: BidirectionalCollection
where Sequence1: BidirectionalCollection,
Sequence2: BidirectionalCollection { /* ... */ }
RandomAccessCollection については、同じ Zip2Sequence 型に条件付きで適合させる方法が性能上うまくいきません(count の実装が、ランダムアクセスのときと非ランダムアクセスのときで最適な戦略が異なるため、どちらかを犠牲にするか遅延評価を混ぜる必要が出てしまいます)。そのため、zip(_:_:) に別オーバーロードを追加し、両方が RandomAccessCollection のときだけ専用型 Zip2RandomAccessCollection を返す設計になっています。
public func zip<Base1: RandomAccessCollection,
Base2: RandomAccessCollection>(
_ base1: Base1, _ base2: Base2
) -> Zip2RandomAccessCollection<Base1, Base2>
public struct Zip2RandomAccessCollection<Base1, Base2>
where Base1: RandomAccessCollection, Base2: RandomAccessCollection { /* ... */ }
extension Zip2RandomAccessCollection: RandomAccessCollection { /* ... */ }
呼び出し側のコードは従来どおり zip(a, b) と書くだけで、両方がランダムアクセスなら自動的にこの型が返ります。
EnumeratedSequence の Collection 適合
EnumeratedSequence も、ベースの能力に応じて Collection / BidirectionalCollection / RandomAccessCollection / LazyCollectionProtocol へ条件付きで適合します。
extension EnumeratedSequence: Collection
where Base: Collection { /* ... */ }
extension EnumeratedSequence: BidirectionalCollection
where Base: BidirectionalCollection { /* ... */ }
extension EnumeratedSequence: RandomAccessCollection
where Base: RandomAccessCollection {}
extension EnumeratedSequence: LazySequenceProtocol
where Base: LazySequenceProtocol {}
extension EnumeratedSequence: LazyCollectionProtocol
where Base: LazyCollectionProtocol {}
これにより、(1000..<2000).enumerated().dropFirst(500) が定数時間になり、"abc".enumerated().reversed() が ReversedCollection を返すといった効率化が得られます。
BidirectionalCollection の条件はあえて Base: BidirectionalCollection にとどめており、RandomAccessCollection にはしていません。endIndex から index(before:) を辿るときに count を求める必要があり、非ランダムアクセスの双方向コレクションでは last の取得が O(n) になるといった落とし穴はあるものの、reversed() や suffix(_:) などの効率的なオーバーロードを使えるメリットのほうが大きいと判断されています。
LazySequenceProtocol / LazyCollectionProtocol への適合が増えたことで、self.lazy.enumerated().filter { ... }.map(\.element) のような遅延チェーンの中で enumerated() が遅延性を伝播するようになります。これは同時に小さなソース互換性への影響でもあり、従来は返り値が [Element] に解決されていた一部のコードで LazyMapSequence<...> が推論されるケースがあり得ます。その場合は、返り値が LazyMapSequence ではなく [Element] になるよう return Array(...) のように明示すれば解消できます。
Collection に indexed() を追加
Collection に indexed() メソッドが追加されます。これは「本物の Index と要素」のペアを返すコレクションを返します。
extension Collection {
public func indexed() -> IndexedCollection<Self>
}
public struct IndexedCollection<Base: Collection> { /* ... */ }
extension IndexedCollection: Collection { /* ... */ }
extension IndexedCollection: BidirectionalCollection
where Base: BidirectionalCollection { /* ... */ }
extension IndexedCollection: RandomAccessCollection
where Base: RandomAccessCollection {}
extension IndexedCollection: LazySequenceProtocol
where Base: LazySequenceProtocol {}
extension IndexedCollection: LazyCollectionProtocol
where Base: LazyCollectionProtocol {}
enumerated() が offset: Int を返していたのに対し、indexed() は元のコレクションの Index をそのまま返すので、そのまま添字に使えて安全です。
let numbers = [10, 20, 30, 40, 50]
let slice = numbers[2...]
for (index, element) in slice.indexed() {
// index は 2, 3, 4, ... と slice の本物の Index
precondition(slice[index] == element)
}
// 最大値を持つ要素の Index が欲しい、といったケースでも素直に書ける
if let (maxIndex, _) = slice.indexed().max(by: { $0.1 < $1.1 }) {
print(maxIndex) // slice に対して有効な Index
}
zip(c.indices, c) と比べても、indices を別途走査しなくてよいぶん効率的で、書き方もシンプルです。
使い分けの目安
- 単に 0 始まりの連番が欲しいだけなら、これまでどおり
enumerated()を使います(offsetはIntであり、コレクションのIndexとは別物だと意識しておきます)。 - その連番をコレクションの添字として使いたい、あるいは本物の
Indexが必要ならindexed()を使います。ArraySliceなどstartIndexが 0 でないコレクションでも正しく動きます。 - 2 つのコレクションを並べて取り扱いたいときは
zip(_:_:)を使います。両方がランダムアクセスなら、今回追加されるオーバーロードにより結果もRandomAccessCollectionとして扱えます。
状態
本 Proposal は review の結果 Returned for revision となっており、上記の API 形状のまま Swift の標準ライブラリに取り込まれたわけではありません。ここで提示されたアイデア(zip / enumerated への Collection 適合追加、および indexed() の導入)自体は依然として有用で、類似の API は Swift Algorithms パッケージでも提供されています。