Bridge from Combine to AsyncSequence - the plan (p. 1)
Previously, I wrote about actors in Actors part 1 and Actors part 2; this time I’m going to cover how to write a Combine operator to proxy a Combine publisher into an async sequence that you can iterate over by using a simple for
loop.
Note: The current version of Xcode 13 is beta 2 and I’m going to be using the latest swift.org toolchain nightly snapshot in order to make use of
AsyncStream
. If you’re using beta 3 or laterAsyncStream
is likely supported out of the box.
AsyncSequence and AsyncStream
AsyncSequence
is a new API introduced in iOS15/macOS12 that allows you to use the new async/await Swift syntax to create asynchronous sequence iterators.
The existing Swift standard library Sequence
protocol provides a mechanism for working with finite or infinite sequences of elements.
AsyncSequence
, simply put, is exactly the same sequence protocol with the difference that getting the next element in the sequence is asynchronous - i.e. you optionally might non-blockingly wait for the next element to become available.
Let’s look at a practical example: Foundation offers a new API on the URL
type - URL.lines
. lines
is an asynchronous sequence that makes text lines of a file at the given URL available as elements in a sequence as soon as they are available. In other words, if you’re reading a remote file, you can iterate over URL.lines
in a simple for
loop and it will non-blockingly run the body of the loop for each text line of the file when it’s received from the server.
The mechanics of making an async sequence are the same as for sync sequences. The async sequence protocol requires a single method that makes an iterator:
|
|
The iterator protocol is also pretty flexible with only a single requirement that returns asynchronously the next element or a nil
to terminate the sequence:
|
|
The neat thing then is that you can use your custom async sequence in a normal for
loop without the need to pass around closures or fiddle with cancellables or other rituals:
|
|
Making your own async sequences is quite easy but for most simple cases using the default AsyncStream
implementation is sufficient. AsyncStream.init(...)
takes a single closure that can yield elements, complete, or error. The closure is transparently used as the sequence iterator without the need for you to implement an iterator yourself.
Combine and AsyncStream
In this blog series we’re going to put AsyncStream
and Combine side by side and see where are the similarities and make a simple plan how to make an operator to proxy Combine’s publishers to async sequences.
We’re, more specifically, going to compare Combine’s Subscriber
and AsyncStream
types. Let’s have a look at:
- Elements type - Combine subscribers are typed and receive a single type of element (
Input
); async streams also yield only a single value type (Element
). - Emitting an element - subscribers have a method called
receive(_:)
which emits a single element, async stream uses a continuation that can emit a value calledyield(_:)
. - Erroring - subscribers have a method
receive(completion:)
to which you can pass a failure with an error; a stream’s continuation has afinish(throwing:)
method that takes an error too. - Completing successfully - as previously, subscribers have
receive(completion:)
with a successful enum case, stream’s continuation acceptsnil
as the error infinish(throwing:)
for successful completion.
The only remaining open question is publisher cancellation. Some publishers implement specific behavior when they are cancelled (usually resource cleanup). Cancelling is simply the consumer stopping being interested in the publisher.
This is a bit of a head scratcher.
Generally when you’re iterating over a sequence in a for
loop there is no easy way to cancel the iterator. Your sequence might have a built-in mechanism to complete or error but not necessarily for being cancelled. But it might have a cancel mechanism since it’s a good idea for async code in general to have a way for being cancelled.
So for the purpose of building a proxy from Combine to async sequence (this blog series’ ultimate goal) I think the async sequence should complete successfully if the publisher is cancelled. Erroring out seems inappropriate, and doing nothing will leave a for
loop hanging forever.
It looks like, then, we can easily map all events of a Combine publisher to calling the respective methods on an async stream’s continuation!
We’ll do that in the next post: Bridge from Combine to AsyncSequence - the code (part 2)
Where to go from here?
All of the concurrency features in Swift are still work in progress. I’m using an Xcode beta version plus a swift.org toolchain right now to get all of the above working but it’s still interesting to play with all those new APIs and get a feeling of the future of Swift code.
Hit me up with your ideas, replies, and feedback on Twitter at https://twitter.com/icanzilb.