[Second review] SE-0516: `Iterable`

Doesn’t this pose a problem for modeling a network socket, pipe, etc. as an Iterable? There might be nothing yet but something later.

1 Like

You can think of Optional as a collection of zero or one element. The iterator of Sequence returns nil to terminate. Returning an empty Span is similar.

In your case, you would either block until there are elements or wait for the async companion of Iterable which has been previewed above.

These are use cases where you would want async iteration, which Iterable doesn’t support.

1 Like

I don’t think it’s wise to punt to async here. If I am implementing, say, a high-performance event loop, I want to synchronously read whatever data is available from each socket without a context switch.

They’re very different in a crucial way. Sequence explicitly does not conflate “no data” with “end of data.” The consumer of a Sequence can distinguish .some([]) from nil. That allows modeling a pipe as a Sequence of UInt8.

On Unix, a failure to read from a socket marked for non-blocking I/O returns EAGAIN, and not end of file. This would correspond to throwing an error if you modeled this with an Iterable.

But I don't think there is any need to abstract a socket as Iterable. How often are you performing a collection algorithm over something that is either an Array, or a socket, for example?

1 Like

I’m not sure what implication you are trying to make with that example. It sounds like the direct Swift translation of that design would be to throw an error and expect the client to call next() again later, which is explicitly outlawed by the second rule that Holly posted. In any event, blocking I/O is explicitly what I am not interested in.

A more likely scenario is that you are doing iteration over a collection of file handles (or handle-like abstractions), each of which may have a different underlying concrete implementation. Like if you’re implementing file handles themselves in a kernel written in Swift, or are using a non-socket network library (like Network.framework) in a server process that communicates over both pipes and the network, like many database servers.

Edit: maybe this is solvable with a hasNextSpan: Bool property? Implementors would be required to return false if an error has occurred or if next() has ever returned an empty span. The answer shall never change from true to false between calls to next(), though it can change from false to true.

Yeah, to be clear, I'm not suggesting this is a good use case for Iterable, just saying that hypothetically if it were allowed, this is how you would model it.

EAGAIN is only returned by read() and write() when the file descriptor is set to non-blocking mode.

I'm not familiar with Network.framework, but Linux tends to be a more common platform for deploying network servers, and the Linux system call interface allows you to treat pipes and sockets somewhat uniformly when doing non-blocking I/O.

Perhaps your use case calls for a "Stream" abstraction instead. This can be made more efficient than Iterable since you can deal directly with buffers of bytes instead of Spans of an abstract element type. A stream-specific interface can also allow you to differentiate recoverable conditions (EAGAIN, EINTR) from fatal errors.

Also, it's not clear how you'd model scatter-gather I/O (readv() and writev()) with something like Iterable, which would preclude it from being used to model a socket in many applications.

1 Like

If your element type is an array, then Iterable can also provide a Span with an empty array. Otherwise, Sequence cannot have .some([]). That is what I am getting at by describing Optional and Span as both acting like “collections” where the empty collection terminates iteration.

@ksluder, this reminded me of the Generalized asynchronous streaming proposal. It's async-focused and not yet pitched, but you might still find it interesting, along with this forum thread.

1 Like

Streams also need to allow output, which the Iterable proposal does not address.

I think in general, programmers reach for protocols a bit too eagerly sometimes. Unless you need to abstract over your hypothetical pipe/socket types and existing conformers to Iterable, there is no benefit to using Iterable over writing a new protocol that your pipes/sockets both conform to. You shouldn't use protocols to minimize the number of unique "names" in your program, because that just makes the code harder to read for no tangible benefit.

5 Likes

Presumably the pattern is worth generalizing or else nobody would be proposing it’s a protocol for the standard library, right?

To phrase it another way: Swift System’s FileDescriptor currently exposes very basic wrappers around C-style I/O: read(into: UnsafeMutableRawBufferPointer, retryOnInterrupt: Bool), etc. Is it worth designing Iterable such that a future version of Swift System can either make FileDescriptor conform to Iterable<UInt8> or can vend some instance of a type that does?

1 Like

I am very happy to see this change. Both of these bullets are slightly different. The first one is guidance for implementors of iterators whereas the second one is guidance for callers. Could the second one be re-phrased or add an implementor’s guidance as well? Since callers aren't expected to call nextSpan again after it throws, should iterators either return an empty span or throw the error again?

I personally don't think Iterable is the right abstraction here, neither is swift-system the place to define Swift's I/O abstractions. It is a place for providing multi-platform C API wrappers in Swift. I have been thinking a lot about I/O in Swift recently, in particular, non-blocking I/O and how we could model the different platform I/O abstractions such as read/write syscalls + epoll/kqueue, io_uring, IOCP in Swift. This requires a lot more than just an Iterable protocol such as integration with executors and more. I am happy to discuss some of this in a different thread since I don't want to derail the review here.

4 Likes

I've been thinking about this for a bit too and agree that we probably need some further clarification here. Is Iterable, like Sequence, supposed to admit potentially infinite and one-shot sequences? (As I recall, some have had some regrets about that for Sequence.)

For the purposes of termination behavior, this comes into play with respect to iteration past a thrown error: based on the steering group's proposed guidance, it's user error to try to iterate further from a generic context but there are no restrictions on the conforming type's behavior. As always, of course, concrete types may provide stronger guarantees than the protocols to which they conform: a user iterating a concrete Iterable type might then know something about what happens after a specific error and retry.

This would mean, though, that that particular Iterable behaves differently (revealing a different number of elements!) when iterated over from a generic versus a concrete context—presumably, this is undesirable (particularly for sequences that aren't one-shot and/or infinite)? If we agree, this would narrow the unspecified behavior permissible for conforming types to "iterators should return either an empty span or throw [an] error again."

[I suppose we could also say that one-shot Iterables like a random number generator are free to do whatever but repeatedly iterable sequences are not, but (a) does this actually enable any useful generic algorithms; (b) is it semantically sound to have such a carveout?—I'm thinking specifically of someone (I think, Dave Abrahams?) pointing out that our dance in Sequence with the algorithmic complexity of certain APIs being "O(1) except if a collection, in which case O(N) where N is the number of elements, except if also random-access, in which case O(1)" is unsound.]

I'm not sure I follow. I think the behavior would be the same, it's just that the guarantees of what a consumer could do would be different in a concrete context. A concrete IterableThatRecoversAfterThrowing would produce an IteratorThatRecoversAfterThrowing, which would document that for this type, it does indeed remain safe to call nextSpan() after a thrown error.

But AFAIU this would not change the behavior of for x in iterableThatRecoversAfterThrowing { ... } regardless of whether we're in a concrete context or hidden behind T: Iterable, nor would it change the values produced by a manually-produced Iterator, regardless of whether we're working with the concrete value or hidden behind IterableIteratorProtocol.

If I open-code iteration over the elements of a concrete IterableThatRecoversAfterThrowing and, indeed, choose to recover after throwing, I will get a different number of elements than if I write for x in iterableThatRecoversAfterThrowing—no? If so, the concrete type is iterable (with the machinery of Iterable) in two different ways...

These two are in tension. The change under discussion (that is, to direct Iterable consumers not to call nextSpan() after a thrown error) effectively means that there is no such thing as recoverable throwing within the machinery of Iterable. So by "choosing to recover" you are decidedly placing yourself outside the guarantees of Iterable.

Of course, by underspecifying the post-throw semantics, it would be perfectly permissible (in the view of Iterable) for a concrete type to have any behavior it wants when nextSpan() is called after throwing. But at that point you are not operating in the Iterable universe anymore! Under Iterable, "iteration" would definitionally cease when an error is thrown.

In my mind this is "different behavior" only in the trivial sense—yes, if you call different (or additional) methods then you will end up with different (or additional) values.

I would disagree with this characterization. It'd be Iterable stating (or, more accurately, not stating) semantics with the deliberate intention of licensing divergent iteration behavior in concrete types: I would not call that operating outside the Iterable universe.

Look at it another way: I could legitimately create a refining protocol IterableThatOnlyThrowsWhenRecoverable: Iterable, conform validly conformant Iterable types to that refining protocol, and then write generic algorithms that iterate the same instance in two different ways. And it wouldn't be some loophole or semantic fudge; it'd be thanks to this non-specifying of behavior explicitly permitted by Iterable.

This is very odd. We also don't do this elsewhere.

For example, even though there's more than one way to iterate over String, we vend distinct utf8, utf16, unicodeScalar views to enable those different ways of iterating—each with bona fide protocol-conformant iteration; we don't have iteration one way with String-as-a-Collection and a non-protocol-conformant different way as a concrete type (however spelt). Or another example: we make a (differently not-so-delightful) decision to make Double.nan != Double.nan in all scenarios; we don't make it ==-for-the-purposes-of-Equatable but !=-when-concrete—not even with a separate isEqual(to:) API or something.

1 Like

This discussion, actually, has made me want to revisit the text with respect to throwing iteration entirely. And it occurs to me that I have a fundamental question.

Why are we trying to support throwing iteration at all? The text of the protocol does not explore that option: most of that section about throwing iteration defends how the design with an associated type permits the same protocol to ergonomically support non-throwing iteration. Since no clear examples are presented in the text for using throwing iteration at all, can we...just not?

If, from a generic context, the only thing that a user correctly do is either swallow or rethrow the error and stop iteration, and anything interesting would have to be done on concrete types with distinct APIs (although, as I argue, not distinct enough if Iterable is contorted to accommodate them), then should the protocol itself not just have these types return an empty Span and direct them to offer concrete retrying iteration outside the protocol hierarchy?

4 Likes

The moment you call nextSpan() on an iterator which has thrown you are operating outside the contract of Iterable, and the only way you can have any expectation about what will happen is by some knowledge of the underlying implementation (whether due to some other protocol conformance which imposes additional requirements or due to knowledge of the concrete type you’re working with). Under the semantics of Iterable, there are no longer any guarantees about what nextSpan() will do.

Even if we specified "nextSpan() must continue to throw an error after throwing once" one could define a refining protocol IterableThatOnlyThrowsWhenRecoverable with a func recoveryNextSpan() that performs the recovery operation. The guidance to users would just be "once nextSpan() throws, start calling recoveryNextSpan()". All the same types from your example could be made to conform to this protocol. Why is this any less "iterating the same instance in two different ways" than in the case where we happen to share the name nextSpan()? Iterable is unopinionated (indeed, silent) on the semantics of recoveryNextSpan(), just as it is unopinionated on the semantics of nextSpan() after it has thrown.

If it were somehow possible to produce a different prefix of elements while operating within the contract of Iterable I agree that this would be problematic. But it does not seem concerning to me that certain types may be able to continue producing elements after throwing an error—such behavior is simply not something that Iterable is concerned with.

Perhaps not quite in this way, where a (syntactic) requirement becomes invalid to call under the protocol contract after a certain point, but the universe of "unspecified behavior which a refining protocol could lock down" is unbounded! Collection is silent on the issue of time complexity for index calculations, so RandomAccessCollection refines in order to lock these semantics down.

2 Likes