[Revision/Pitch] `Iterable` (formerly `BorrowingSequence`)

Hello, Swift Evolution!

Included below is a significant revision of SE-0516: BorrowingSequence. The revision adds some additional detail about the additions, and includes two significant changes:

  • The main protocol has been renamed to Iterable to reflect a more universal role for iteration. All associated types and methods have been renamed to fit.
  • Borrowing iteration can now throw, with associated Error types added to the protocols.

The PR for the revision can be found here, in case you have minor edits to suggest. Many thanks for any and all feedback on this new direction!


Iterable

Summary of changes

We propose a new protocol for iteration, Iterable, which provides a universal implementation point for synchronous iteration. Types conforming to Iterable can be noncopyable or nonescapable, can have noncopyable or nonescapable elements, and can throw during iteration. The Swift compiler will support use of this protocol via the familiar for-in syntax.

Changes from Original Version

This version of the proposal includes the following changes from the original BorrowingSequence proposal:

  • Renamed to Iterable: The protocol and all associated types and methods have been renamed to reflect the protocol's more universal role.
  • Throwing iteration: Both Iterable and IterableIteratorProtocol now include a Failure: Error associated type, enabling typed throws during iteration.

Motivation

The Sequence protocol first appeared alongside collections like Array in the early days of Swift, and has many appealing characteristics, allowing iteration and generic algorithms on sequences based on the simple for-in primitive.

However, it predates the introduction of ~Copyable and ~Escapable, and is fundamentally based around copyable elements, posing a limitation on working with Span types, inline arrays, and other types that use the newer noncopyable and nonescapable features.

Protocols can have requirements loosened on them, such as with the introduction of ~Copyable to Equatable in SE-0499, or using the proposed SE-0503, which would provide language support for associated types to be marked ~Copyable or ~Escapable.

The design challenge with Sequence, though, is more fundamental. A sequence provides access to its elements through an Iterator, and an iterator's next() operation returns an Element?. For a sequence of noncopyable elements, this operation could only be implemented by consuming the elements of the iterated sequence, with the for loop taking ownership of the elements individually.

While consuming iteration is sometimes what you want, borrowing iteration is equally important, serves as a better default for noncopyable elements, and yet cannot be supported by the existing Sequence.

Proposed solution

This proposal introduces a new Iterable protocol, with iteration based around providing Spans of elements instead of individual elements, eliminating the need to copy. This pattern is also more optimizable in many cases where an iterated type is backed by contiguous memory.

Detailed design

The Iterable protocol is defined as:

public protocol Iterable<Element, Failure>: ~Copyable, ~Escapable {
  /// A type representing the iterable type's elements.
  associatedtype Element: ~Copyable

  /// A type representing an error thrown during iteration.
  associatedtype Failure: Error = Never

  /// A type that provides the iteration interface and
  /// encapsulates its iteration state.
  associatedtype IterableIterator: IterableIteratorProtocol<Element, Failure> & ~Copyable & ~Escapable

  /// Returns a borrowing iterator over the elements of this sequence.
  @_lifetime(borrow self)
  func makeIterableIterator() -> IterableIterator
  
  /// A value less than or equal to the number of elements in the sequence,
  /// calculated nondestructively.
  var underestimatedCount: Int { get }
  
  /// Internal customization point for fast `contains(_:)` checks.
  func _customContainsEquatableElement(_ element: borrowing Element) -> Bool?
}

// Default implementations
extension Iterable where Self: ~Copyable & ~Escapable, Element: ~Copyable {
  public var underestimatedCount: Int { 0 }
  public func _customContainsEquatableElement(...) -> Bool? { nil }
}

This protocol shape is very similar to the current Sequence. It has a primary associated type for the element, and a method that hands you an iterator you can use to iterate the elements of the sequence.

The differences from the Sequence protocol are as follows:

  • Iterable allows conformance by noncopyable and nonescapable types. Note that copyable and escapable types can also conform, but the protocol is designed to not require it.
  • The Element type is also not required to be copyable.
  • There is an IterableIterator associated type, and a makeIterableIterator() method that returns one. These play a similar role to Iterator and makeIterator() on Sequence.
  • The iterator returned by makeIterableIterator is constrained to the lifetime of the sequence. This allows the iterator to be implemented in terms of properties borrowed from the sequence or a Ref of the sequence itself.
  • Iterable defines an associated Failure type that enables throwing during iteration. Throwing iteration allows the broadest set of types to conform to Iterable, including lazy transformations and types with elements that can throw during generation or access.

Note that the names of the associated type and iterator-providing method are specifically chosen to not conflict with existing names on Sequence, allowing a type to have different implementations for both Iterable and Sequence. The underestimatedCount and _customContains... requirements, however, must have the same semantics between the two protocols, and therefore have the same names.

The IterableIteratorProtocol is similar to its analog, but differs a little more:

public protocol IterableIteratorProtocol<Element, Failure>: ~Copyable, ~Escapable {
  /// A type representing the iterated elements.
  associatedtype Element: ~Copyable
  
  /// A type representing an error thrown during iteration.
  associatedtype Failure: Error = Never
  
  /// Returns a span over the next group of contiguous elements, up to the
  /// specified maximum number.
  @_lifetime(&self)
  mutating func nextSpan(maximumCount: Int) throws(Failure) -> Span<Element>
  
  /// Advances this iterator by up to the specified number of elements and
  /// returns the number of elements that were actually skipped.
  mutating func skip(by maximumOffset: Int) throws(Failure) -> Int
}

// Default implementations
extension IterableIteratorProtocol where Element: ~Copyable {
  public mutating func nextSpan() throws(Failure) -> Span<Element> { ... }
  public mutating func skip(by maximumOffset: Int) throws(Failure) -> Int { ... }
}

Instead of returning individual elements from a next() method as IteratorProtocol does, IterableIteratorProtocol offers up spans of elements. The iterator indicates there are no more elements to iterate by returning an empty Span.

As with Sequence, once an iterator returns an empty Span, every subsequent call to nextSpan() must return an empty Span as well. An iterator's behavior after throwing an error is undefined: some iterators may treat that as the end of the sequence, some may continue to throw the same error or a different error, and some may resume iteration on the next call. Generic code should therefore not assume any particular behavior, with a recommendation that the error be propagated to the caller.

Iterator and element lifetimes

For noncopyable and/or nonescapable types, the Iterable protocols provide lifetimes that are tightly scoped to the providing types. In general, the IterableIterator of an iterable type is a nonescapable type with a lifetime borrowed from the original type. For example, the iterator for InlineArray is SpanIterator, a nonescapable type that provides access to the array's storage via a Span.

To support a broad range of iterator types, the Span returned from an iterator's nextSpan(...) method has an exclusive access dependency on the iterator (i.e. @_lifetime(&self)). This means that the elements accessed via that span also have an exclusive access dependency, with lifetimes ending on the next call to nextSpan() or another mutating call to the iterator.

For Iterable types with ~Copyable element types, this means that some operations that are common in sequence iteration are not possible to implement:

  • Returning an element: In the most general case, a method like first(where:) cannot be implemented in an extension on Iterable, because the selected element's lifetime is tied to the iterator created within the method.
  • Escaping an element from a loop: For the same reason, the functionality of first(where:) could not be coded in-place using a for-in loop to find an element and assign it to a variable outside the loop's scope.
  • Comparing elements across Span accesses: A method like allEqual(), that checks whether every element in a sequence is equal to every other element, cannot be implemented in the most general case for Iterable. Because full iteration of an Iterable type requires calling nextSpan() an unknown number of times, the implementation would require preserving an element across those calls, violating exclusive access.

None of these restrictions apply when an Iterable type's element is Copyable, so these are not new restrictions compared to what Sequence provides. Only operations written to explicitly generalize the implicit Copyable constraint would be bound by the lifetimes.

Span size

The number of elements in each span is determined by both the conforming type and the caller. For the conforming type, the usual implementation will be to provide the largest span possible for each call. In the case of InlineArray, or other contiguously-stored types, this is just a single span for the entire collection.

Examples where nextSpan may be required to be called more than once include:

  • Types where elements are held in discontiguous storage, such as a ring buffer. A ring buffer would provide two spans: one from the first element to the end of the buffer, followed by one from the start of the buffer to the last element.
  • Types that produce elements on demand, such as a Range. Because the elements of a range aren't stored directly in memory, the iterator would provide access to a span of a single element at a time. Note that this is how any Sequence can be adapted to conform to Iterable (see later in this proposal).
  • Callers that only process a certain number of elements at a time would pass their limit as maximumCount, with successive calls to nextSpan until the returned span is empty.

Specifying a maximum number of elements is important for use cases where an iterator is passed inout to another function, which consumes only as many elements as it needs and no more. This is required as a result of the bulk iteration model, unlike IteratorProtocol.next(), which returns only one element at a time.

Additionally, the maximum parameter provides a convenience for the caller. Because calling nextSpan is mutating, a caller that can only handle a specific number of elements at a time would otherwise need to write complex code to manage partial usage of a returned span.

The maximum count also gives signal to the iterator that only a specific number of elements are needed, which can be used to produce results more efficiently. For example, a lazily filtered span might want to serve up "runs" of filtered-in elements from the original collection, in which case you really want to know how many the caller actually wants to consume.

Throwing iteration

Both Iterable and IterableIteratorProtocol include a Failure associated type that defaults to Never. When iteration is simply providing access to a type's storage, as with InlineArray and UniqueArray, the Never failure type allows iteration without the try keyword or handling of throwing in the surrounding contexts. However, for types that can fail during iteration, like a lazy filter wrapper, using for-in syntax or calling an iterator's nextSpan() or skip(by:) methods requires the try keyword.

To iterate over a throwing Iterable type, callers use for try element in iterable, similar to iterating over an AsyncSequence. The desugared form of a throwing loop follows the same structure shown in the non-throwing case above, with try added to the nextSpan() call.

Because Failure is a typed-throws associated type, generic algorithms can propagate the failure type exactly. An algorithm declared as throws(Failure) will be non-throwing when the concrete Iterable has a Failure type equal to Never, and will throw the specific error type otherwise. See the example_reduce(into:_:) declaration below for an example.

Use of the new protocols

To illustrate how these new protocols would be used, we can also look at the proposed desugaring of the for-in syntax. The following familiar code:

for element in myIterable {
    f(element)
    g(element)
}

would cause the compiler to generate code similar to:

var iterator = myIterable.makeIterableIterator()
while true {
    let span = iterator.nextSpan(maximumCount: Int.max)
    if span.isEmpty { break }	  

    for i in span.indices {
        f(span[i])
        g(span[i])
    }
}

This automatic code generation is often referred to as "desugaring" the loop.

Note, the compiler will not necessarily generate this exact code, but it is illustrative of how a user might use the methods directly. The inner for i in span.indices, followed by access to the individual elements by subscripting with i wherever you would previously have used the loop variable, will be familiar to anyone who has iterated spans of noncopyable elements as they exist today. It allows noncopyable elements to be passed directly into functions like f and g without the need for a temporary variable.

This desugared while loop is more complex than its Sequence equivalent, but the day-to-day usage remains exactly the same, with the added complexity left to the caller.

Example Iterable algorithms

The following two examples, included in this proposal only for illustration, show how some Iterable operations will be as simple as their Sequence counterparts, while others will require more careful manual iteration.

The differences between an implementation of reduce(into:_:) for Sequence and Iterable are only in the parameters' ownership annotations, because we only need access to one element at a time. The implementation itself is essentially identical:

extension Iterable where Element: ~Copyable {
   func example_reduce<T: ~Copyable>(
      into initial: consuming T,
      _ nextPartialResult: (inout T, borrowing Element) -> Void
   ) throws(Failure) -> T {
      var result = initial
      for try element in self {
         nextPartialResult(&result, element)
      }
      return result
   }
}

In order to properly implement elementsEqual, however, we must use manual iteration to compare spans of equal size between two Iterable types:

extension Iterable where Self: ~Escapable & ~Copyable, Element: ~Copyable & Equatable {
   func example_elementsEqual<S: Iterable<Element, Failure>>(
      _ rhs: borrowing S
   ) throws(Failure) -> Bool
      where S: ~Escapable & ~Copyable
   {
      var iter1 = makeIterableIterator()
      var iter2 = rhs.makeIterableIterator()
      while true {
         var span1 = try iter1.nextSpan(maximumCount: .max)
   
         if span1.isEmpty {
            // LHS is empty - sequences are equal iff RHS is also empty
            let span2 = try iter2.nextSpan(maximumCount: 1)
            return span2.isEmpty
         }
   
         while span1.count > 0 {
            let span2 = try iter2.nextSpan(maximumCount: span1.count)
            if span2.isEmpty { return false }
            for i in 0..<span2.count {
               if span1[i] != span2[i] { return false }
            }
            span1 = span1.extracting(droppingFirst: span2.count)
         }
      }
   }
}

Standard Library adoption

InlineArray and the various Span types will conform to Iterable.

extension Span: Iterable
   where Self: ~Copyable & ~Escapable, Element: ~Copyable
{
   @_lifetime(borrow self)
   func makeIterableIterator() -> SpanIterator<Element>
}

extension MutableSpan: Iterable
   where Self: ~Copyable & ~Escapable, Element: ~Copyable
{
   @_lifetime(borrow self)
   func makeIterableIterator() -> SpanIterator<Element>
}

extension RawSpan: Iterable {
   @_lifetime(borrow self)
   func makeIterableIterator() -> SpanIterator<UInt8>
}

extension MutableRawSpan: Iterable {
   @_lifetime(borrow self)
   func makeIterableIterator() -> SpanIterator<UInt8>
}

extension InlineArray: Iterable
   where Self: ~Copyable & ~Escapable, Element: ~Copyable
{
   @_lifetime(borrow self)
   func makeIterableIterator() -> SpanIterator<Element>
}

Each of these types use a new borrowing iterator type, SpanIterator, that is suitable for types that store their elements in a single contiguous block of memory. The SpanIterator type stores both a Span and the current offsets into the span, to allow flexibility for future kinds of iteration.

/// A borrowing iterator type that provides access to the contents of a single
/// span of elements.
public struct SpanIterator<Element>: IterableIteratorProtocol, ~Copyable, ~Escapable
  where Element: ~Copyable
{
  public typealias Failure = Never

  @_lifetime(copy elements)
  public init(_ elements: Span<Element>)
  
  @_lifetime(&self)
  public mutating func nextSpan(maximumCount: Int) -> Span<Element>
  
  public mutating func skip(by offset: Int) -> Int
}

Adapters for existing Sequence types

As mentioned above, it is possible given an implementation to Sequence to implement the necessary conformance to Iterable. We propose the following addition to the standard library:

// An adapter type that, given an IteratorProtocol instance, serves up spans
// of each element generated by `next` one at a time.
public struct IterableIteratorAdapter<Iterator: IteratorProtocol>: IterableIteratorProtocol {
  public typealias Failure = Never

  var iterator: Iterator
  var currentValue: Iterator.Element? = nil

  public init(iterator: Iterator) {
    self.iterator = iterator
  }

  @_lifetime(&self)
  public mutating func nextSpan(maximumCount: Int) -> Span<Iterator.Element> {
	// It may be surprising to some readers not used to Swift's ownership
	// model that currentValue is a stored property, not just a local variable.
	// This is because currentValue must be storage owned by the adapter
	// instance, in order to return a span of its contents with the specified lifetime.
    currentValue = iterator.next()
	// note Optional._span is a private method in the standard library
	// that creates an empty or 1-element span of the optional
    return currentValue._span
  }
}

extension Sequence {
  public func makeIterableIterator() -> IterableIteratorAdapter<Iterator> {
    IterableIteratorAdapter(iterator: makeIterator())
  }
}

Given this, it will be possible for all types conforming to Sequence to also conform to Iterable. The conformance of existing sequence types, like Array, Dictionary, and UnfoldSequence, will be included in a future proposal. Some of these types (such as Array) will merit a custom iterator exposing an underlying Span. For other types, the conformance will trivially make use of the IterableIteratorAdapter shown above.

Suppressed conformance of the IterableIterator associated type

Proposal SE-0503 allows the suppression of Copyable and Escapable on associated types.

Since Element is a primary associated type, users will be able to extend Iterable with algorithms that require copyability by default. For example, an algorithm to return the minimum element requires the ability to copy that minimum element in order to return it. Extensions on Iterable without specifying otherwise will default to Element being copyable, as specified by SE-0503. Swift users unaware of the concept of noncopyable types can extend Iterable without being aware of that feature of the language.

The IterableIterator associated type is not a primary associated type; rather it's an implementation detail of an Iterable type. Therefore, per SE-0503, it will not default to being either copyable or escapable.

This is a highly desirable property. While Sequence iterators are copyable, the actual behavior of a particular iterator when you iterate, copy it, then iterate the copy, is very implementation dependent. The Sequence protocol gives no specifications for how an iterator should behave when you do this in a generic context. As such, writing generic code that relies on being able to copy an iterator is never appropriate.

Additionally, many types (like Span or InlineArray) will require a nonescaping iterator. The preferred behavior for users, even those not aware that noncopyable or nonescapable types exist, is to allow extensions written against Iterable to "just work" on those types, since needing to copy or escape an iterator is rarely bumped into when implementing most sequence algorithms.

Use of @_lifetime

Both Iterable and IterableIteratorProtocol have methods that return a non-escapable type (the IterableIterator and a Span, respectively), with a lifetime tied to self. This will require conforming types to enable the Lifetimes experimental feature.

Using these types – either directly, or via the for syntax – will not generally require use of lifetime annotations or enabling of the experimental feature (unless the user intends to e.g. return the iterator out of a function).

Algorithms written as extensions on Iterable should similarly not commonly need use of lifetime annotations.

for-in loop desugaring when both protocols are available

In order to preserve the performance and semantics of existing code that uses for-in loops, the generated code will only use borrowing iteration for types that only conform to Iterable, or in contexts where only Iterable conformance can be assured.

Existing code written with types that conform to both Iterable and Sequence will call the makeIterator() method and iterate over each element using that approach.

In effect, this means that InlineArray and the span types will be the only ones to use borrowing iteration. Types like Array, if given conformance to Iterable, would continue to use the element-wise Sequence-based iteration model for existing code that they use today.


For the remainder of the proposal, including future directions and alternatives that we considered along the way, please see the full proposal.

19 Likes

Was renaming _customContainsEquatableElement to be non-underscored considered? Is this a useful property for most types to implement?

Would there be a way to define a nextSpan method with looser lifetime restrictions, and then expand the APIs that would be otherwise incompatible with non-escapable/non-copyable elements to such conforming types?

What prevents SpanIterator from being copyable?

1 Like

It’s quite possible I’ve missed something, but it would be helpful (to me at least) if this could be expanded on a bit.

Looking at the shape of the protocols and associated types in this proposal, it looks to be that conforming a type to iterable will confer conformance to ~copyable and ~escapable for the type and require it of the Elements which may not be possible or desirable.

public protocol Iterable<Element, Failure>: ~Copyable, ~Escapable {
  /// A type representing the iterable type's elements.
  associatedtype Element: ~Copyable, ~Escapable

Feel like I’m missing something give the description in the proposal.

I am still very much in favor of this overall, but I have a few questions after having played with this new iteration type from swift-collections extensively in the past weeks and re-reading the updated proposal.

Now that we renamed BorrowingSequence to Iterable, have you thought about how the other types of iteration alluded to in the future directions section would be named?

Can we clarify the termination semantics and expectations of IterableIteratorProtocol in the type's docs to reflect what the proposal specifies? In particular, can we expand the proposal and docs to also clarify the semantics of what happens if the iterator throws? I expect that after an iterator throws, it must return empty spans. Similarly, once an iterator returned an empty span, it must continue returning empty spans.

The default skip implementation is missing a typed throws annotation in the proposal. Is this intentional or just an oversight?

Should OutputSpan and OutputRawSpan also adopt Iterable similar to the other adoption of the other span types?

I like the introduction of the Failure generic parameter. I have been prototyping new asynchronous streaming APIs that we are using in the new HTTP client and server proposals. Those types, similar to AsyncSequence, also have the concept of a Failure. In addition to a Failure, I am currently working on the notion of a final element. My initial thinking was that final elements could be HTTP-specific; however, it turns out many network and file protocols have the notion of a final element, such as HTTP, Quic, gRPC, TLS, gzip, and more. I don't want to derail this proposal too much, and I understand synchronous iteration is something different than asynchronous streaming. I was mostly curious if you have thought about this. A hypothetical desugared syntax could look like this:

let finalElement = for element in myIterable {
  print(element)
}
1 Like

T: ~Copyable is not a “conformance” requiring that T be non-copyable. It is a relaxation of the default conformance to Copyable, allowing T to be either Copyable or not.

10 Likes

No, that API is an existing Sequence requirement that we want to match, for types that implement both Iterable and Sequence.

The future directions (you'll have to follow the link to the full proposal) describe the Container protocol and a couple other protocols that don't have the same locked-down lifetime restriction. Those protocols and their operations are still under development.

Actually it seems like that would be fine – I can look at allowing that.

There's an underlying missing language feature here, that's a bit tangential to the proposal. _customContainsEquatableElement is weird because it's a hook that conformers to the protocol sometimes (rarely tbh) need to implement, but users of the protocol should be oblivious to. We don't have a nice way of spelling that right now (or you could say "yes we do, that's what the underscore is for" which might be an OK answer, especially if we think we've finally broken the habit of using underscore to represent experimental/std lib internal public symbols, since these days we use experimental feature flags).

1 Like

Just to get it out there: IterableIterator (and makeIterableIterator etc) is obviously not great. But it's sort of unavoidable given the (good) name for the overall protocol, and the need to avoid naming collisions, and won't surface to most users. Any better ideas welcome though.

7 Likes

I like Iterable as a name much better than BorrowingSequence.

Regarding IterableIterator: perhaps something like IterableContext or IterationState or similar?

3 Likes

Hah, this was primarily what I was going to say.

Likewise; the original proposal took a bit of digesting before it occurred to me that it was just another spelling for the new name.

I think that I have a slight preference for the State though Context makes sense as well. The (associated) type is scoped to Iterable, so I would personally prefer the spelling Iterable.State or Iterable.Context.

5 Likes

What's the performance overhead of supporting throws?
Is this getting partially specialized in a generic context that is known to Never throw but other generic's aren't known like the Element type?

1 Like

I can see the justification for supporting throwing iteration. I'm hardpressed to understand the justification specifically for typed throwing iteration. In some senses this is strictly more limiting than untyped throws with respect to allowing "the broadest set of types" to conform--what does it buy us in return?

I think that part comes down to "Embedded Swift doesn't support untyped throws and Iterable has to support Embedded Swift" before we get into anything about which is superior.

8 Likes

I don't love this basis for the design choice. We converged on a rather thoughtful set of guidelines as to when we think typed throws is the right choice versus untyped, and now we're basically saying, well, stdlib additions are either going to use typed throws or they're going to exclude Embedded—so case closed.

The first part is a fair enough statement of fact about where things stand today, but insofar as we've deliberately created Embedded as a subset of the non-Embedded language, it does not seem the right move is to contort the design of the latter with a central motivation of enlarging the subset that is the former.

2 Likes

Is throws(Error) not an alternative spelling for throws?

1 Like

I still don't like the definition of nextSpan, particularly the maximumCount argument.

My understanding is that nextSpan returns Span because we can reduce iteration overhead in an ABI-stable generic function call: If the underlying container returns few spans, then the optimizable local code can get down to a pure pointer-incrementing loop.

I'm guessing that maximumCount is a nod to how hard it is to write Iterable "combinators" without it. Consider map with maximumCount (this isn't close to actually compiling with Swift 6.3 so there may be mistakes particularly in lifetimes and where clauses):

Attempt at implementing map for Iterable
struct MapIterable<Upstream: Iterable, ElementOfResult: ~Copyable>: Iterable { // 6.3 error, possibly because Iterable itself doesn't compile

    typealias Element = ElementOfResult
    typealias Failure = Upstream.Failure

    struct IterableIterator: ~Copyable, ~Escapable, IterableIteratorProtocol { // 6.3 error, possibly because IterableIteratorProtocol itself doesn't compile

        typealias Element = ElementOfResult
        typealias Failure = Upstream.Failure

        var upstream: Upstream.IterableIterator
        var transform: (borrowing Upstream.Element) -> ElementOfResult
        var buffer = ElementOfResult?.none

        @_lifetime(copy upstream) // 6.3 error: Invalid lifetime dependence on an Escapable value with consuming ownership
        init(
            upstream: consuming Upstream.IterableIterator,
            transform: @escaping (borrowing Upstream.Element) -> ElementOfResult
        ) {
            self.upstream = upstream
            self.transform = transform
        }

        @_lifetime(&self)
        mutating func nextSpan(maximumCount: Int) throws(Failure) -> Span<Element> {
            buffer = try transform(upstream.nextSpan(maximumCount: 1)[0])
            return buffer.span
        }
    }

    var upstream: Upstream
    var transform: (borrowing Upstream.Element) -> ElementOfResult

    init(upstream: Upstream, transform: @escaping (borrowing Upstream.Element) -> ElementOfResult) {
        self.upstream = upstream
        self.transform = transform
    }

    @_lifetime(borrow self)
    func makeIterableIterator() -> IterableIterator {
        IterableIterator(upstream: upstream.makeIterableIterator(), transform: transform)
    }

}

extension MapIterable: Copyable where ElementOfResult: ~Copyable, Upstream: Copyable {}
extension MapIterable: Escapable where ElementOfResult: ~Copyable, Upstream: Escapable {}

extension Iterable where Self: ~Copyable, Self: ~Escapable {
    @_lifetime(borrow self) // 6.3 error: Invalid lifetime dependence on an Escapable result
    func map<ElementOfResult: ~Copyable>(
        _ transform: @escaping (borrowing Element) -> ElementOfResult
    ) -> MapIterable<Self, ElementOfResult> {
        MapIterable(upstream: self, transform: transform)
    }
}
  1. good grief, that's a lot of code for the most conceptually simple combinator!
  2. thank goodness we had maximumCount, it made the code much simpler!

If we don't have maximumCount, the IterableIterator implementation has to maintain some kind of local iteration state over the potentially-large upstream span, which we simply don't have any API to express, safe or otherwise.

However, like the case of the "unfused nil" case from Sequence, we now have more cases where the compiler can't check the implementation of IterableIteratorProtocol: In addition to the "what if the upstream iterator returns an empty span on one call and then a nonempty span on the next" (by analogy to "what if the upstream iterator returns nil on one call and non-nil on the next"), we add "what if the maximumCount is ignored by the upstream iterator and it returns a too-large span"?

And this maximumCount is only useful when iterating over a container directly; any combinator is forced to use maximumCount: 1. Either because that's necessary to avoid pessimizing the algorithm, or simply because the code is impossible to express in the language.

So I'm still thinking, could these containers not just make their IterableIterator types @alwaysEmitIntoClient anyway, and we always return a 1-element span (or more usefully, a Ref<Element>)? Surely that would provide the same optimization opportunity?


The attempt to write map above also shows a couple of other areas where the language isn't ready for this definition of Iterable:

  • There's no good way to write a throwing map given the throwing upstream sequence; we have to write up to four overloads (some of which the compiler might not actually be able to distinguish):
    • func map<E: ~Copyable>(_ transform: (borrowing Element) -> E) -> some Iterable<E, Failure>
    • func map<E: ~Copyable, F: Error>(_ transform: (borrowing Element) throws(F) -> E) -> some Iterable<E, F> where Failure == Never
    • func map<E: ~Copyable>(_ transform: (borrowing Element) throws(Failure) -> E) -> some Iterable<E, Failure>
    • func map<E: ~Copyable, F: Error>(_ transform: (borrowing Element) throws(F) -> E) -> some Iterable<E, Error>
    • maybe it's time to consider a magic type TypeUnion<E...> which turns into the most-specific-supertype of its type parameters, again?
  • There's no way to say that the returned Iterable is as Escapable as the union of the upstream Iterable and the passed transform, since we don't have syntax that ties @escaping on closures to Escapable conformances and lifetimes. We also don't have the ability to intersect lifetimes AFAIK.
  • Even this implementation relies on Optional.span (returning a zero- or one-element span as appropriate) which doesn't exist yet.

The paragraph about how the compiler chooses between Iterable iteration (supposedly efficient) and Sequence iteration (supposedly inefficient) shows another shortcoming: there's no standard way for someone who cares about iteration performance to opt into Iterable. I believe that as-written they could do for element in array.span but I'm unconvinced that that has all the flexibility we want, nor that it's a pattern we necessarily want to see proliferate as a lint rule.


As written, Iterable doesn't support other modes of iteration, such as consumption (Rust's IntoIterator/for item in vec/drain).


Ultimately I'm still not really sure why we're not just ~copying Rust:

protocol Iterator: ~Copyable, ~Escapable {
    associatedtype Element: ~Copyable, ~Escapable
    associatedtype Failure: Error
    mutating func next() throws(Failure) -> Element?
}

It supports consuming iteration, it supports borrowing iteration, it supports copying iteration, it's easy to implement.

It does require you to use an extra property in for loops to pick your iteration mode; bikeshedding:

// only for types with copyable elements, element: Element
for element in sequence { ... }
// borrowing iteration available on most containers, element: Ref<Element>
// (or a magic borrow binding desugared from the Ref)
// (even if noncopyable or nonescapable),
for element in sequence.borrowed { ... }
// consuming iteration available as appropriate, element: Element
// (even if noncopyable)
for element in sequence.consume() { ... }

But generally I think that's a good thing.

I think performance concerns go away when SpanIterator is in the stdlib and all its methods are inlinable; I write

for element in array.borrowed {
    ...
}

The compiler desugars that into

var iterator: SpanIterator<Element> = array.borrowed
while let ref: Ref<Element> = iterator.next() {
    // non-syntax, to emphasize the user doesn't deal in Ref
    borrowed let element = *ref 
    ...
}

Which turns easily into something like

var p = array.unsafeBaseAddress // p is one field of SpanIterator
let q = array.unsafeEndAddress  // q is the other field of SpanIterator
while p < q { // inlined check from SpanIterator.next()
    borrowed let element = *p // inlined from SpanIterator and/or Ref
    ...
    p += 1 // inlined from SpanIterator.next()
}

It seems to me that once the Span from the collection is in the user's code's hands, the optimization of the iteration shouldn't be a problem.

2 Likes

It's actually a limitation that Rust's iterator doesn't support borrowing iteration. True borrowing iteration ought to be able to borrow from the iterator instance, allowing temporary materialization as you go, but Rust's iterator requires that the element's lifetime be tied to the underlying collection instead. (They've been calling this missing feature a "lending iterator".)

4 Likes

The Rust problem more explained: there are plausibly two lifetimes in the iteration, the lifetime of the type that implements Iterator (IterableIterator, in this proposal), and the lifetime of the value returned from next(). Rust implicitly ties those two together, because the only lifetime that can be applied to the associated type Item (not exactly Element in this proposal, since Rust's Iterator::next returns Item, and the proposed IterableIterator.nextSpan returns Span<Element>) is a lifetime attached to the Iterator type itself. That means that the element returned from iteration must live at least as long as the iterator (it could live longer, since unlike this proposal it's not forced to be behind a reference). That's fine for e.g. borrowing iteration over a collection! But there are (relatively rare) cases that it doesn't serve; a "lending iterator" design would allow the Item to have a lifetime between calls to next. This is useful if the iterator constructs the value on each "turn" of the loop, but wants to reuse parts of the previous value to form the next. This is tricky in Rust because you need to apply the lifetime of the call to next to the associated type Item, which can be done only with the newish feature "generic associated types".

But since in Swift lifetimes are a property of a method, not a property of a type, I don't think we have the same problem. I omitted the lifetime annotation in the version of the protocol I proposed above, but if it was

protocol Iterator: ~Copyable, ~Escapable {
    associatedtype Element: ~Copyable, ~Escapable
    associatedtype Failure: Error
    @_lifetime(borrow self)
    mutating func next() throws(Failure) -> Element?
}

Does that not allow lending iteration, as well as all the other cases? The compiler will ensure that the Iterator doesn't outlive its container if the iterator is ~Escapable and it'll ensure the Element doesn't outlive the [next call to next on the] Iterator if the Element is ~Escapable. In cases where those types are Escapable, it can still work as a copying or consuming iterator.

It doesn't cover "you can hang onto a Ref<Element> to the collection even after you've discarded the iterator", which Rust would allow. But I think that's pretty obscure, and I think it does cover the "lending iterator" case.

2 Likes

Embedded Swift does support untyped throws starting with Swift 6.4. Requires an allocator to be present though, but so do classes or concurrency for example.

throws(any Error) is an alternative spelling for throws. IMO, making it unambiguously obvious it's an existential is important in this context.

4 Likes

Isn’t Error a synonym for any Error in today’s Swift? Are there plans to remove Error’s special nature as its own self-confirming existential?

I think it’s nice that substituting Failure = Error into func f<Failure>() throws(Failure) works in both Embedded and non-embedded Swift, without any special syntax needed to unlock the full capabilities in non-embedded Swift or enabling existential support in Embedded Swift.