Sequence builders in Kotlin Coroutines
- async functions (also called async/await);
- generator functions (functions in which subsequent values are yielded).
We've already seen how
async can be used in Kotlin, but this will be explained in detail in the Coroutine builders chapter. Instead of generators, Kotlin provides a sequence builder - a function used to create a sequence1.
A Kotlin sequence is a similar concept to a collection (like
Set), but it is evaluated lazily, meaning the next element is always calculated on demand, when it is needed. As a result, sequences:
- do the minimal number of required operations;
- can be infinite;
- are more memory-efficient2.
Due to these characteristics, it makes a lot of sense to define a builder where subsequent elements are calculated and "yielded" on demand. We define it using the
sequence function. Inside its lambda expression, we can call the
yield function to produce the next elements of this sequence.
sequencefunction here is a small DSL. Its argument is a lambda expression with a receiver (
suspend SequenceScope<T>.() -> Unit). Inside it, the receiver
thisrefers to an object of type
SequenceScope<T>. It has functions like
yield. When we call
yield(1), it is equivalent to calling
thiscan be used implicitly. If this is your first contact with lambda expressions with receivers, I recommend starting from learning about them and about DSL creation, as they are used intensively in Kotlin Coroutines.
What is essential here is that each number is generated on demand, not in advance. You can observe this process clearly if we print something in both the builder and in the place where we handle our sequence.
Let's analyze how it works. We ask for the first number, so we enter the builder. We print "Generating first", and we yield number 1. Then we get back to the loop with the yielded value, and so "Next number is 1" is printed. Then something crucial happens: execution jumps to the place where we previously stopped to find another number. This would be impossible without a suspension mechanism, as it wouldn't be possible to stop a function in the middle and resume it from the same point in the future. Thanks to suspension, we can do this as execution can jump between
main and the sequence generator.
To see it more clearly, let's manually ask for a few values from the sequence.
Here, we used an iterator to get the next values. At any point, we can call it again to jump into the middle of the builder function and generate the next value. Would this be possible without coroutines? Maybe, if we dedicated a thread for that. Such a thread would need to be maintained, and that would be a huge cost. With coroutines, it is fast and simple. Moreover, we can keep this iterator for as long as we wish as it costs nearly nothing. Soon we will learn how this mechanism works under the hood (in the Coroutines under the hood chapter).
There are a few use cases where sequence builders are used. The most typical one is generating a mathematical sequence, like a Fibonacci sequence.
This builder can also be used to generate random numbers or texts.
The sequence builder should not use suspending operations other than yielding operations3. If you need, for instance, to fetch data, it’s better to use Flow, as will be explained later in the book. The way its builder works is similar to the sequence builder, but Flow has support for other coroutine features.
We've learned about the sequence builder and why it needs suspension to work correctly. Now that we've seen suspension in action, it is time to dive even deeper to understand how suspension works when we use it directly.
Even better, it offers flow builders. Flow is a similar but much more powerful concept which we will explain later in the book.
See item Prefer Sequence for big collections with more than one processing step in the Effective Kotlin.
And it cannot be, since
SequenceScope is annotated with
RestrictsSuspension, which prevents the suspend function being called unless its receiver is