1. Introduction
We often need to work with collections in our Kotlin code, and on many occasions, we need to be able to transform either the elements of the collection or the entire collection into other forms. The Kotlin standard library gives us a number of built-in ways to achieve this so that we can better focus on our code.
2. Filtering Values
One of the more basic transformations that we can perform on a collection is to ensure that all of the elements meet certain criteria. We can perform this by transforming one collection into another, and filtering all of the elements so that only the ones that we want to match – essentially like running the collection through a sieve.
The basic filter call takes a lambda function as a predicate, applies it to every element in the collection, and only allows that element through if the predicate returns true:
val input = listOf(1, 2, 3, 4, 5)
val filtered = input.filter { it <= 3 }
assertEquals(listOf(1, 2, 3), filtered)
We can use any means to provide a function to this, including a reference to another method:
val filtered = input.filter(this::isSmall)
On some occasions, we need to be able to reverse this. One option is for us to wrap the call in our own lambda and negate the return value. But Kotlin also gives us a direct call that we can use in place of this:
val large = input.filter { !isSmall(it) }
val alsoLarge = input.filterNot(this::isSmall)
assertEquals(listOf(4, 5), filtered)
In some cases, we might also need to know the index into the collection when we are filtering elements. For this, we have the filterIndexed method that will call a lambda with both index and element:
val input = listOf(5, 4, 3, 2, 1)
val filtered = input.filterIndexed { index, element -> index < 3 }
assertEquals(listOf(5, 4, 3), filtered)
There are also some special filtering methods that we can use that will subtly transform the collection on the way through. We have one for converting a collection of nullable values into guaranteed non-null values, and we can convert a collection of values into sub-types:
val nullable: List<String?> = ...
val nonnull: List<String> = nullable.filterNotNull()
val superclasses: List<Superclass> = ...
val subclasses: List<Subclass> = superclasses.filterIsInstance<Subclass>()
Note that these are transforming the type of the collection itself, by ensuring the contents of the collection meet some requirement.
3. Mapping Values
We’ve just seen how we can take a collection and filter out elements that don’t match our needs. We can also take a collection and convert – or map – the values from one to another. This mapping is as simple or complicated as needed:
val input = listOf("one", "two", "three")
val reversed = input.map { it.reversed() }
assertEquals(listOf("eno", "owt", "eerht"), reversed)
val lengths = input.map { it.length }
assertEquals(listOf(3, 3, 5), lengths)
The first of these converts a String into a different String, where the characters are reversed. The second converts the String into a totally different type, representing the number of characters in the String. Both of these work exactly the same for our code.
As with filtering, we also have a version that can know the index of the elements in the collection as well:
val input = listOf(3, 2, 1)
val result = input.mapIndexed { index, value -> index * value }
assertEquals(listOf(0, 2, 2), result)
In some cases, we will have a mapping function that might return null values and need to remove these. We’ve already seen that we can do this by chaining on a filterNotNull after our call, but Kotlin actually gives us this built-in, in the form of mapNotNull and mapIndexedNotNull:
val input = listOf(1, 2, 3, 4, 5)
val smallSquares = input.mapNotNull {
if (it <= 3) {
it * it
} else {
null
}
}
assertEquals(listOf(1, 4, 9), smallSquares)
3.1. Transforming Maps
So far, everything we’ve seen applies to standard Java collections such as List and Set. We also have some that we can apply specifically to Map types as well, allowing us to transform the keys or the values of a map. In both cases, our lambda is called with the Map.Entry<> that represents that entry in the map, but the return value will determine what is transformed:
val inputs = mapOf("one" to 1, "two" to 2, "three" to 3)
val squares = inputs.mapValues { it.value * it.value }
assertEquals(mapOf("one" to 1, "two" to 4, "three" to 9), squares)
val uppercases = inputs.mapKeys { it.key.toUpperCase() }
assertEquals(mapOf("ONE" to 1, "TWO" to 2, "THREE" to 3), uppercases)
4. Flattening Collections
We’ve seen how we can transform collections by mapping individual values. This is often used for mapping one simple value to another, but it can also be used to map simple values into collections of values instead:
val inputs = listOf("one", "two", "three")
val characters = inputs.map(String::toList)
assertEquals(listOf(listOf('o', 'n', 'e'), listOf('t', 'w', 'o'),
listOf('t', 'h', 'r', 'e', 'e')), characters)
If we want to, we can use the flatten method to convert this into a single list instead of a nested one:
val flattened = characters.flatten();
assertEquals(listOf('o', 'n', 'e', 't', 'w', 'o', 't', 'h', 'r', 'e', 'e'), flattened)
In addition to this, because the two often go together, we have the flatMap method. This can be thought of as the combination of map and flatten built into a single method call. It is required for the lambda passed to this to return a Collection
val inputs = listOf("one", "two", "three")
val characters = inputs.flatMap(String::toList)
assertEquals(listOf('o', 'n', 'e', 't', 'w', 'o', 't', 'h', 'r', 'e', 'e'), characters)
5. Zipping Collections
So far, we’ve seen several tools for transforming a single collection, either by filtering out values that don’t meet criteria or by transforming values within the collection.
In some cases, we want to perform a transformation that combines two different collections together to produce a single one. This is known as zipping the two collections together, taking elements from each and producing a list of pairs:
val left = listOf("one", "two", "three")
val right = listOf(1, 2, 3)
val zipped = left.zip(right)
assertEquals(listOf(Pair("one", 1), Pair("two", 2), Pair("three", 3)), zipped)
The new list contains Pair<L, R> elements, made up of one element from the left list, and one from the right list.
In some cases, the lists are not of the same length. In this case, the resulting list is the same length as the shortest input list:
val left = listOf("one", "two")
val right = listOf(1, 2, 3)
val zipped = left.zip(right)
assertEquals(listOf(Pair("one", 1), Pair("two", 2)), zipped)
Since this itself returns a collection, we are then able to perform any additional transformations on it, including filtering and mapping them. This will act on the Pair<L, R> elements in this new list, which allows for some interesting abilities:
val posts = ...
posts.map(post -> authorService.getAuthor(post.author)) // Returns a collection of authors
.zip(posts) // Returns a collection of Pair<Author, Post>
.map((author, post) -> "Post ${post.title} was written by ${author.name}")
We sometimes need to then go in the other direction – taking a Collection<Pair<L, R>>, and converting it back into two lists. This is known as unzipping the collection and literally transforms Collection<Pair<L, R>> into Pair<List
val left = listOf("one", "two", "three")
val right = listOf(1, 2, 3)
val zipped = left.zip(right)
val (newLeft, newRight) = zipped.unzip()
assertEquals(left, newLeft)
assertEquals(right, newRight)
6. Converting Collections to Maps
Everything that we’ve seen so far transforms collections into the same type and just manipulates the data within the collection. We can also transform a collection into a Map<K, V> in some cases, though, through a variety of mechanisms.
The simplest way is to use the toMap() method to directly convert a *Collection<Pair<L,* R>> into a Map<L, R>. In this case, our collection already contains all of the map entries, but it’s just not structured as a map:
val input = listOf(Pair("one", 1), Pair("two", 2))
val map = input.toMap()
assertEquals(mapOf("one" to 1, "two" to 2), map)
How we get our Collection<Pair<L, R>> is entirely up to us – for example, it could be directly constructed, but it could also be the result of a map or zip operation.
We can also perform some of this together by associating values in our collection with other values from somewhere. We have the options here to treat the collection elements like the map key, the map value, or as some means to generate both key and value:
val inputs = listOf("Hi", "there")
// Collection elements as keys
val map = inputs.associateWith { k -> k.length }
assertEquals(mapOf("Hi" to 2, "there" to 5), map)
// Collection elements as values
val map = inputs.associateBy { v -> v.length }
assertEquals(mapOf(2 to "Hi", 5 to "there"), map)
// Collection elements generate key and value
val map = inputs.associate { e -> Pair(e.toUpperCase(), e.reversed()) }
assertEquals(mapOf("HI" to "iH", "THERE" to "ereht"), map)
In this case, duplicates get filtered out. Any element in the collection that would produce the same map key will cause only the last one to appear in the map:
val inputs = listOf("one", "two")
val map = inputs.associateBy { v -> v.length }
assertEquals(mapOf(3 to "two"), map)
If we want to instead keep all of the duplicates so that we know every instance that mapped together, then we can use the groupBy methods instead. These return a Map<K, List
val inputs = listOf("one", "two", "three")
val map = inputs.groupBy { v -> v.length }
assertEquals(mapOf(3 to listOf("one", "two"), 5 to listOf("three")), map)
7. Joining Collections into Strings
Instead of transforming to another collection, or to a map, we can instead join all of the elements in our collection together into one single String.
When doing this, we can optionally provide values to use for the separator between elements, the prefix at the start of the new string, and the suffix at the end of the string:
val inputs = listOf("Jan", "Feb", "Mar", "Apr", "May")
val simpleString = inputs.joinToString()
assertEquals("Jan, Feb, Mar, Apr, May", simpleString)
val detailedString = inputs.joinToString(separator = ",", prefix="Months: ", postfix=".")
assertEquals("Months: Jan,Feb,Mar,Apr,May.", detailedString)
As we can see, if we omit the prefix and suffix, they are the empty string, and if we omit the separator, then it is the string “, “.
We can also specify a limit to the number of elements to combine. When doing this, we additionally get to specify a truncation suffix to put on if we do limit the string:
val inputs = listOf("Jan", "Feb", "Mar", "Apr", "May")
val simpleString = inputs.joinToString(limit = 3)
assertEquals("Jan, Feb, Mar, ...", simpleString)
This call also has the built-in ability to transform the elements. This functions exactly the same as if we had made a map call before the joinToString call, but only needs to transform the elements that are actually included – in other words, anything past the limit call does not get transformed:
val inputs = listOf("Jan", "Feb", "Mar", "Apr", "May")
val simpleString = inputs.joinToString(transform = String::toUpperCase)
assertEquals("JAN, FEB, MAR, APR, MAY", simpleString)
We also have access to an additional version of this call that writes the output string to an Appendable instance – for example, a StringBuilder. This can let us join a collection into the middle of a string we’re building – essentially as a more powerful version of the prefix and suffix parameters:
val inputs = listOf("Jan", "Feb", "Mar", "Apr", "May")
val output = StringBuilder()
output.append("My ")
.append(inputs.size)
.append(" elements: ")
inputs.joinTo(output)
assertEquals("My 5 elements: Jan, Feb, Mar, Apr, May", output.toString())
8. Reducing Collections to Values
Above, we saw how to perform a specific action to combine a collection into a single value – joining the values all together into a string.
We have a much more powerful alternative that we can use, known as reducing the collection. This works by providing a lambda that knows how to combine the next element in the collection onto our so-far accumulated value. Finally, we get back the total accumulated value.
For example, we can use this feature to implement a simple version of joinToString:
val inputs = listOf("Jan", "Feb", "Mar", "Apr", "May")val result =
inputs.reduce { acc, next -> "$acc, $next" }
assertEquals("Jan, Feb, Mar, Apr, May", result)
This uses the first element in the array as our initial accumulated value and calls our lambda to combine each of the subsequent values into it:
- Call 1 – acc = “Jan”, next = “Feb”
- Call 2 – acc = “Jan, Feb”, next = “Mar”
- Call 3 – acc = “Jan, Feb, Mar”, next = “Apr”
- Call 4 – acc = “Jan, Feb, Mar, Apr”, next = “May”
This only works where our elements are of the same type as our desired output. It also only works for collections that contain at least one element.
Another alternative is the fold method, which is almost the same, but we provide an additional argument as the initial accumulated value. By doing this, we can have our accumulated values be any type we want, and we can support collections of any size – including empty collections:
val inputs = listOf("Jan", "Feb", "Mar", "Apr", "May")
val result = inputs.fold(0) { acc, next -> acc + next.length }
assertEquals(15, totalLength)
This time our lambda will get called five times – once for each element in the list – and will have an initial accumulated value of 0.
9. Summary
We’ve seen a variety of tools built into the Kotlin standard library for manipulating collections in a variety of ways. All of the examples seen here can be found over on GitHub. Why not see how we can make use of these in our next project?