Exploring Kotlin Lists in 2021

sebastianaigner

Sebastian Aigner

Posted on July 8, 2021

Exploring Kotlin Lists in 2021

This blog post accompanies a video from our YouTube series which you can find on our Kotlin YouTube channel, or watch here directly!

Today, we're talking all about lists! Lists are the most popular collection type in Kotlin for a good reason, and we’ll find out why together.

Lists

What’s a list?

If you've written Kotlin code before, you've definitely seen a list – they're collections of ordered elements, where each element is accessible via an index. As such, they're one of the basic building blocks for a lot of Kotlin code.

Creating lists

If you’re creating lists on your own, you’re most likely using the listOf function, which takes a variable number of arguments, and those become the elements of your list. Even in this blog post series, we've created a list like that about a hundred times:

listOf(1, 2, 3, 4, 5)
// [1, 2, 3, 4, 5]
Enter fullscreen mode Exit fullscreen mode

A little lesser known is the ability to create lists via the List constructor function. Here, we pass two parameters – the size of the list, and an init function that creates each of the elements in our list. That function we pass gets the element index as its parameter, which we can use to adjust the item content:

List(5) { idx -> "No. $idx" }
// [No. 0, No. 1, No. 2, No. 3, No. 4]
Enter fullscreen mode Exit fullscreen mode

Of course, lists can come from other places as well: types like collections, iterables, and others often feature a toList method.

For example, in the case of a string, we get a list of its characters:

"word-salad".toList()
// [w, o, r, d, -, s, a, l, a, d]
Enter fullscreen mode Exit fullscreen mode

Given a map of placements and the associated medals, we can call toList on that to get a list of key-value pairs:

mapOf(
    1 to "Gold",
    2 to "Silver",
    3 to "Bronze"
).toList()
// [(1, Gold), (2, Silver), (3, Bronze)]
Enter fullscreen mode Exit fullscreen mode

Sequences, ranges, and progressions behave similarly. They materialize their values, and put them in a list when calling toList. As an example, we can consider a random sequence of numbers, or the inclusive integer range from zero to ten:

generateSequence {
    Random.nextInt(100).takeIf { it > 30 }
}.toList()
// [73, 77, 69, 79, 71, 64]

(0..10).toList()
// [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Enter fullscreen mode Exit fullscreen mode

An extra case worth mentioning is calling toList on something that already is a list. This creates brand-new copy of the original list. We can see this in the following example, where we create a mutable list with a few numbers. By calling toList, we obtain a new working copy:

val list = mutableListOf(1, 2, 3)
val otherList = list.toList()

list[0] = 5
println(list)
// [5, 2, 3]

println(otherList)
// [1, 2, 3]
Enter fullscreen mode Exit fullscreen mode

As we can see, when the original list is changed, the working copy we just created does not contain any of the changes applied to the original collection.

Accessing list items

To get items out of our lists, we have multiple options. The most basic way of doing so is using the get function, together with an index:

val myList = listOf("🍔", "🌭", "🍕")
myList.get(1) // 🌭
Enter fullscreen mode Exit fullscreen mode

But if you ever type out .get manually, you’ll see that IntelliJ IDEA already gives you the helpful hint to use some much more popular syntactic sugar for it – the indexed access operator, denoted by the brackets with an index:

myList[1] // 🌭
Enter fullscreen mode Exit fullscreen mode

There are also some additional flavors of the get function which we can explore. Two of those that come to mind are getOrElse and getOrNull. They help us handle cases where we might be accessing an index that falls out of bounds (which can either be a negative index, or an index that’s larger than the last index in our collection.)

Using the default indexed access causes an exception when provided a parameter that's out of bounds:

myList[3]
// Index 3 out of bounds for length 3
Enter fullscreen mode Exit fullscreen mode

We can use getOrNull to short-circuit our return value to null. Alternatively, we can use getOrElse to compute a default value to be used instead. The default value is computed based on a passed lambda, which also receives the index:

val myList = listOf("🍔", "🌭", "🍕")
myList.getOrNull(3)
// null

myList.getOrElse(3) {
    println("There's no index $it!")
    "😔"
}
// There's no index 3!
// 😔
Enter fullscreen mode Exit fullscreen mode

These special functions are only necessary to work with indexes that might fall out of bounds, though. Nullability, for example, is handled the same way as you would in any other situation in Kotlin: using the power of the Elvis operator, smart-casts and friends.

val listOfNullableItems = listOf(1, 2, null, 4)
val x: Int = listOfNullableItems[0] ?: 0
Enter fullscreen mode Exit fullscreen mode

Slicing

Of course, we can go beyond getting individual items out of our list. Because a list is a collection like any other, we have access to the same take and drop functions that were introduced in the Diving into Kotlin collections post.

But lists have a special way of retrieving multiple items - the slice function!

When we give this function a bunch of indexes, it returns the elements at those places in our collection. In this example, we’re passing a list with index 0, 2, 4, and get those items from our list of letters:

val myList = listOf("a", "b", "c", "d", "e")
myList.slice(listOf(0, 2, 4))
// [a, c, e]
Enter fullscreen mode Exit fullscreen mode

Instead of writing out all the indices by hand, we could also use IntRanges or progressions to specify the indexes. For example, we could request “all items from 0 through 3”, or specify a custom step-size of 2. We could even pull out some items in reverse order, if we create a progression that uses downTo:

myList.slice(0..3)
// [a, b, c, d]

myList.slice(0..myList.lastIndex step 2)
// [a, c, e]

myList.slice(2 downTo 0)
// [c, b, a]
Enter fullscreen mode Exit fullscreen mode

As you may suspect, this list of list features is not quite exhaustive – as always, there’s some more to explore even on this subject. But let’s put that on the back burner for a bit, and move on to a special kind of list – it's time to talk about mutable lists!

Mutable Lists

What's so special about mutable lists? Well, you can mutate them! That, of course, doesn’t mean that these lists will turn into zombies (🧟‍♂️), but that you can change their content. If we consult an excerpt of a class hierarchy, we can see that MutableList specializes List, meaning everything we’ve learned about lists so far also works for their mutable counterpart, plus some extra functionality.

list-specialization

It's precisely that extra functionality that we’re interested in right now!

Creating mutable lists

Once again, mutable lists are commonly created via the mutableListOf function, with a bunch of values as arguments. And, wherever you were able to find a toList method, as discussed previously, you’ll probably also find a toMutableList. That also includes other lists and mutable lists – where you’ll get a fresh copy when calling toMutableList:

mutableListOf(1, 2, 3)
// [1, 2, 3]

(0..10).toMutableList()
// [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

listOf(1, 2, 3).toMutableList()
// [1, 2, 3]
Enter fullscreen mode Exit fullscreen mode

Add / Remove / Update

Let's move on to the core of this subject – the ability to change content. That starts with adding something to the collection. If we want to add an extra number to the end of our mutable list we can do so via the add function, or by using the += operator shorthand, both of which append an item to the end of the list.

If we know where in the collection we want our item to go, the add function also accepts an index, which inserts the new element at that position and moves the surrounding elements to accommodate it. In the same way, we can also add a whole other collection to our mutable list:

val m = mutableListOf(1, 2, 3)
m.add(4)
m += 4
println(m)
// [1, 2, 3, 4, 4]

m.add(2, 10)
println(m)
// [1, 2, 10, 3, 4, 4]

m += listOf(5, 6, 7)
println(m)
// [1, 2, 10, 3, 4, 4, 5, 6, 7]
Enter fullscreen mode Exit fullscreen mode

We’re of course not constrained to just adding elements to our list – we can also remove them. If we know what element we want to get rid of, we can do that via the remove function or the -= operator shorthand, which removes from our collection a single instance of the element we provide. In this example, after calling -= and remove, we got rid of two of the 3s in our original collection – because each invocation removed one of them.

Alternatively, we can also pass the -= operator a collection of elements. In this case, the operator acts as a shorthand for the removeAll function. Here, it looks at every element in the collection we pass, and removes all instances of them in our original, mutable collection. (This is an important distinction to make!) So, by passing 1 and 4 as a collection, we remove all instances of those numbers from our mutable list, and we’re left with only 2 and 3 at the end.

val m = mutableListOf(1, 2, 3, 3, 3, 4, 4, 4)
m -= 3
m.remove(3)
println(m)
// [1, 2, 3, 4, 4, 4]

m -= listOf(1, 4)
println(m)
// [2, 3]
Enter fullscreen mode Exit fullscreen mode

If we know the index where we want to kick an item out, we use the removeAt function instead. For example, we could remove the second element in our list, which resides at index 1.

val m = mutableListOf(1, 2, 3, 3, 3)
m.removeAt(1)
println(m)
// [1, 3, 3, 3]
Enter fullscreen mode Exit fullscreen mode

To update an item, we most commonly use the indexed access operator – so the brackets – together with an assignment. That one calls the set function with that index and element under the hood, and switches out the item at the specified index – in this case, trading a "b" for an "a".

val m = mutableListOf("a", "b", "c", "d", "e")
m[1] = "a"
println(m)
// [a, a, c, d, e]
Enter fullscreen mode Exit fullscreen mode

Fill and Clear

In certain situations, we might want to turn all elements of our list into the same element – like zeroing out a buffer before reusing it. This is something we can do using the fill function, which replaces each element with the same value we specify. If we look at a list of fruits, for example, and suddenly realize that all of them are really just sugar, we use fill to replace them with candy (🍬). While that metaphor may not be entirely scientifically accurate, it's tasty nonetheless!

And when we want to wipe our collection clean, the clear function can help with removing all elements from a collection – in our case, getting rid of all the candy:

val fruits = mutableListOf("🍉", "🍊", "🥝")
// wait, it's all sugar?
fruits.fill("🍬")

println(fruits)
// [🍬, 🍬, 🍬]

// ... nom nom
fruits.clear()

println(fruits)
// []
Enter fullscreen mode Exit fullscreen mode

Perhaps unsurprisingly, mutable lists grow and shrink automatically to accommodate all your items, so you can have an arbitrary number of elements in your collection. This might be obvious, but it’s so darn convenient, so I figured I’d mention it. The things we take for granted!

In-place modifications

Thinking back to some of the previous entries of this series, we’ve seen a number of neat functions which we wouldn’t want to miss for mutable collections either – things like sorted, shuffled, and reversed. However, those don’t modify the original collection.

Luckily for us, these functions also have a mutable counterpart. So, when we want to sort, shuffle, or reverse a mutable list in place – instead of creating a new, separate copy with the effects applied – we use the sort() instead of sorted(), shuffle() instead of shuffled(), and reverse() instead of reversed() functions:

val list = listOf(3, 1, 4, 1, 5, 9)
list.shuffled()
list.sorted()
list.reversed()

println(list)
// [3, 1, 4, 1, 5, 9]

val m = list.toMutableList()
m.shuffle()
println(m)
// [5, 1, 1, 3, 4, 9]

m.sort()
println(m)
// [1, 1, 3, 4, 5, 9]

m.reverse()
println(m)
// [9, 5, 4, 3, 1, 1]
Enter fullscreen mode Exit fullscreen mode

Mutable lists also offer the possibility to remove or keep all elements that fulfill a certain predicate. The removeAll function can remove all elements that match the predicate we specify. Let’s say we’re not a fan of small numbers in our collection, and only want to keep numbers that are 5 or above – removeAll helps us do exactly that.

val numbers = mutableListOf(3, 1, 4, 1, 5, 9)
numbers.removeAll { it < 5 }
println(numbers)
// [5, 9]
Enter fullscreen mode Exit fullscreen mode

The retainAll function is the opposite, and only keeps those elements in the mutable list that match. If we want to retain every character in our collection that is a letter, we do that with the retainAll function:

val letters = mutableListOf('a', 'b', '3', 'd', '5')
letters.retainAll { it.isLetter() }
println(letters)
// [a, b, d]
Enter fullscreen mode Exit fullscreen mode

This might feel a bit familiar to you, and rightfully so, because these are essentially the mutating equivalents of the filter and filterNot functions.

Views on Lists

The last topic on today’s agenda is views on lists. That name already hints at what they allow us to do – they allow us to look at the elements in our list from a different perspective – let’s see what that means.

Let’s assume we have a collection of fruits. To create a view, we can use the subList function, which takes a beginning and end index, which determines which elements should be “visible” in the view. By having a look at an example sublist, we can see that it contains the elements from our original collections based on the indices we specify (with the upper bound being exclusive):

val fruits = mutableListOf("🍉", "🍊", "🥝", "🍏")
val sub = fruits.subList(1, 4)
println(sub)
// [🍊, 🥝, 🍏]
Enter fullscreen mode Exit fullscreen mode

Because this is only a view, and not a copy of our original collection, changes are automatically visible. That means if we change the orange to a banana in the underlying fruits list, then our sublist will reflect that change:

fruits[1] = "🍌"
println(sub)
// [🍌, 🥝, 🍏]
Enter fullscreen mode Exit fullscreen mode

What may be even more interesting is that this sublist is in itself mutable, as well! If we change the green apple in our sublist to a pineapple, and have a look at our original fruits collection again, we see that the change is visible from here as well:

sub[2] = "🍍"
println(fruits)
// [🍉, 🍌, 🥝, 🍍]
Enter fullscreen mode Exit fullscreen mode

Or, we can use the fill function which we’ve learned about earlier to turn an interval inside of our fruit-list back into candy, again:

sub.fill("🍬")
println(fruits)
// [🍉, 🍬, 🍬, 🍬]
Enter fullscreen mode Exit fullscreen mode

To reiterate; all of that works because these aren’t two different collections – there is only one collection, and subList has just given us a different perspective on that list!

An important note on the topic of sublists: They are only well-defined as long as the underlying, original list is not structurally changed. Changes affecting the size of the list, for example, automatically cause any views that were previously returned by invoking subList to have undefined behavior.

For a common case, which is looking at a list backwards, the Kotlin standard library also comes with the asReversed function. It provides a backwards view of the underlying list. Once again, changes made in the view are visible in the original collection, and vice versa. As you can see in the following example, turning the orange into a banana in our original list also changes what we see in the reversed view. Altering it back to a pineapple via our reversed view also alters our original mutable list:

val fruits = mutableListOf("🍉", "🍊", "🥝", "🍏")
val stiurf = fruits.asReversed()

println(stiurf)
// [🍏, 🥝, 🍊, 🍉]

fruits[1] = "🍌"
println(stiurf)
// [🍏, 🥝, 🍌, 🍉]

stiurf[2] = "🍍"
println(fruits)
// [🍉, 🍍, 🥝, 🍏]
Enter fullscreen mode Exit fullscreen mode

These types of “views” are actually available for non-mutable lists, as well, and allow you to pass around different sub-selections of your collections without having to create new copies every time – however, this seemed like a topic that would be nicer to illustrate with the mutable variant, to drive the point home that there really is only one underlying collection.

Outro

With that, we have reached the end of today’s expedition! I hope some of the stuff you’ve seen today is helping you strengthen your understanding of Kotlin lists. When you’re writing Kotlin code the next time, see if you can apply some of the stuff we’ve talked about today – whether it’s slicing a collection, using sub-lists, or handling out-of-bounds situations for lists elegantly with the getOrNull and getOrElse functions.

If you’ve learned something new, and want to see more like this, make sure to follow us here on dev.to, and check out some of the interesting content we publish on our official YouTube channel, as well!

Now, it’s time for all of you to go and explore some more Kotlin! Take care, and see you in the next one!

💖 💪 🙅 🚩
sebastianaigner
Sebastian Aigner

Posted on July 8, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related