Swift Digest
SE-0312 | Swift Evolution

Add indexed() and Collection conformances for enumerated() and zip(_:_:)

Proposal
SE-0312
Authors
Tim Vermeulen
Review Manager
Ben Cohen
Status
Returned for revision

01 何が問題だったのか

zip(_:_:) が返す Zip2Sequenceenumerated() が返す EnumeratedSequence は、標準ライブラリで長らく Sequence にしか適合していませんでした。このため、これらの結果を Collection 以上を要求する文脈で使えず、たびたび不便さや非効率が生じていました。

Collection に適合していない不便さ

たとえば次のような処理が本来より重くなります。

  • (1000..<2000).enumerated().dropFirst(500) は、Collection であれば定数時間で済むところを、Sequence しか持たないため先頭から 500 要素を走査する必要があります。
  • zip("abc", [1, 2, 3]).reversed() は、BidirectionalCollection であれば ReversedCollection を返すだけで済むのに、新しい配列を確保して要素を詰め直してから反転する実装になります。
  • SwiftUI の ListForEach のように RandomAccessCollection を受け取る API に、enumerate した結果や zip した結果をそのまま渡すことができません。

歴史的には Zip2SequenceEnumeratedSequenceCollection 適合を後付けするのは難しく、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 どのように解決されるのか

Zip2SequenceEnumeratedSequence に条件付きで Collection 系プロトコルへの適合を追加し、あわせて Collectionindexed() メソッドを新設します。zip(_:_:) には、両方が RandomAccessCollection のときに RandomAccessCollection を返す専用のオーバーロードも追加します。

Zip2SequenceCollection 適合

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) と書くだけで、両方がランダムアクセスなら自動的にこの型が返ります。

EnumeratedSequenceCollection 適合

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(...) のように明示すれば解消できます。

Collectionindexed() を追加

Collectionindexed() メソッドが追加されます。これは「本物の 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() を使います(offsetInt であり、コレクションの Index とは別物だと意識しておきます)。
  • その連番をコレクションの添字として使いたい、あるいは本物の Index が必要なら indexed() を使います。ArraySlice など startIndex が 0 でないコレクションでも正しく動きます。
  • 2 つのコレクションを並べて取り扱いたいときは zip(_:_:) を使います。両方がランダムアクセスなら、今回追加されるオーバーロードにより結果も RandomAccessCollection として扱えます。

状態

本 Proposal は review の結果 Returned for revision となっており、上記の API 形状のまま Swift の標準ライブラリに取り込まれたわけではありません。ここで提示されたアイデア(zip / enumerated への Collection 適合追加、および indexed() の導入)自体は依然として有用で、類似の API は Swift Algorithms パッケージでも提供されています。