Kotlin Sequence Tutorial


Kotlin is a fascinating programming language that combines both object-oriented and functional programming concepts. Sequences are a key abstraction to functional programming in Kotlin, a concept quite similar to Java 8 Streams. Sequences enable you to easily operate upon collections of elements by chaining pure function calls with a rich fluent API. Let’s take a deeper look at how sequences work and what you can achieve with it in this example-driven tutorial.

data class Person(val name: String, val age: Int)

val persons = listOf(
    Person("Peter", 16),
    Person("Anna", 23),
    Person("Anna", 28),
    Person("Sonya", 39)
)

val names = persons
    .asSequence()
    .filter { it.age > 18 }
    .map { it.name }
    .distinct()
    .sorted()
    .toList()

print(names)     // [Anna, Sonya]

This first example creates a sequence by calling asSequence on a list of person objects. Sequences provide a fluent API to perform operations on the input elements. Sequence operations such as filter, map, distinct and sorted are intermediate and return a new sequence, thus enabling method chaining. Other operations such as toList are terminal and return an arbitrary result, in this example a new list of elements.

We’ll discover a lot of sequence operations during this article, all described with easy-to-grasp code samples. We even learn how to extend sequences with our own custom operations.

If you’re not yet familiar with Kotlins lambda syntax such as map { it.name }, you should first read this section of the official language docs.

Creating sequences

Sequences can be created in various different ways. The most common usecase is to create a sequence from a collection of elements by calling the method asSequence on an iterable (such as a list, set or map) as seen in the previous example.

But sequences are not limited to collections. For example the function sequenceOf creates a sequence from a variable number of arguments:

val result = sequenceOf(1, 2, 3, 4, 5)
    .filter { it % 2 == 1 }
    .toList()

print(result)     // [1, 3, 5]

The above example creates a sequence for a range of integer numbers. Instead of passing these numbers manually you could also utilize a generator function via generateSequence:

val result = generateSequence(0) { it + 1 }
    .take(5)
    .filter { it % 2 == 1 }
    .toList()

print(result)     // [1, 3, 5]

This example generates an infinite sequence of numbers calculated by the function f(n) = n + 1 with n starting at 0. We use the intermediate operation take(5) to restrict the size of the sequence to the first 5 elements. Otherwise the sequence would yield an infinite number of elements and calling toList would raise an exception.

In order to simplify the last examples we can just create a sequence from IntRange of 1..6:

val result = (1..6)
    .asSequence()
    .filter { it % 2 == 1 }
    .toList()

print(result)   // [1, 3, 5]

As a side note it’s worth mentioning that most sequences in Kotlin can be reused and evaluated multiple times:

val sequence = sequenceOf(1, 2, 3, 4, 5)
    .filter { it % 2 == 1 }
println(sequence.toList())      // [1, 3, 5]
println(sequence.first())       // 1

Processing order

We’ve learned different ways to create sequences for a given set of input data. Now let’s examine how those sequences are actually processed.

The first important thing to know is that all intermediate operations (the functions that return a new sequence) are not executed until a terminal operation has been called. Sequences are evaluated lazily to avoid examining all of the input data when it’s not necessary.

The following code will print nothing because filter is an intermediate function and no terminal function is ever called:

sequenceOf("A", "B", "C")
    .filter {
        println("filter: $it")
        true
    }

Now, if we extend the example by calling the terminal function forEach after filter, we can actually examine the order of processing the elements:

sequenceOf("A", "B", "C")
    .filter {
        println("filter: $it")
        true
    }
    .forEach {
        println("forEach: $it")
    }

// filter:  A
// forEach: A
// filter:  B
// forEach: B
// filter:  C
// forEach: C

Each element will be passed vertically to each sequence operation. The advantage of this processing order will be noticeable in the next example:

val result = sequenceOf("a", "b", "c")
    .map {
        println("map: $it")
        it.toUpperCase()
    }
    .any {
        println("any: $it")
        it.startsWith("B")
    }

println(result)

// map: A
// any: A
// map: B
// any: B
// true

The interesting aspect is that the element c is never processed due to the early exit characteristic of sequences. The terminal function any stops further processing of elements as soon as the given predicate function yields true. This can greatly improve the performance of your application when working with large input collections.

Why order matters

We’ve learned that Sequences are lazily evaluated and exit early to always perform the least amount of work needed. Now let’s find out how we can utilize this knowledge in order to write optimal sequences. How do you think the next sequence example could be optimized?

sequenceOf("a", "b", "c", "d")
    .map {
        println("map: $it")
        it.toUpperCase()
    }
    .filter {
        println("filter: $it")
        it.startsWith("a", ignoreCase = true)
    }
    .forEach {
        println("forEach: $it")
    }

// map:     a
// filter:  A
// forEach: A
// map:     b
// filter:  B
// map:     c
// filter:  C
// map:     d
// filter:  D

As you might have guessed filter has the same early exit characterics as the terminal function any in the previous example. So we can simply improve the sequence by flipping the order of filter and map. That way we reduce the number of operation calls from 9 to 6.

sequenceOf("a", "b", "c", "d")
    .filter {
        println("filter: $it")
        it.startsWith("a", ignoreCase = true)
    }
    .map {
        println("map: $it")
        it.toUpperCase()
    }
    .forEach {
        println("forEach: $it")
    }

// filter:  a
// map:     a
// forEach: A
// filter:  b
// filter:  c
// filter:  d

While those kinds of performance optimizations are negligible for small input data, it can significantly improve performance for larger sets of input data. Luckily ordering of sequences operations becomes quite natural really fast. You just have to remember the following rule: always put data-reducing operations such as filter before data-transforming operations such as map and you’re good to go.

Sequences vs Collections

We must talk a moment about collections before proceeding with more sequence operation examples. While Kotlin uses the traditional Java Collection API, there’s a bunch of extension functions available for the most common collections such as lists, sets and maps. To be more precise almost all sequence operations are also available as collection-methods without sequences being involved. However there’s a subtle difference between using sequences vs. directly calling these operations on collections.

The following example includes two variants of transforming a list of numbers into a list of strings. The first variant utilizes sequences while the second variant operates directly on the input collection:

// variant 1
val list1 = listOf(1, 2, 3, 4, 5)
    .asSequence()
    .filter { it % 2 == 1 }
    .sortedDescending()
    .map { it.toString() }
    .toList()

print(list1)   // ["5", "3", "1"]

// variant 2
val list2 = listOf(1, 2, 3, 4, 5)
    .filter { it % 2 == 1 }
    .sortedDescending()
    .map { it.toString() }

print(list2)   // ["5", "3", "1"]

The main difference between both variants is that all collection functions are terminal operations resulting in another collection of the same type, in this case List. In other words the List functions filter, map and sortedDescending all return a new List. In contrast the same intermediate sequence operations return new sequences which is a lot more efficient in terms of memory-consumption because no additional collections are being allocated while processing the sequence.

Another huge benefit of sequences over collections is lazy evaluation. Since collection operations are not lazily evaluated you see huge performance increases when using sequences in certain scenarios.

In order to demonstrate this behavior, we use the following helper function:

fun measure(block: () -> Unit) {
    val nanoTime = measureNanoTime(block)
    val millis = TimeUnit.NANOSECONDS.toMillis(nanoTime)
    print("$millis ms")
}

We first start with processing a huge list of 50.000.000 numbers which takes over 8s on my machine:

val list = generateSequence(1) { it + 1 }
    .take(50_000_000)
    .toList()

measure {
    list
        .filter { it % 3 == 0 }
        .average()
}
// 8644 ms

The same operations using sequences is 10x faster due to lazy evaluation:

val sequence = generateSequence(1) { it + 1 }
    .take(50_000_000)

measure {
    sequence
        .filter { it % 3 == 0 }
        .average()
}
// 822 ms

My advice is to use collection functions only for single-operation transformations, whereas for multi-operation transformations you should always prefer sequences due to lower memory consumption and better performance due to lazy evaluation. In the above example sequences are more efficient whereas for a single list.filter { ... } or a single list.map { ... } you can simply use the collection functions.

Sequence operations

You’ve already learned about the most common sequence operations in the previous examples, such as filter, map, sorted and toList. But there’s a lot more operations available for different kind of programming tasks. I’ve assembled a bunch of code samples to demonstrate a part of the sequence API to you. A few of those examples operate on the following list of persons:

data class Person(val name: String, val age: Int)

val persons = listOf(
    Person("Peter", 16),
    Person("Anna", 28),
    Person("Anna", 23),
    Person("Sonya", 39)
)

Sorting via Java Comparators can be cumbersome. Sequences provide a nifty function sortedBy to sort by a given property of the input elements. The following code sorts the persons by age in ascending order:

val result = persons
    .asSequence()
    .sortedBy { it.age }
    .toList()

print(result)   // [Person(name=Peter, age=16), Person(name=Anna, age=23), Person(name=Anna, age=28), Person(name=Sonya, age=39)]

A similar function distinctBy can be utilized to discard elements based on same property values. This is useful if you don’t want to rely on the equals implementation of the elements. In this case we discard persons with the same name:

val result = persons
    .asSequence()
    .distinctBy { it.name }
    .toList()

print(result)   // [Person(name=Peter, age=16), Person(name=Anna, age=28), Person(name=Sonya, age=39)]

In order to determine the oldest person of the sequence operation maxBy comes in handy. Similar functions are available to calculate the minimum, average or sum.

val result = persons
    .asSequence()
    .maxBy { it.age }

print(result)   // Person(name=Sonya, age=39)

Imagine you need to efficiently find persons by their name. Iterating over all persons on every search would be slow for a large amount of persons. So we better transform the list of persons into a map of persons using the name as key. This task is quite simple with the groupBy function:

val result = persons
    .asSequence()
    .groupBy { it.name }

print(result)   // {Peter=[Person(name=Peter, age=16)], Anna=[Person(name=Anna, age=28), Person(name=Anna, age=23)], Sonya=[Person(name=Sonya, age=39)]}

The resulting map is of type Map<String, List<Person>>. If you instead want to map a single distinct person per name, use the function associateBy which constructs a Map<String, Person>.

val result = persons
    .asSequence()
    .associateBy { it.name }

print(result)   // {Peter=Person(name=Peter, age=16), Anna=Person(name=Anna, age=23), Sonya=Person(name=Sonya, age=39)}

The terminal operation any returns true as soon as the first element matches the given predicate. The following example filters the odd numbers and then checks if any number is even which is obviously not the case:

val result = sequenceOf(1, 2, 3, 4, 5)
    .filter { it % 2 == 1 }
    .any { it % 2 == 0 }

print(result)   // false

Performing operations based on the elements position in the current sequence is quite simple. The function withIndex transforms each element into an IndexedValue, consisting of the element as value and the zero-based index in the sequence. The next example uses withIndex to discard every second element of the sequence before mapping back to the original value:

val result = sequenceOf("a", "b", "c", "d")
    .withIndex()
    .filter { it.index % 2 == 0 }
    .map { it.value }
    .toList()

print(result)   // [a, c]

This example slices a sequence by adding and removing elements on the fly. Similar functions are available to add and remove collections or sequences of elements at once.

val result = sequenceOf("a", "b", "c")
    .plus("d")
    .minus("c")
    .map { it.toUpperCase() }
    .toList()

print(result)   // [A, B, D]

The intermediate operation flatMap is useful to both transform and flatten collections of elements. In the following example a sequence consisting of lists of numbers is flattened into a list of numbers while simultaneously skipping all even numbers:

val result = sequenceOf(listOf(1, 2, 3), listOf(4, 5, 6))
    .flatMap {
        it.asSequence().filter { it % 2 == 1 }
    }
    .toList()

print(result)   // [1, 3, 5]

The last example demonstrates how to construct a single string out of the elements of a sequence. The terminal function joinToString per default uses the string ", " as separator between all joined elements. You can define a custom separator along with other options by passing optional arguments to the function.

val result = persons
    .asSequence()
    .map { it.name }
    .distinct()
    .joinToString();

print(result)   // "Peter, Anna, Sonya"

For a full list of sequence operations, refer to the latest API documentation.

Extending Sequences

We’ve already learned about a lot of sequence operations and there’s much more functions available which I encourage you to explore by yourself. Chances are good that you’ll find a function for every problem you have to solve. Otherwise it’s quite easy in Kotlin to extend sequences with your own operations.

One thing you cannot actually do with sequences is bringing the elements into a random order. Let’s see how an extension function shuffle could be implemented to complement this behavior:

fun <T> Sequence<T>.shuffle(): Sequence<T> {
    return toMutableList()
        .apply { shuffle() }
        .asSequence()
}

The implementation is rather straight forward: we first call the terminal function toMutableList on the existing sequence which is available as this inside the extension function. Then we mutate the list by calling the build-in collection function shuffle. The result is then returned as a new sequence. We can use this extension function just like every other intermediate operation:

val result = sequenceOf(1, 2, 3, 4, 5)
    .shuffle()
    .toList()

print(result)   // [5, 3, 2, 4, 1]

Bonus: Sequences for JavaScript and TypeScript

There’s also an implementation of Kotlin sequences for JavaScript and TypeScript which I wrote in back 2017. The library is called Sequency and available via NPM. For TypeScript projects it offers the same type-safety and code completion as the Kotlin original. If you’re into frontend development, you should give it a try.

import {asSequence} from "sequency";

const result = asSequence([1, 2, 3, 4, 5])
    .filter(it => it % 2 === 1)
    .sortedDescending()
    .toArray();

console.log(result)   // [5, 3, 1]

Where to go from here?

I hope you’ve enjoyed our journey into functional programming with Kotlin Sequences. I encourage you to try the examples by yourself. For that purpose I’ve published the examples to GitHub, so feel free to fork the repository, try the examples and experiment with other sequence operations. If you like this article then tell your friends about it and consider posting a link to this post on your favorite social network. You should also follow me on Twitter for programming-related updates.

Read More