Skip to content

Commit e5eed16

Browse files
Azoyairspeedswift
andauthored
EnumeratedSequence collection conformance (#2634)
* EnumeratedSequence collection conformance * Update the ABI stability section of enumerated * Update and rename nnnn-enumerated-collection.md to 0459-enumerated-collection.md Kick off review --------- Co-authored-by: Ben Cohen <airspeedswift@users.noreply.github.com>
1 parent 9d3bd3d commit e5eed16

File tree

1 file changed

+199
-0
lines changed

1 file changed

+199
-0
lines changed
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# Add `Collection` conformances for `enumerated()`
2+
3+
* Previous proposal: [SE-0312](0312-indexed-and-enumerated-zip-collections.md)
4+
* Author: [Alejandro Alonso](https://github.com/Azoy)
5+
* Review Manager: [Ben Cohen](https://github.com/airspeedswift)
6+
* Status: **Active Review (28 Jan - February 7 2025)**
7+
* Implementation: [apple/swift#78092](https://github.com/swiftlang/swift/pull/78092)
8+
9+
## Introduction
10+
11+
This proposal aims to fix the lack of `Collection` conformance of the sequence returned by `enumerated()`, preventing it from being used in a context that requires a `Collection`.
12+
13+
Swift-evolution thread: [Pitch](https://forums.swift.org/t/pitch-add-indexed-and-collection-conformances-for-enumerated-and-zip/47288)
14+
15+
## Motivation
16+
17+
Currently, `EnumeratedSequence` type conforms to `Sequence`, but not to any of the collection protocols. Adding these conformances was impossible before [SE-0234 Remove `Sequence.SubSequence`](https://github.com/swiftlang/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!
18+
19+
Conformance to the collection protocols can be beneficial in a variety of ways, for example:
20+
* `(1000..<2000).enumerated().dropFirst(500)` becomes a constant time operation.
21+
* `"abc".enumerated().reversed()` will return a `ReversedCollection` rather than allocating a new array.
22+
* SwiftUI’s `List` and `ForEach` views will be able to directly take an enumerated collection as their data.
23+
24+
## Detailed design
25+
26+
Conditionally conform `EnumeratedSequence` to `Collection`, `BidirectionalCollection`, `RandomAccessCollection`.
27+
28+
```swift
29+
@available(SwiftStdlib 6.1, *)
30+
extension EnumeratedSequence: Collection where Base: Collection {
31+
// ...
32+
}
33+
34+
@available(SwiftStdlib 6.1, *)
35+
extension EnumeratedSequence: BidirectionalCollection
36+
where Base: BidirectionalCollection
37+
{
38+
// ...
39+
}
40+
41+
@available(SwiftStdlib 6.1, *)
42+
extension EnumeratedSequence: RandomAccessCollection
43+
where Base: RandomAccessCollection {}
44+
```
45+
46+
## Source compatibility
47+
48+
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 `EnumeratedSequence` do not expose their underlying sequences, there is no reasonable way anyone could have conformed to `Collection` themselves.
49+
50+
## Effect on ABI stability
51+
52+
These conformances are additive to the ABI, but will affect runtime casting mechanisms like `is` and `as`. On ABI stable platforms, the result of these operations will depend on the OS version of said ABI stable platforms. Similarly, APIs like `underestimatedCount` may return a different result depending on if the OS has these conformances or not.
53+
54+
## Alternatives considered
55+
56+
#### Add `LazyCollectionProtocol` conformance for `EnumeratedSequence`.
57+
58+
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:
59+
60+
```swift
61+
extension Sequence {
62+
func everyOther_v1() -> [Element] {
63+
let x = self.lazy
64+
.enumerated()
65+
.filter { $0.offset.isMultiple(of: 2) }
66+
.map(\.element)
67+
68+
// error: Cannot convert return expression of type 'LazyMapSequence<...>' to return type '[Self.Element]'
69+
return x
70+
}
71+
72+
func everyOther_v2() -> [Element] {
73+
// will keep working, the eager overload of `map` is picked
74+
return self.lazy
75+
.enumerated()
76+
.filter { $0.offset.isMultiple(of: 2) }
77+
.map(\.element)
78+
}
79+
}
80+
```
81+
82+
We chose to keep this proposal very small to prevent any such potential headaches of source breaks.
83+
84+
#### Keep `EnumeratedSequence` the way it is and add an `enumerated()` overload to `Collection` that returns a `Zip2Sequence<Range<Int>, Self>`.
85+
86+
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.
87+
88+
#### Only conform `EnumeratedSequence` to `BidirectionalCollection` when the base collection conforms to `RandomAccessCollection` rather than `BidirectionalCollection`.
89+
90+
Here’s what the `Collection` conformance could look like:
91+
92+
```swift
93+
extension EnumeratedSequence: Collection where Base: Collection {
94+
struct Index {
95+
let base: Base.Index
96+
let offset: Int
97+
}
98+
var startIndex: Index {
99+
Index(base: _base.startIndex, offset: 0)
100+
}
101+
var endIndex: Index {
102+
Index(base: _base.endIndex, offset: 0)
103+
}
104+
func index(after index: Index) -> Index {
105+
Index(base: _base.index(after: index.base), offset: index.offset + 1)
106+
}
107+
subscript(index: Index) -> (offset: Int, element: Base.Element) {
108+
(index.offset, _base[index.base])
109+
}
110+
}
111+
112+
extension EnumeratedSequence.Index: Comparable {
113+
static func == (lhs: Self, rhs: Self) -> Bool {
114+
return lhs.base == rhs.base
115+
}
116+
static func < (lhs: Self, rhs: Self) -> Bool {
117+
return lhs.base < rhs.base
118+
}
119+
}
120+
```
121+
122+
Here’s what the `Bidirectional` conformance could look like. The question is: should `Base` be required to conform to `BidirectionalCollection` or `RandomAccessCollection`?
123+
124+
```swift
125+
extension EnumeratedSequence: BidirectionalCollection where Base: ??? {
126+
func index(before index: Index) -> Index {
127+
let currentOffset = index.base == _base.endIndex ? _base.count : index.offset
128+
return Index(base: _base.index(before: index.base), offset: currentOffset - 1)
129+
}
130+
}
131+
```
132+
133+
Notice that calling `index(before:)` with the end index requires computing the `count` of the base collection. This is an O(1) operation if the base collection is `RandomAccessCollection`, but O(n) if it's `BidirectionalCollection`.
134+
135+
##### Option 1: `where Base: BidirectionalCollection`
136+
137+
A direct consequence of `index(before:)` being O(n) when passed the end index is that some operations like `last` are also O(n):
138+
139+
```swift
140+
extension BidirectionalCollection {
141+
var last: Element? {
142+
isEmpty ? nil : self[index(before: endIndex)]
143+
}
144+
}
145+
146+
// A bidirectional collection that is not random-access.
147+
let evenNumbers = (0 ... 1_000_000).lazy.filter { $0.isMultiple(of: 2) }
148+
let enumerated = evenNumbers.enumerated()
149+
150+
// This is still O(1), ...
151+
let endIndex = enumerated.endIndex
152+
153+
// ...but this is O(n).
154+
let lastElement = enumerated.last!
155+
print(lastElement) // (offset: 500000, element: 1000000)
156+
```
157+
158+
However, since this performance pitfall only applies to the end index, iterating over a reversed enumerated collection stays O(n):
159+
160+
```swift
161+
// A bidirectional collection that is not random-access.
162+
let evenNumbers = (0 ... 1_000_000).lazy.filter { $0.isMultiple(of: 2) }
163+
164+
// Reaching the last element is O(n), and reaching every other element is another combined O(n).
165+
for (offset, element) in evenNumbers.enumerated().reversed() {
166+
// ...
167+
}
168+
```
169+
170+
In other words, this could make some operations unexpectedly O(n), but it’s not likely to make operations unexpectedly O(n²).
171+
172+
##### Option 2: `where Base: RandomAccessCollection`
173+
174+
If `EnumeratedSequence`’s conditional conformance to `BidirectionalCollection` is restricted to when `Base: RandomAccessCollection`, then operations like `last` and `last(where:)` will only be available when they’re guaranteed to be O(1):
175+
176+
```swift
177+
// A bidirectional collection that is not random-access.
178+
let str = "Hello"
179+
180+
let lastElement = str.enumerated().last! // error: value of type 'EnumeratedSequence<String>' has no member 'last'
181+
```
182+
183+
That said, some algorithms that can benefit from bidirectionality such as `reversed()` and `suffix(_:)` are also available on regular collections, but with a less efficient implementation. That means that the code would still compile if the enumerated sequence is not bidirectional, it would just perform worse — the most general version of `reversed()` on `Sequence` allocates an array and adds every element to that array before reversing it:
184+
185+
```swift
186+
// A bidirectional collection that is not random-access.
187+
let str = "Hello"
188+
189+
// This no longer conforms to `BidirectionalCollection`.
190+
let enumerated = str.enumerated()
191+
192+
// As a result, this now returns a `[(offset: Int, element: Character)]` instead
193+
// of a more efficient `ReversedCollection<EnumeratedSequence<String>>`.
194+
let reversedElements = enumerated.reversed()
195+
```
196+
197+
The base collection needs to be traversed twice either way, but the defensive approach of giving the `BidirectionalCollection` conformance a stricter bound ultimately results in an extra allocation.
198+
199+
Taking all of this into account, we've gone with option 1 for the sake of giving collections access to more algorithms and more efficient overloads of some algorithms. Conforming this collection to `BidirectionalCollection` when the base collection conforms to the same protocol is less surprising. We don’t think the possible performance pitfalls pose a large enough risk in practice to negate these benefits.

0 commit comments

Comments
 (0)