I think there is a clear value in the indices property returning the common Range type; I do not see a need for introducing a new set of fixed-size Range/ClosedRange variants. (And I certainly wouldn't want us to roll a custom range type that's specifically tied to Vector.)
Swift represents integer ranges using Range<Int>; the indices of a Vector form an integer range; therefore the Vector.indices property must be of type Range<Int>.
(As we discussed above, the Vector.Indices typealias is a remnant of a failed Collection conformance; we should remove it from the proposal. The Vector.indices property should stay, though, to be consistent with Span. This property will be useful for iteration, at least until we become able to directly iterate over noncopyable/nonescapable container types like Vector.)
As you describe, Range/ClosedRange rely on the default IndexingIterator, and indeed this means that a trivial loop like for i in v.indices { foo(v[i]) } technically performs two unnecessary rounds of bound checking in each iteration: one in Range.index(after:), followed by another in Vector.subscript. The compiler is often able to eliminate one or both of these, but this is not a strict guarantee that can be safely relied on. (Especially not in unspecialized generic code, where regular inlining heuristics do not apply.)
This issue is not new with Vector -- it lies within the preexisting Range/ClosedRange types. For example, for-in loops over an array's indices are subject to the same unnecessary bounds checking behavior, and so are loops over manually constructed ranges, as in for i in 0 ..< array.count. These are quite common in Swift, and they generally work well enough.
I don't think the range types are broken enough for us to consider replacing them. Rather, we should find a way to improve for i in range loops even though Range.Iterator is forever stuck with IndexingIterator.
There are several ways we can go about this, ranging from narrow tweaks like encouraging existing optimizer phases to trigger by carefully force-inlining specific entry points (such as Range._failEarlyRangeCheck), all the way to major changes to compiler magic, such as tweaking the implementation of for-in loops if the subject happens to be a range type. (Either by manually special casing these types in the compiler, or by adding a new Sequence requirement (or child protocol) that opportunistically opts in to some new for-in implementation.)
All this is useful and important work, but it is almost entirely orthogonal to the proposal at hand. The range types are extremely commonly used throughout pretty much all of Swift, and therefore even slight changes to their behavior will inevitably risk causing widespread breakage. We also know that for-in loops over ranges generally work well enough: the bounds checks do often happen to be optimized away, as expected.
So I expect for i in vector.indices will be a fine stopgap solution for vector iteration, until we are able to provide direct borrowing for-in loops over (potentially) noncopyable container types. If there are important optimization opportunities we aren't taking, I trust we'll be able to patch them by carefully applying some elbow grease.
I expect this issue will be entirely eliminated with the advent of borrowing for-in loops enabled by the upcoming container protocols. These are designed to get rid of iteration bottlenecks of all sorts, including bounds checking overhead. Because these new protocols will be (necessarily) distinct from Sequence/Collection, they are free to establish their own patterns. This gives us a chance to make a different set of exciting mistakes than before, this time optimizing for performance over expressivity.
In-memory containers hold their elements as a series of contiguous memory regions -- this suggests a model for borrowing iteration that is based on exposing borrow access to these storage chunks using the nonescapable Span type. We do not have the means to implement this model in Swift yet, as the protocols will be built on language features that do not exist yet. But here is a glimpse of roughly where I want us to go: (The magic wand
marks some of the missing pieces. This is just a rough draft; none of the API names or precise signatures/shapes that I present here are final.)
public protocol BorrowingIteratorProtocol: ~Escapable {
associatedtype Element: ~Copyable // đȘnoncopyable associated types
// đȘprotocol member requirements with nonescapable return types
mutating func nextChunk(maximumCount: Int) -> Span<Element>
}
protocol Container: ~Copyable, ~Escapable {
associatedtype Element: ~Copyable // đȘnoncopyable associated types
associatedtype BorrowingIterator: BorrowingIteratorProtocol, ~Escapable
where BorrowingIterator.Element == Element // đȘnonescapable associated types
// đȘprotocol member requirements with nonescapable return types
borrowing func startBorrowingIteration() -> BorrowingIterator
...
}
extension Span: BorrowingIterator where Element: ~Copyable {
mutating func nextChunk(maximumCount: Int) -> Span<Element> {
let c = Swift.max(0, Swift.min(maximumCount, count))
defer { self = self.extracting(unchecked: c...) }
return self.extracting(unchecked: ..<c)
}
}
extension Vector: Container where Element: ~Copyable {
var storage: Span<Element> { // đȘ Nonescapable computed properties
get { ... }
}
typealias BorrowingIterator = Span<Element>
func startBorrowingIteration() -> BorrowingIterator {
self.storage
}
}
let items: Vector = [0, 2, 4, 6, 8]
// This loop:
for borrow item in items { // đȘ borrowing for-in loop syntax
foo(item)
}
// Turns into something like this:
var it = items.startBorrowingIteration()
while true {
let chunk = it.nextChunk(maximumCount: Int.max)
guard chunk.count > 0 else { break }
var i = 0
while i < chunk.count {
defer { i &+= 1 }
borrow item = chunk[unchecked: i] // đȘ borrow bindings
foo(item)
}
}
Of course, this raises many more questions than it answers. But it hopefully illustrates how this approach will enable for-in loops to efficiently provide direct borrowing access to the contents of in-memory containers. Unnecessary per-iteration bounds checks are either eliminated entirely, or at least they are limited to be within nextChunk calls that are invoked once per storage chunk, rather than once per element. In the case of contiguous containers like Vector or Array, there is only a single storage chunk, so the outer loop will iterate exactly once.
Some notes on unresolved and/or curious details
-
The for-in expansion will have some interesting mechanical details, as a break statement in the loop body needs to break out of the outer loop in the expansion, while continue statements need to only apply to the inner loop.
-
The semantics of member requirements that return nonescapable types are intentionally left unspecified above, as they're currently still unclear -- nonescapable types (like Span itself) want to conform to Container, too, and that raises some interesting questions about the precise lifetime requirements on the result of startBorrowingIteration. (For composability, we need the spans returned by the iterator to be directly tied to the container itself (not the iterator). But if the container is nonescapable, do we want the spans to inherit the dependencies of the container, or should they be scoped to the container itself? I.e., do we need to allow the spans returned to sometimes survive the (nonescapable) container they (ostensibly) came from?)
-
Things get even more interesting when we consider that protocol Container should also allow nonescapable elements. Inevitably, we will need to be able to, say, collect a bunch of Span instances into some future array-like type, and then iterate over them. To do this, we need to be able to reason about lifetime dependencies of not just spans, iterators, and containers, but also the elements that are contained within. (E.g., how do lifetime dependencies of the elements relate to those of the container? It seems likely that some escapable types will want to conform to Container with a nonescapable element type. How do we describe the lifetime dependencies of the elements of such a type? Can we even model such escapable containers with the same general protocol?)
The advent of the new borrowable container protocols will resolve the question of how to iterate over the contents of a Span or Vector, but it will not directly help Range/ClosedRange, as those types will not be able to conform to the new protocols. Borrowable containers expose direct access to their contents; this means that their contents need to physically exist somewhere "within" them. Ranges only materialize their contents on demand. That is fine for Sequence/Collection, but it will definitely not be okay for Container.
However, the idea of span-based iteration can be retrofitted onto Collection, too. To support on-the-fly generation of items, it will need to use different (and far less useful) lifetime semantics, where the returned spans are tied to a borrow of the iterator, and they cannot survive until the next nextChunk call. This is a potential direction I'm investigating; its possible benefits include the possibility to give Range a new iterator type, escaping the sad shackles of its IndexingIterator. This is what I alluded to above when I talked about classic for-in loops opportunistically switching to a new implementation.
In an ideal world, we would delay doing this Collection work until we have finalized the Container protocols, to avoid unnecessary discrepancies between them. However, adding a new (escapable/copyable!) BulkIterator associated type to Collection would require fewer future language features, so it may be possible to ship it sooner. (The primary risk is that this work wouldn't directly help noncopyable types, but it would distract us from figuring out the unresolved semantic issues about protocols involving nonescapable types. What we urgently need is a workable model for iteration over noncopyable types; retrofitting bulk iteration into Collection is going to be a sideshow at best.)