|
From Kotlin in Action by Dmitry Jemerov and Svetlana Isakova The functional style provides many benefits when it comes to manipulating collections. You can use library functions for most tasks and simplify your code. In this article, we’ll discuss some of the functions in the Kotlin standard library for working with collections. |
Save 37% on Kotlin in Action. Just enter code fccjemerov into the discount code box at checkout at manning.com.
We’ll start with staples like filter
and map
and the concepts behind them. We’ll cover other useful functions and give you tips about how not to overuse them and write clear and comprehensible code.
Note that none of these functions were invented by the designers of Kotlin. These or similar functions are available for all languages that support lambdas, including C#, Groovy and Scala. If you’re already familiar with these concepts, you can quickly look through the following examples and skip the explanations.
Essentials: filter and map
The filter
and map
functions form the basis for manipulating collections. Many requests to collections can be expressed with their help.
For each function, we’ll provide one example with numbers and one using the familiar Person
class:
data class Person(val name: String, val age: Int)
The filter
function goes through a collection and selects the elements for which the given lambda returns true
:
>>> val list = listOf(1, 2, 3, 4)
>>> println(list.filter { it % 2 == 0 }) ❶
[2, 4]
❶ Only even numbers remain.
The result is a new collection that contains only the elements from the input collection that satisfy the predicate, as illustrated in figure 1.
Figure 1. The filter
function selects elements matching given predicate.
If you want to keep only people older than 30, you can use filter
:
>>> val people = listOf(Person("Alice", 29), Person("Bob", 31))
>>> println(people.filter { it.age > 30 })
[Person(name=Bob, age=31)]
The filter
function can remove unwanted elements from a collection, but it doesn’t change the elements themselves. Transforming elements is where map
comes into play.
The map
function applies the given function to each element in the collection and collects the results into a new collection. You can transform a list of numbers into a list of their squares, for example:
>>> val list = listOf(1, 2, 3, 4)
>>> println(list.map { it * it })
[1, 4, 9, 16]
The result is a new collection that contains the same number of elements, but each element is transformed based on the given predicate (see figure 2).
Figure 2. The map
function applies a lambda to all elements in a collection.
If you want to print a list of names, not a list of people, you can transform the list using map
:
>>> val people = listOf(Person("Alice", 29), Person("Bob", 31))
>>> println(people.map { it.name })
[Alice, Bob]
Note that this example can be nicely rewritten using member references:
people.map(Person::name)
You can chain several calls like that. For example, let’s print the names of people older than 30:
>>> people.filter { it.age > 30 }.map(Person::name)
[Bob]
Now, let’s say you need the names of the oldest people in the group. You can find the maximum age of the people in the group and return everyone who’s that age. It’s easy to write such code using lambdas:
people.filter { it.age == people.maxBy(Person::age).age }
Note that this code repeats the process of finding the maximum age for every person. If there are one hundred people in the collection, the search for the maximum age will be performed one hundred times!
The following solution improves on that and calculates the maximum age once:
val maxAge = people.maxBy(Person::age).age
people.filter { it.age == maxAge }
Don’t repeat a calculation if you don’t need to! Simple-looking code using lambda expressions can sometimes obscure the complexity of the underlying operations. Always keep in mind what’s happening in the code.
You can also apply the filtering and transformation functions to maps:
>>> val numbers = mapOf(0 to "zero", 1 to "one")
>>> println(numbers.mapValues { it.value.toUpperCase() })
{0=ZERO, 1=ONE}
Separate functions exist to handle keys and values. filterKeys
and mapKeys
filter and transform the keys of a map, respectively, whereas filterValues
and mapValues
filter and transform the corresponding values.
all
, any
, count
, and find
: applying a predicate to a collection
Another common task is checking whether all elements in a collection match a certain condition (or, as a variation, whether any elements match). In Kotlin, this is expressed through the all
and any
functions. The count
function checks how many elements satisfy the predicate, and the find
function returns the first matching element.
To demonstrate those functions, let’s define the predicate canBeInClub27
to check whether a person is 27 or younger:
val canBeInClub27 = { p: Person -> p.age <= 27 }
If you’re interested in whether all the elements satisfy this predicate, you use the all
function:
>>> val people = listOf(Person("Alice", 27), Person("Bob", 31))
>>> println(people.all(canBeInClub27))
false
If you need to check whether there’s at least one matching element, use any
:
>>> println(people.any(canBeInClub27))
true
Note that !all
(“not all”) with a condition can be replaced by any
with a negation of that condition, and vice versa. To make your code easier to understand, you should choose a function that doesn’t require you to put a negation sign before it:
>>> val list = listOf(1, 2, 3)
>>> println(!list.all { it == 3 }) ❶
true
>>> println(list.any { it != 3 }) ❷
true
❶ The negation ! isn’t noticeable, and it’s better to use “any” in this case.
❷ The condition in the argument has changed to its opposite.
The first check ensures that not all elements are equal to 3. That’s the same as having at least one non-3, which is what you check using any
on the second line.
If you want to know how many elements satisfy this predicate, use count
:
>>> val people = listOf(Person("Alice", 27), Person("Bob", 31))
>>> println(people.count(canBeInClub27))
1
Using the right function for the job: count vs. size
It’s easy to forget about count
and implement it by filtering the collection and getting its size:
>>> println(people.filter(canBeInClub27).size)
1
In this case, an intermediate collection is created to store all the elements that satisfy the predicate. On the other hand, the count
method tracks the number of matching elements, not the elements themselves, and is therefore more efficient.
Generally, try to find the most appropriate operation that suits your needs.
To find an element that satisfies the predicate, use the find
function:
>>> val people = listOf(Person("Alice", 27), Person("Bob", 31))
>>> println(people.find(canBeInClub27))
Person(name=Alice, age=27)
This returns the first matching element if there are many, or null
if nothing satisfies the predicate. A synonym of find
is firstOrNull
, which you can use if it clarifies the idea for you.
groupBy
: converting a list to a map of groups
Imagine that you need to divide all elements into different groups based on some quality. For example, you want to group people of the same age together. It’s convenient to pass this quality directly as a parameter. The groupBy
function can do this for you:
>>> val people = listOf(Person("Alice", 31),
... Person("Bob", 29), Person("Carol", 31))
>>> println(people.groupBy { it.age })
The result of this operation is a map from the key by which the elements are grouped (age
, in this case) to the groups of elements (persons); see figure 3.
Figure 3. The result of applying the groupBy
function
For this example, the output is as follows:
{29=[Person(name=Bob, age=29)],
31=[Person(name=Alice, age=31), Person(name=Carol, age=31)]}
Each group is stored in a list, and the resulting type is Map<Int, List<Person>>
. You can do further modifications with this map, using functions such as mapKeys
and mapValues
.
As another example, let’s see how to group strings by their first character using member references:
>>> val list = listOf("a", "ab", "b")
>>> println(list.groupBy(String::first))
{a=[a, ab], b=[b]}
Note that first
here isn’t a member of the String
class, it’s an extension. Nevertheless, you can access it as a member reference.
flatMap
and flatten
: processing elements in nested collections
Now let’s put aside our discussion of people and switch to books. Suppose you have a collection of books, represented by the class Book
:
class Book(val title: String, val authors: List<String>)
Each book was written by one or more authors. You can compute the set of all the authors in your library:
books.flatMap { it.authors }.toSet()❶
❶ Set of all authors who wrote books in “books” collection
The flatMap
function does two things: at first it transforms (or maps) each element to a collection based on the function given as an argument, and then it combines (or flattens) several lists into one. An example with strings illustrates this concept well (see figure 4):
>>> val strings = listOf("abc", "def")
>>> println(strings.flatMap { it.toList() })
[a, b, c, d, e, f]
Figure 4. The result of applying the flatMap
function
The toList
function on a string converts it into a list of characters. If you use the map
function together with toList
, you’ll get a list of lists of characters, as shown in the second row in the figure. The flatMap
function does the following step as well, and returns one list consisting of all the elements.
Let’s return to the authors:
>>> val books = listOf(Book("Thursday Next", listOf("Jasper Fforde")),
... Book("Mort", listOf("Terry Pratchett")),
... Book("Good Omens", listOf("Terry Pratchett",
... "Neil Gaiman")))
>>> println(books.flatMap { it.authors }.toSet())
[Jasper Fforde, Terry Pratchett, Neil Gaiman]
Each book can be written by multiple authors, and the book.authors
property stores the collection of authors. The flatMap
function combines the authors of all the books in a single, flat list. The toSet
call removes duplicates from the resulting collection—in this example, Terry Pratchett is listed only once in the output.
You may think of flatMap
when you’re stuck with a collection of collections of elements that must be combined into one. Note that if you don’t need to transform anything and need to flatten such a collection, you can use the flatten
function: listOfLists.flatten()
.
We’ve highlighted a few of the collection operation functions in the Kotlin standard library, but there are many more. Our general advice when you write code that works with collections is to think of how the operation could be expressed as a general transformation, and to look for a library function that performs such a transformation. It’s likely that you’ll be able to find one and use it to solve your problem more quickly than with a manual implementation.
That’s it for this article.
For more info on Kotlin, check out the whole book on liveBook here and see this Slideshare presentation.