Introduction
When writing asynchronous code using AsyncSequence
adopters it is often needed to be able to convert back and forth from the async world to the synchronous world. In many other similar APIs there are mechanisms to accommodate for this. Namely of which those conversions work best if they are trailing syntax upon those types to aide in ergonomics of call sites and code-completion.
In the same vein of useful additions; there were a few APIs missing from the initial AsyncSequence
proposal that are present on Sequence
. A number of these were omitted due to missing features of the language that had not yet been proposed.
Motivation
Learning and testing in Swift is incredibly important as both an implementation requirement but also for developers using the language. The newly introduced AsyncSequence
protocol offers great flexibility of expressing values over time but there are a few places where it could use a few connections to the non-async world. One of those connections is the process of collecting all values and awaiting the completion of the asynchronous sequence. The other major point is the creation of stand-in or pre-computed asynchronous sequences.
For nearly all use cases AsyncSequence
should have similar interfaces as Sequence
. This provides a stable interface for developers to work with and have expectations of prototypes written as non-async code to be just as simple as adopting the asynchronous protocol instead (of course with the caveat of where it makes sense to have symmetry). This is a two way street. Where there are mechanisms that make sense we should also consider those if they make sense to Sequence
.
Proposed Solution
To facilitate the collecting of values and moving from the asynchronous world into the synchronous world we should add an extension on AsyncSequence
of collect
to gather up all the values asynchronously and produce an array of those values. This of course means that the function must follow the effects entailed with said asynchronous iteration; that means that when an AsyncSequence
that throws is collected it will throw and when it does not throw of course that collecting process should not throw. This means that the act should be rethrows according to the conformance of the type it is called upon. Furthermore collecting all values must also be in it of itself asynchronous.
extension AsyncSequence {
public func collect() async rethrows -> [Element]
}
Similar to the construction of LazySequence
adopters, Sequence
itself can easily produce values that can be represented asynchronously. This can be achieved by a similar extension on Sequence
as lazy
(in this case async
) and a concrete generic type that adapts a given Sequence
into a suitable asynchronous sequence.
public struct AsyncLazySequence<Base: Sequence>: AsyncSequence {
public typealias Element = Base.Element
public typealias AsyncIterator = Iterator
public struct Iterator: AsyncIteratorProtocol {
public mutating func next() async -> Base.Element?
}
public func makeAsyncIterator() -> Iterator
}
extension Sequence {
public var async: AsyncLazySequence<Self> { get }
}
In the category of existing APIs missing parity are enumerated()
, joined()
, elementsEqual()
, and zip()
. We should add these methods to AsyncSequence
.
extension AsyncSequence {
public func enumerated() -> AsyncEnumeratedSequence<Self>
}
public struct AsyncEnumeratedSequence<Base: AsyncSequence>: AsyncSequence {
public typealias Element = (offset: Int, element: Base.Element)
public typealias AsyncIterator = Iterator
public struct Iterator: AsyncIteratorProtocol {
public mutating func next() async rethrows -> (offset: Int, element: Base.Element)?
}
public func makeAsyncIterator() -> Iterator
}
extension AsyncSequence where Element: AsyncSequence {
public func joined<Separator: AsyncSequence>(separator: Separator) -> AsyncJoinedSequence<Self, Separator>
}
public struct AsyncJoinedSequence<Base: AsyncSequence> where Base.Element: AsyncSequence {
public typealias Element = Base.Element.Element
public typealias AsyncIterator = Iterator
public struct Iterator: AsyncIteratorProtocol {
public mutating func next() async rethrows -> Base.Element.Element?
}
public func makeAsyncIterator() -> Iterator
}
One minor difference between JoinedSequence
and AsyncJoinedSequence
worth noting is that AsyncJoinedSequence
must carry the Separator
type in order to properly asynchronously stitch the elements together.
extension AsyncSequence {
public func elementsEqual<OtherSequence: AsyncSequence>(_ other: OtherSequence, by areEquivalent: (Element, OtherSequence.Element) async throws -> Bool) async rethrows -> Bool
}
public func zip<Sequence1, Sequence2>(_ sequence1: Sequence1, _ sequence2: Sequence2) -> AsyncZip2Sequence<Sequence1, Sequence2>
public struct AsyncZip2Sequence<Sequence1: AsyncSequence, Sequence2: AsyncSequence>: AsyncSequence {
public typealias Element = (Sequence1.Element, Sequence2.Element)
public typealias AsyncIterator = Iterator
public struct Iterator: AsyncIteratorProtocol {
public mutating func next() async rethrows -> (Sequence1.Element, Sequence2.Element)?
}
public func makeAsyncIterator() -> Iterator
}
For additional N-ary variants of zip; it is relatively easy to make a three part by using two zips and a map;
let zipOf3 = zip(zip(a, b), c).map { ($0.0, $0.1, $1) }
The N-ary variants are not often used and are fairly trivial to compose. Given that, we feel that they are not within the scope of extending both AsyncSequence
and Sequence
.
Examples
func useSomeAsyncSequence<Source: AsyncSequence>(_ source: Source) async rethrows {
let items = try await source.collect()
...
}
useSomeAsyncSequence([1, 2, 3].async)
func useEnumerated<Source: AsyncSequence>(_ source: Source) async rethrows {
for try await (index, element) in source.enumerated() {
...
}
}
Detailed Design
This is already being used in the implementation for the tests for the AsyncSequence
"operators". The implementation for Sequence.async
can be found here and the implementation for AsyncSequence.collect()
can be found here
Impact on Existing Code
Other than the tests needing to be reworked to use the new implementations this is purely additive.
Alternatives Considered
The property on Sequence
for constructing an asynchronous sequence could be just an initializer; however that wont work well as composition. AsyncLazySequence([1, 2, 3].map { $0.description })
is a bit hard to reason and it misses the parity with lazy
.
The function collect
could be an initializer for Array
but it suffers the same ergonomic issue with the extension variable async
on Sequence
.
Alternatively collect
could take a collector of sorts that gets values (e.g. some sort of inout RangeReplaceableCollection
a la Kotlin). This approach offers some flexibility of the type the values are collected into however it does not lend itself to ergonomics-of-use since it is incumbent upon the developer to know that a given type can conform to the given interface.