|
| 1 | +# Add `indexed()` and `Collection` conformances for `enumerated()` and `zip(_:_:)` |
| 2 | + |
| 3 | +* Proposal: [SE-0312](0312-indexed-and-enumerated-zip-collections.md) |
| 4 | +* Author: [Tim Vermeulen](https://github.com/timvermeulen) |
| 5 | +* Review Manager: [Ben Cohen](https://github.com/airspeedswift) |
| 6 | +* Status: **Active Review (Apr 27 - May 7 2021)** |
| 7 | +* Implementation: [apple/swift#36851](https://github.com/apple/swift/pull/36851) |
| 8 | + |
| 9 | +## Introduction |
| 10 | +This proposal aims to fix the lack of `Collection` conformance of the sequences returned by `zip(_:_:)` and `enumerated()`, preventing them from being used in a context that requires a `Collection`. Also included is the addition of the `indexed()` method on `Collection` as a more ergonomic, efficient, and correct alternative to `c.enumerated()` and `zip(c.indices, c)`. |
| 11 | + |
| 12 | +Swift-evolution thread: [Pitch](https://forums.swift.org/t/pitch-add-indexed-and-collection-conformances-for-enumerated-and-zip/47288) |
| 13 | + |
| 14 | +## Motivation |
| 15 | +Currently, the `Zip2Sequence` and `EnumeratedSequence` types conform to `Sequence`, but not to any of the collection protocols. Adding these conformances was impossible before [SE-0234 Remove `Sequence.SubSequence`](https://github.com/apple/swift-evolution/blob/main/proposals/0234-remove-sequence-subsequence.md), and would have been an ABI breaking change before the language allowed `@available` annotations on protocol conformances ([PR](https://github.com/apple/swift/pull/34651)). Now we can add them! |
| 16 | + |
| 17 | +Conformance to the collection protocols can be beneficial in a variety of ways, for example: |
| 18 | +* `(1000..<2000).enumerated().dropFirst(500)` becomes a constant time operation. |
| 19 | +* `zip("abc", [1, 2, 3]).reversed()` will return a `ReversedCollection` rather than allocating a new array. |
| 20 | +* SwiftUI’s `List` and `ForEach` views will be able to directly take an enumerated or zipped collection as their data. |
| 21 | + |
| 22 | +This proposal also includes the addition of the `indexed()` method (which can already be found in the [Swift Algorithms](https://github.com/apple/swift-algorithms) package) as an alternative for many use cases of `zip(_:_:)` and `enumerated()`. When the goal is to iterate over a collection’s elements and indices at the same time, `enumerated()` is often inadequate because it provides an offset, not a true index. For many collections this integer offset is different from the `Index` type, and in the case of `ArraySlice` in particular this offset is a common source of bugs when the slice’s `startIndex` isn’t `0`. `zip(c.indices, c)` solves these problems, but it is less ergonomic than `indexed()` and potentially less performant when traversing the indices of a collection is computationally expensive. |
| 23 | + |
| 24 | +## Detailed design |
| 25 | +Conditionally conform `Zip2Sequence` to `Collection`, `BidirectionalCollection`, and `RandomAccessCollection`. |
| 26 | + |
| 27 | +> **Note**: OS version 9999 is a placeholder and will be replaced with whatever actual OS versions this functionality will be introduced in. |
| 28 | +
|
| 29 | +```swift |
| 30 | +@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) |
| 31 | +extension Zip2Sequence: Collection |
| 32 | + where Sequence1: Collection, Sequence2: Collection |
| 33 | +{ |
| 34 | + // ... |
| 35 | +} |
| 36 | + |
| 37 | +@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) |
| 38 | +extension Zip2Sequence: BidirectionalCollection |
| 39 | + where Sequence1: BidirectionalCollection, Sequence2: BidirectionalCollection |
| 40 | +{ |
| 41 | + // ... |
| 42 | +} |
| 43 | + |
| 44 | +@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) |
| 45 | +extension Zip2Sequence: RandomAccessCollection |
| 46 | + where Sequence1: RandomAccessCollection, Sequence2: RandomAccessCollection {} |
| 47 | +``` |
| 48 | + |
| 49 | +Conditionally conform `EnumeratedSequence` to `Collection`, `BidirectionalCollection`, `RandomAccesCollection`, and `LazyCollectionProtocol`. |
| 50 | + |
| 51 | +```swift |
| 52 | +@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) |
| 53 | +extension EnumeratedSequence: Collection where Base: Collection { |
| 54 | + // ... |
| 55 | +} |
| 56 | + |
| 57 | +@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) |
| 58 | +extension EnumeratedSequence: BidirectionalCollection |
| 59 | + where Base: BidirectionalCollection |
| 60 | +{ |
| 61 | + // ... |
| 62 | +} |
| 63 | + |
| 64 | +@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) |
| 65 | +extension EnumeratedSequence: RandomAccessCollection |
| 66 | + where Base: RandomAccessCollection {} |
| 67 | + |
| 68 | +@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) |
| 69 | +extension EnumeratedSequence: LazySequenceProtocol |
| 70 | + where Base: LazySequenceProtocol {} |
| 71 | + |
| 72 | +@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) |
| 73 | +extension EnumeratedSequence: LazyCollectionProtocol |
| 74 | + where Base: LazyCollectionProtocol {} |
| 75 | +``` |
| 76 | + |
| 77 | +Add an `indexed()` method to `Collection` that returns a collection over (index, element) pairs of the original collection. |
| 78 | + |
| 79 | +```swift |
| 80 | +extension Collection { |
| 81 | + @available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) |
| 82 | + public func indexed() -> Indexed<Self> { |
| 83 | + Indexed(_base: self) |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) |
| 88 | +public struct Indexed<Base: Collection> { |
| 89 | + // ... |
| 90 | +} |
| 91 | + |
| 92 | +@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) |
| 93 | +extension Indexed: Collection { |
| 94 | + // ... |
| 95 | +} |
| 96 | + |
| 97 | +@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) |
| 98 | +extension Indexed: BidirectionalCollection where Base: BidirectionalCollection { |
| 99 | + // ... |
| 100 | +} |
| 101 | + |
| 102 | +@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) |
| 103 | +extension Indexed: RandomAccessCollection where Base: RandomAccessCollection {} |
| 104 | + |
| 105 | +@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) |
| 106 | +extension Indexed: LazySequenceProtocol where Base: LazySequenceProtocol {} |
| 107 | + |
| 108 | +@available(macOS 9999, iOS 9999, watchOS 9999, tvOS 9999, *) |
| 109 | +extension Indexed: LazyCollectionProtocol where Base: LazyCollectionProtocol {} |
| 110 | +``` |
| 111 | + |
| 112 | +## Source compatibility |
| 113 | +Adding `LazySequenceProtocol` conformance for `EnumeratedSequence` is a breaking change for code that relies on the `enumerated()` method currently not propagating `LazySequenceProtocol` conformance in a lazy chain: |
| 114 | + |
| 115 | +```swift |
| 116 | +extension Sequence { |
| 117 | + func everyOther_v1() -> [Element] { |
| 118 | + let x = self.lazy |
| 119 | + .enumerated() |
| 120 | + .filter { $0.offset.isMultiple(of: 2) } |
| 121 | + .map(\.element) |
| 122 | + |
| 123 | + // error: Cannot convert return expression of type 'LazyMapSequence<...>' to return type '[Self.Element]' |
| 124 | + return x |
| 125 | + } |
| 126 | + |
| 127 | + func everyOther_v2() -> [Element] { |
| 128 | + // will keep working, the eager overload of `map` is picked |
| 129 | + return self.lazy |
| 130 | + .enumerated() |
| 131 | + .filter { $0.offset.isMultiple(of: 2) } |
| 132 | + .map(\.element) |
| 133 | + } |
| 134 | +} |
| 135 | +``` |
| 136 | + |
| 137 | +All protocol conformances of an existing type to an existing protocol are potentially source breaking because users could have added the exact same conformances themselves. However, given that `Zip2Sequence` and `EnumeratedSequence` do not expose their underlying sequences, there is no reasonable way anyone could have conformed either type to `Collection` themselves. The only sensible conformance that could conflict with one of the conformances added in this proposal is the conformance of `EnumeratedSequence` to `LazySequenceProtocol`. |
| 138 | + |
| 139 | +## Effect on ABI stability |
| 140 | +This proposal does not affect ABI stability. |
| 141 | + |
| 142 | +## Alternatives considered |
| 143 | +#### Don’t add `LazyCollectionProtocol` conformance for `EnumeratedSequence` for the sake of source compatibility. |
| 144 | +We consider it a bug that `enumerated()` currently does not propagate laziness in a lazy chain. |
| 145 | + |
| 146 | +#### Only conform `Zip2Sequence` and `EnumeratedSequence` to `BidirectionalCollection` when the base collections conform to `RandomAccessCollection`. |
| 147 | +Traversing an `EnumeratedSequence` backwards requires computing the `count` of the collection upfront in order to determine the correct offsets, which is an O(count) operation when the base collection does not conform to `RandomAccessCollection`. This is a one-time cost incurred when `c.index(before: c.endIndex)` is called, and does not affect the overall time complexity of an entire backwards traversal. Besides, `index(before:)` does not have any performance requirements that need to be adhered to. |
| 148 | + |
| 149 | +Similarly, `Zip2Sequence` requires finding the index of the longer of the two collections that corresponds to the end index of the shorter collection, when doing a backwards traversal. As with `EnumeratedSequence`, this adds a one-time O(n) cost that does not violate any performance requirements. |
| 150 | + |
| 151 | +#### Keep `EnumeratedSequence` the way it is and add an `enumerated()` overload to `Collection` that returns a `Zip2Sequence<Range<Int>, Self>`. |
| 152 | +This is tempting because `enumerated()` is little more than `zip(0..., self)`, but this would cause an unacceptable amount of source breakage due to the lack of `offset` and `element` tuple labels that `EnumeratedSequence` provides. |
0 commit comments