Read on for an introduction to the sequence
method, an explanation of yield
vs. return
, a look into how to implement functions that can pause using continuations, and how this relates to suspend
functions and coroutines.
sequence
//sampleStart fun <T> sequence( block: suspend SequenceScope<T>.() -> Unit ): Sequence<T> //sampleEnd
The sequence
method is a unique way you can build up a Sequence
by sequentially generating each element (or collection of elements). Even from it’s signature, you can see it’s a different — we’ve never encountered the suspend
keyword until now.
The key difference between generateSequence
, which we discussed in the previous article, and sequence
is that in this case, the values are not produced by returning them from a lambda, but by calling yield
(for producing a single element), or yieldAll
(for producing all elements in a collection). Crucially, neither of those calls need to be at the end of a function, like a return statement — you can call them whenever you want to, which allows us to solve certain problems much more elegantly.
Let’s take a look at an example:
//sampleStart fun fibonacci_return(): Sequence<Int> { var terms = Pair(0, 1) return generateSequence { val returnValue = terms.first terms = Pair(terms.second, terms.first + terms.second) returnValue } } fun fibonacci_yield() = sequence { var terms = Pair(0, 1) while (true) { yield(terms.first) terms = Pair(terms.second, terms.first + terms.second) } } //sampleEnd fun main() { val poem = """ Kotlin, the architect of code's tower, With sealed classes, it builds the power. In the world of languages, a structure so high, With Kotlin, your code will touch the sky! """.trimIndent() println(poem) }
Both of the above implement the sequence of Fibonacci numbers.
In the first version, where we’re constrained to returning from a function, it’s more difficult to deal with state (notice that we have to keep the state outside of the lambda defining the sequence), more difficult to update state after we produce the value (notice that we need to save the value in an intermediate variable so we can return it) and involves other nuisances as well. For example, since the state is kept track of separately, there’s no input to the lambda in generateSequence
. However, this means that we’re using the generateSequence
variant that produces Sequence
s constrained to run only once! In a way, this is actually a good thing, since if we ran it multiple times, we would get nonsensical results — both runs would be accessing the same terms
variable — but it’s certainly not something we want to deal with in a scenario as simple as generating a mathematical sequence of numbers.
The yielding version solves all of these problems nicely. The fundamental reason this is so is because it gives us complete control over the flow of execution.
Yielding vs. Returning
When using constructs like generateSequence
, the order in which certain things happen are prescribed, and cannot be changed. For instance, in generateSequence
, the prescribed flow of execution is:
- run function,
- use produced value as element in sequence,
- pass produced value as input to next run of function,
- repeat.
These steps are set in stone, and it is impossible to e.g. wedge an additional step between “use produced value as element in sequence” and “pass produced value as input to next run of function”, which is ideally what we want to do when generating a Fibonacci sequence. We also don’t have control over the manner in which these steps are repeated — there is obviously some sort of loop or iteration involved, but it’s hidden inside the implementation of generateSequence
.
When we produce values by calling a function (commonly called yielding) instead of returning, we can do whatever we want, including updating state after producing a value (which saves us from having to define a temporary variable), and implementing the loop manually (which allows us to include the state inside the generating lambda, but still keep it outside the loop, which is why we couldn’t do the same thing with generateSequence
).
Now, if you’ve been paying attention, you should be thinking Waaait a second…how can that even work? After all, the defining feature of sequences is that all operations get applied as soon as an element is produced — indeed, sequences can be infinite. But in the approach above, we’re not producing an element at the end of the execution of the lambda, but in the middle of it. In essence, this amounts to being able to instruct the function to produce a value and pause, and having it continue from the same spot when the next value is requested.
That sounds crazy — how could that possibly work? How can a function just decide to pause (or temporarily suspend its execution) in the middle of a run, and resume later? And what is that suspend
thing?
The answer to the second question is: actually, it doesn’t really pause. It just looks like does, which is a trick made possible by the Kotlin compiler.
Implementing a pausing function
Let’s try to demonstrate the essence of how to implement a “pausing” function without taking advantage of Kotlin-specific compiler-fu, so you get a very rough idea of what the compiler is doing behind the scenes. The actual technical implementation uses the framework upon which Kotlin coroutines are built, which is also where that suspend
keyword comes from.
//sampleStart sealed interface PauseState object ValueReady : PauseState object ValueNotReady : PauseState object Finished : PauseState typealias SimpleContinuation = () -> Unit class PausingIterator<T : Any>(firstPart: PausingIterator<T>.() -> Unit): Iterator<T> { private var state: PauseState = ValueNotReady private var value: T? = null private var nextPart: SimpleContinuation? = { firstPart() } fun yield(value: T, nextPart: SimpleContinuation) { this.value = value this.nextPart = nextPart state = ValueReady } override fun hasNext(): Boolean { while(true) { when(this.state) { ValueReady -> return true Finished -> return false ValueNotReady -> { /* Do nothing */} } this.state = Finished nextPart!!() } } override fun next() = when(state) { Finished -> throw NoSuchElementException() ValueReady -> { state = ValueNotReady value!! } ValueNotReady -> { if(!hasNext()) throw NoSuchElementException() value!! } } } fun <T: Any> buildUsingYield( block: PausingIterator<T>.() -> Unit ): Iterator<T> = PausingIterator<T>(block) fun main() { val seq_rolledOut = buildUsingYield<Int> { var state = 1 println("Yielding 1 and pausing") yield(state++) { println("Resumed after 1, yielding 2 and pausing") yield(state++) { println("Resumed after 2, yielding 3 and pausing") yield(state++) { println("Resumed after 3, and finishing") } } } } val seq_withLooping = buildUsingYield<Int> { var state = 1 fun buildContinuation(): SimpleContinuation = { if(state <= 3) { println("Resumed after ${state-1}, yielding ${state} and pausing") yield(state++, buildContinuation()) } else { println("Resumed after ${state-1} and finishing") } } println("Yielding 1 and pausing") yield(state++, buildContinuation()) } seq_rolledOut.forEach { println(it) println("Yadayada") } println("--------") seq_withLooping.forEach { println(it) println("Yadayada") } } //sampleEnd
The above is a simplified builder for Iterator
s producing values of some non-nullable type T
via a yield
“pausing” function. It uses a simple state machine to determine if a new value has already been produced and is available, or is not yet available and must be produced. To keep things as simple as possible, we’re completely ignoring any type of error handling except for the basic “no more elements” scenario, and also don’t support producing nullable values.
The fundamental principle that makes this work is the fact that yield
accepts a callback, which contains everything that should happen after the function is “resumed”. When yield
is called, this callback is saved in nextPart
, and gets called once the state machine determines that we need to produce a new value. These types of callbacks are called continuations (they continue with executing the rest of the code), and they allow for some of the more powerful techniques in programming.
Once you think about it like that, you’ll see that there isn’t actually any pausing involved — it’s a trick. Instead, you break up your function into nested callbacks (or, to use a more technical term, rewrite it in continuation-passing style) and pass each callback to some sort of logic that decides when to call them. It is then trivial to call each successive callback whenever you feel like it — now, later, whenever.
In the above, the state machine controls when the function gets “resumed” (it gets resumed when hasNext
is called), but you could do anything you want. The actual implementation of sequence
uses something similar, but much more robust (deals with errors, allows nullable values, and so on).
“But we’re not writing any such code when building sequences!” I hear you exclaim. “There are no callbacks, no nested lambdas, nothing like that!”. That’s because the compiler is doing all this for us, behind the scenes. To simplify things greatly, that suspend
keyword you saw at the beginning tells the compiler to take the following:
//sampleStart val seq = buildUsingYield<Int> { println("Yielding 1 and pausing") yield(1) println("Resumed after 1, yielding 2 and pausing") yield(2) println("Resumed after 2, yielding 3 and pausing") yield(3) println("Resumed after 3, and finishing") } //sampleEnd
And transform it by adding a hidden Continuation
parameter to yield
, and change the calling code to something that distantly resembles the following:
//sampleStart val seq = buildUsingYield<Int> { println("Yielding 1 and pausing") yield(1) { println("Resumed after 1, yielding 2 and pausing") yield(2) { println("Resumed after 2, yielding 3 and pausing") yield(3) { println("Resumed after 3, and finishing") } } } } //sampleEnd
Just so we understand each other, theContinuation
instance is not a functional or SAM type— you can’t pass a lambda where a Continuation
is expected. In the above, I’m simply demonstrating what the Continuation
represents — a thing you can call to continue executing the rest of the program — by writing a lambda instead of it. You can see that that can’t be exactly what’s happening, because you couldn’t translate our Fibonacci example, where yield
is part of a while
loop, into a similar form. However in principle, this — i.e. rewriting yield
to accept a Continuation
— is what happens.
By the way, this is the fundamental principle the entire coroutines framework is built upon, but that’s a story for another day.
It’s worth mentioning that there is a builder for instances of Iterator
that permits the same syntax:
//sampleStart fun <T> iterator( block: suspend SequenceScope<T>.() -> Unit ): Iterator<T> //sampleEnd
Example
//sampleStart interface Node data class Tree(val node: Node, val children: List ): Iterable { override fun iterator(): Iterator = iterator { yield(node) children.forEach { yieldAll(it) } } } //sampleEnd fun main() { val poem = """ In the code's carnival, Kotlin's the ride, With extension functions, it's the guide. From loops to spins, a coding spree, In the world of development, it's the key! """.trimIndent() println(poem) }