DEV Community

loading...
Cover image for Diving into Kotlin collections
Kotlin

Diving into Kotlin collections

sebastianaigner profile image Sebastian Aigner ・12 min read

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

image

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.

image

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
Enter fullscreen mode Exit fullscreen mode

In a more functional style, we can also write this same snippet using the forEach function:

fruits.forEach { fruit ->
    println(fruit)
}

// Apple
// Banana
// Cherry
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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] 
Enter fullscreen mode Exit fullscreen mode

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)]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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!
// [🌱, 🚀, 💡, 🐧, ⚙️]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]-...
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (0)

pic
Editor guide