This blog post accompanies a video from our YouTube series which you can find on our Kotlin YouTube channel, or watch here directly!
Kotlin Collections! You’ve heard of them, you’ve used them – so it makes sense to learn even more about them! Kotlin's standard library provides awesome tools to manage groups of items, and we’re going to take a closer look!
Let's see what types of collections the Kotlin standard library offers, and explore a common subset of operations that’s available for all of the collections you get in the standard library. Let’s get started.
In the Kotlin standard library, we have three big types of collections: Lists, Sets, and Maps. Just like many other parts of the standard library, these collections are available anywhere you can write Kotlin: on the JVM, but also in Kotlin/Native, Kotlin/JS, and common Kotlin code.
Lists
Let’s start with the most popular candidate of a collection in Kotlin: a List
. To rehearse:
A list is a collection of ordered elements. That means that you can access the elements of a list using indices – so you can say “give me the element at position two”. There’s also no constraints on duplicate elements in our list. We can just put in whatever elements we’d like. So, very few constraints on content, and maximum versatility in how we access the elements!
val aList = listOf(
"Apple",
"Banana",
"Cherry",
"Apple"
)
aList // [Apple, Banana, Cherry, Apple]
aList[2] // Banana
Sets
Next up, we have the Set
! Sets are groups of objects where we don’t care about the order of elements. Instead, we want to make sure that our collection never contains any duplicates.
That’s the key property of a set: all of its contents are unique.
That makes sets a bit more of a specialized data structure, but there’s a good chance you want to use them in everyday scenarios anyway.
What are typical things you might want to store in a set? Tags, for example. Or, maybe you’re building a social network, and you want to store the IDs of all the friends that a certain user has. In both cases, you don't want to have duplicates in these collections, and probably don't care about the order.
A set can help you enforce these constraints without having to really think about it, and without manual duplication checks.
val emotions = setOf(
"Happy",
"Curious",
"Joyful",
"Happy", // even if we try to add duplicates...
"Joyful" // ...to our set...
)
println(emotions) // ...the elements in our set stay unique!
// [Happy, Curious, Joyful]
Sets are actually also a common mathematical abstraction. Typical mathematical concepts, like unions, intersections, or the set difference also translate neatly into Kotlin code.
Maps
Last, but certainly not least, we have Map
. A map is a set of key-value pairs, where each key is unique. It’s also sometimes called a “dictionary” for that reason. You encounter maps whenever you’re associating data – storing a persons name and their favorite pizza topping, or associating a license plate with vehicle information.
val peopleToPizzaToppings = mapOf(
"Ken" to "Pineapple",
"Lou" to "Peperoni",
"Ash" to "Ketchup"
)
println(peopleToPizzaToppings)
// {Ken=Pineapple, Lou=Peperoni, Ash=Ketchup}
println(toppings["Ash"])
// Ketchup
Key-value pairs are everywhere, and just like in many other languages, maps are the go-to way to manage them in Kotlin.
Collections can be mutable
By default, these collections in Kotlin are read-only. This is in the spirit of immutability which accompanies typical functional paradigms – instead of changing the contents of a collection, you create a new collection with the changes applied, which you can then safely pass around in your application, ensuring that the original collection stays unchanged.
But we also have mutable flavors of all of the collections in Kotlin: we have MutableList
, MutableSet
, and MutableMap
. Those are modifiable, meaning you can comfortably add and remove elements. With data where you’re inherently expecting change, you’d probably use these mutable variants.
Collections are iterable
Kotlin collections being iterable means that the standard library provides a common, standardized set of typical operations for collections, for example, to retrieve their size, check if they contain a certain item, and more.
Lists and sets directly implement the Collection interface, which in turn implements the Iterable
interface. Maps have an iterator()
operator function, and provide iterable properties, like their set of keys, their list of values, as well as the entries of the map, so key-value pairs.
Let’s learn about some shared functionality of iterables. The following examples are going to use a list, but really, we can just assume that we’re just working with an Iterable
here – the concrete implementation does not matter. Also, all the functions discussed leave the original collection unchanged.
Looping over collections
A core function of an Iterable
, as its name suggests, is that it provides a mechanism to access the elements that our collection contains, one after the other – to iterate it.
The easiest way to go through all the elements in a collection is the basic Kotlin for
loop. When we use the for
loop with an Iterable
, the in
operator cleverly understands that we want to go over the iterator:
val fruits = listOf(
"Apple",
"Banana",
"Cherry"
)
for(fruit in fruits) {
println(fruit)
}
// Apple
// Banana
// Cherry
In a more functional style, we can also write this same snippet using the forEach
function:
fruits.forEach { fruit ->
println(fruit)
}
// Apple
// Banana
// Cherry
In this case, forEach
takes every element from our collection, and calls a function (which we provide) with the element as its argument.
Transforming collections: map
Let's continue with a classic when it comes to transforming collections: the map
function! (Don’t be confused! The map
function has nothing to do with the Map
collection type. You can treat them as two completely different things.)
Just like the forEach
function, the map
function is of higher order. So, it:
- Takes each element from our collection,
- applies a function to it, and
- creates another collection, containing the return values of those function applications.
The result of the map function doesn’t have to be the same type as the one of our input collection, either.
This makes the map
function very versatile – whether you want to parse a collection of strings into a collection of integers, or resolve a list of user names to a list of full user profiles –– if you’re transforming one collection into another, it’s probably a good first instinct to think map
.
val fruits = listOf(
"Apple",
"Banana",
"Cherry"
)
val stiurf = fruits.map {
it.reversed()
}
However, you might have a transformation inside your map
function where you can’t generate valid results for all input elements. In this case, we can use the mapNotNull
function, and our resulting collection will only contain those function results that evaluated to an actual value. This also ensures that type of our resulting variable is non-nullable.
val strs = listOf(
"1",
"2",
"three",
"4",
"V"
)
val nums: List<Int> = strs.mapNotNull {
it.toIntOrNull()
}
println(nums)
// [1, 2, 4]
If we need to keep track of the index of the element which we’re currently transforming, we can use the mapIndexed
function. It’s quite similar in how it works, but in this case, we get two parameters in our transformation function: the index and the value:
val rank = listOf(
"Gold",
"Silver",
"Bronze"
)
val ranking = rank.mapIndexed { idx, m ->
"$m ($idx)"
}
println(ranking)
[Gold (0), Silver (1), Bronze (2)]
Filtering collections: filter and partition
If we have a collection, but we’re only interested in elements that fulfil a certain condition, the filter
function comes to the rescue!
Just like the previous examples, filter
accepts another function as its parameter. This time, instead of defining a transformation, we’re defining what you can call a predicate here.
A predicate is a function that takes a collection element and returns a boolean value: true
means that the given element matches the predicate, false
means the opposite. So this predicate acts as the “doorman” – if the value is true
, the collection item is let through to the result collection, otherwise, it is discarded.
open class Person(val name: String, val age: Int) {
override fun toString() = name
}
class Cyborg(name: String) : Person(name, 99)
val people = listOf(
Person("Joe", 15),
Person("Agatha", 25),
Person("Amber", 19),
Cyborg("Rob")
)
val discoVisitors = people.filter {
it.age >= 18
}
println(discoVisitors)
// [Agatha, Amber, Rob]
If you’re testing a negative condition, you can use the filterNot
function instead, which behaves identically, but inverts the condition.
val students = people.filterNot {
it.age >= 18
}
println(students)
// [Joe]
Note that both filter
and filterNot
discard elements where the condition doesn’t match. But maybe we don’t want to discard the “other half” of elements, and instead we want to put those into a separate list. This is where the partition
function comes into play.
By using partition
, we combine the powers of filter
and filterNot
. It returns a pair of lists, where the first list contains all the elements for which the predicate holds true, and the second contains all the elements that fail the test. So, in our doorman analogy, instead of sending people who fail the check away, we just send them to a different place. (Using parentheses, we can destructure this pair of lists directly into two independent variables.)
val (adults, children) = people.partition {
it.age >= 18
}
println(adults)
// [Agatha, Amber, Rob]
println(children)
// [Joe]
If you’re bringing a collection of nullable items to the party, you can use the filterNotNull
function which, as you may have guessed, automatically discards any elements that are null
, and gives you a new collection with an adjusted, non-nullable type accordingly.
Speaking of adjusting types – if your collection contains multiple elements from a type hierarchy, but you’re only interested in elements of a specific type, you can use filterIsInstance
, and specify the desired type as a generic parameter.
val people = listOf(
Person("Joe", 15),
null,
Person("Agatha", 25),
null,
Person("Amber", 19),
Cyborg("Rob"),
null,
)
val actualPeople = people.filterNotNull()
println(actualPeople)
// [Joe, Agatha, Amber, Rob]
val cyborgs = people.filterIsInstance<Cyborg>()
println(cyborgs)
// [Rob]
Retrieve collection parts: take and drop
Filtering allowed us to apply a predicate function, and create a new collection containing items that match. But what about the even simpler cases? Sometimes, we just want to grab a few elements from our collection.
For that, we have the take
and drop
functions. You might already be able to guess what they do. take
gives you a collection of the first n elements from your original collection. So take(2)
is going to give you the first two elements. On the opposite hand, drop(3)
is going to leave out the first three elements of your original collection, and only gives you everything that follows after those three elements. And you don’t have to be afraid to “overdrop” either – dropping more elements from a collection than it contains just gives you an empty list:
val objects = listOf("🌱", "🚀", "💡", "🐧", "⚙️")
val seedlingAndRocket = objects.take(2)
println(seedlingAndRocket)
// [🌱, 🚀]
val penguinAndGear = objects.drop(3)
println(penguinAndGear)
// [🐧, ⚙️]
val nothing = objects.drop(8)
println(nothing)
// []
println(objects) // remember, the original collection is not modified!
// [🌱, 🚀, 💡, 🐧, ⚙️]
One huge benefit of the functions we’ve seen so far is their composability: Because mapping, filtering, taking, dropping, and all their friends return a new collection, it’s easy to just take that result, and immediately use it as an argument for the next collection function, turning collection into collection into collection.
However, we should keep in mind that chaining a number of these functions together means we generate a bunch of intermediate collections. Now, this isn’t going to set your computer on fire immediately, but it is still something to be aware of, especially when you work with very large collections. For this case, Kotlin has a few aces up its sleeve as well, called sequences, but we will dive into those at a later point.
Aggregating collections: sums, averages, minimums, maximums, and counting
Once we’re done transforming our data, we might want to get a single result value out of it. If we have a collection of numerical values like integers or doubles, we get some nice functions called average
and sum
out of the box, which help us calculate those values.
val randomNumbers = listOf(3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 6)
println(randomNumbers.average())
// 4.09090909090909091
println(randomNumbers.sum())
// 45
In some situations (...or, we might say, sum situations...), we have a collection of more complex objects, and want to still add them up somehow, based on their properties. Of course, we could first use the map
function to obtain a collection containing only numbers – but by using the sumOf
function, we can do all of this in a single function call: we can pass a function that acts as a selector (so a function that gives us whatever number we want to associate with the element) and sumOf
will use the result of that selector function to add up all our elements.
val randomNames = listOf("Dallas", "Kane", "Ripley", "Lambert")
val cumulativeLength = randomNames.sumOf { it.length }
println(cumulativeLength)
// 23
If we’re only interested in the greatest or smallest value contained in our collection of numbers, we can use the maxOrNull
and minOrNull
functions.
println(randomNumbers.minOrNull())
// 9
println(randomNumbers.maxOrNull())
// 1
And just like sumBy
, we have the sibling functions maxOf
and minOf
, where we once again pass a selector function, which is going to be used to determine the maximum or minimum of a collection.
val longestName = randomNames.maxOf { it.length }
println(longestName)
// 7
val shortestName = randomNames.minOf { it.length }
println(shortestName)
// 4
If we just care about the number of elements contained in our collection, we can use the count
function – either without any parameters, to just get the number of all elements, or using a predicate. So that’s like filtering the collection first, and then counting the elements. But again, all wrapped into one.
val digits = randomNumbers.count()
println(digits)
// 11
val bigDigits = randomNumbers.count { it > 5 }
println(bigDigits)
// 3
There’s also the powerful joinToString
function, which allows us to turn all elements of our collection into a string, complete with a metric ton of customization options like separators, prefixes and postfixes, limits or a placeholder if you have more elements than what your specified limit allows. And even joinToString
accepts a transformation function, once again, so you don’t need to do some kind of separate mapping beforehand, it’s all built in. Truly powerful stuff to create a string from a collection.
val str = randomNumbers.joinToString (
separator = "-",
prefix = "pi://",
limit = 5
) {
"[$it]"
}
println(str)
// pi://[3]-[1]-[4]-[1]-[5]-...
If you want to refresh what kind of magic we can do with Kotlin strings, watch the Kotlin Standard Library episode that takes us into the depth of strings!
More collection goodness, coming soon!
That concludes our overview of Kotlin collections!
Next, we’re going to step up our game even more, and will take a look at some advanced collection functionality. Some of the functions we’ve seen today actually have some additional variants to them, which are worth an introduction. There’s also the whole world of modifying collections. Plus, each type of collection we’ve seen, Lists, Sets, and Maps, all have their own specialized functionality as well, We’re in for a whole bunch more Kotlin collection content!
Make sure you don’t miss it! To get reminded when new content is released, follow us here on dev.to/kotlin, and make sure to follow me on Twitter @sebi_io.
Also, make sure to find the subscribe button and notification bell on our YouTube channel!
If you don’t want to wait until that episode comes out, there’s only one solution! It’s time to go and explore some more Kotlin!
Top comments (0)