Welcome to the next module which will go deep into the world of asynchronous sequences. We’ll cover AsyncSequence, but also AsyncStream and its throwing companion. It’s not the first time we’re touching async sequences in this course, as we already briefly used them in the task groups lesson. Let’s dive in!
A first look at working with an async sequence
As mentioned, we’ve already briefly worked with an asynchronous sequence. Here’s a piece of sample code I copied over from the task group lesson:
let images: [UIImage] = await withTaskGroup(of: UIImage.self, returning: [UIImage].self) { taskGroup in
let photoURLs = await listPhotoURLs(inGallery: "Amsterdam Holiday")
for photoURL in photoURLs {
taskGroup.addTask { await downloadPhoto(url: photoURL) }
}
var images = [UIImage]()
for await result in taskGroup {
images.append(result)
}
return images
}
As you can see, we have a for loop that contains the await keyword. Zoomed in, it look as follows:
for await result in taskGroup {
images.append(result)
}
We’re basically saying: “Go into the for loop closure as soon as the result of the task group returns”. In other words, we’re asynchronously iterating over newly added items of a collection. As soon as a new result gets added, we’ll receive it in the for loop.
In this case, the taskGroup sequence knows when its finished based on the number of tasks that are added. To better understand this concept as a whole, we can dive into creating a custom sequence ourselves.
What is an AsyncSequence?
An AsyncSequence is an asynchronous variant of the Sequence protocol we’re familiar with in Swift. Due to its asynchronous fashion, we need to use the await keyword since we’re dealing with async-defined methods.
Values can become available over time, meaning that an AsyncSequence can contain none, some, or all of its values by the first time you use it.
It’s important to understand that AsyncSequence is just a protocol. It defines how to access values but doesn’t generate or contain values. Implementors of the AsyncSequence protocol provide an AsyncIterator and take care of developing and potentially storing values.
Creating a custom AsyncSequence
To better understand how an AsyncSequence works, I will demonstrate an example implementation.
Note
You probably want to use an AsyncStream instead when defining your custom implementation of an AsyncSequence since it’s more convenient to set up. Therefore, this is just a code example to understand better how an AsyncSequence works. It helps you to understand the concept before diving into AsyncStream later in this module.
The following example implements a counter. The values are available right away, so there’s not much need for an asynchronous sequence. However, it does demonstrate the underlying structure of an AsyncSequence.
struct Counter: AsyncSequence {
let limit: Int
struct AsyncIterator : AsyncIteratorProtocol {
let limit: Int
var current = 1
mutating func next() async -> Int? {
guard !Task.isCancelled else {
return nil
}
guard current <= limit else {
return nil
}
let result = current
current += 1
return result
}
}
func makeAsyncIterator() -> AsyncIterator {
return AsyncIterator(limit: limit)
}
}
As you can see, we defined a Counter struct that implements the AsyncSequence protocol. The protocol requires us to return a custom AsyncIterator which we solved using an internal type. Depending on your preference, you could decide to implement the AsyncIteratorProtocol without a subtype:
struct Counter: AsyncSequence, AsyncIteratorProtocol {
let limit: Int
var current = 1
mutating func next() async -> Int? {
guard !Task.isCancelled else {
return nil
}
guard current <= limit else {
return nil
}
let result = current
current += 1
return result
}
func makeAsyncIterator() -> Counter {
self
}
}
We can now return self as the iterator and keep all logic centralized.
The next() method takes care of iterating overall values. Our example comes down to providing as many counted values until we reach the limit. We implement cancellation support by checking for Task.isCancelled.
Iterating over an Asynchronous Sequence
Now that we know what an AsyncSequence is and how it’s implemented under the hood, it’s time to start iterating over the values.
Taking the above example, we could start iterating using the Counter:
for await count in Counter(limit: 5) {
print(count)
}
print("Counter finished")
// Prints:
// 1
// 2
// 3
// 4
// 5
// Counter finished
We have to use the await keyword since we might receive values asynchronously. Emphasizing ‘might’ here since in this example, we’re actually not receiving any value asynchronously. It demonstrates that an AsyncSequence simply prepares us for asynchronous values, but it’s not actually always sending values asynchronously.
We exit the for loop once there are no values to be expected anymore. Implementors of an asynchronous sequence can indicate reaching the limit by returning nil in the next() method. In our case, we’ll reach that point once the counter reaches the configured limit or when the iteration cancels:
mutating func next() async -> Int? {
guard !Task.isCancelled else {
/// Return `nil` so the iteration stops as we're cancelled.
return nil
}
guard current <= limit else {
/// Return `nil` and stop the iteration as we reached the limit.
return nil
}
let result = current
current += 1
return result
}
Many of the regular Sequence operators are also available for asynchronous sequences. The result is that we can perform operations like mapping and filtering in an asynchronous manner.
For example, we could filter for even numbers only:
for await count in Counter(limit: 5).filter({ $0 % 2 == 0 }) {
print(count)
}
print("Counter finished")
// Prints:
// 2
// 4
// Counter finished
Or we could map the count to a String before iterating:
let counterSequence = Counter(limit: 5)
.map { $0 % 2 == 0 ? "Even" : "Odd" }
for await count in counterSequence {
print(count)
}
print("Counter finished")
// Prints:
// Odd
// Even
// Odd
// Even
// Odd
// Counter finished
We could even use the AsyncSequence without a for loop by using methods like contains:
let contains = await Counter(limit: 5).contains(3)
print(contains) // Prints: true
Note that the above method is asynchronous, meaning that it could potentially wait endlessly for a value to exist until the underlying AsyncSequence finishes.
Summary
That wraps up our introduction into asynchronous sequences. You now know how they work under the hood, which will help you better understand how values get returned over time. You won’t be often building custom AsyncSequence implementations yourself, but you will interact with them using types like task groups. Therefore, it’s essential to know how they work under the hood.
I briefly mentioned it in this lesson: you’re more likely to work with an AsyncStream. That’s our topic for the next lesson.