Skip to content

Latest commit

 

History

History
503 lines (333 loc) · 33 KB

File metadata and controls

503 lines (333 loc) · 33 KB

Chapter 5: Collections

Collections are a core part of programming. We keep track of our data by organizing it into lists, sets, maps, and other data structures. Then we manipulate these structures in various ways: we look up, sort, group and transform their elements. We merge, intersect, and subtract their contents.

Kotlin improves on the collection handling of Java in two major ways:

  • It defines its own, well-structured collection types, while still being fully interoperable with Java.
  • The Standard Library provides an immense amount of easy-to-use and powerful collection operations.

Let's see how these are achieved.

Collection types

While you can use all the collection types of the JDK directly when working in Kotlin, it is discouraged. Kotlin's original and primary target is the JVM, but it's a multiplatform language, supporting additional targets such as native or Webassembly.

This is one of the reasons why Kotlin introduces its own collection interfaces, which are independent of the platform you're running on. If you're using Kotlin on the JVM, the implementations of the collections you're using will still likely be the JDK classes, such as java.util.ArrayList or java.util.HashSet. These are not reimplemented by the Kotlin Standard Library, which has some great benefits:

  • These are well-tested, performant implementations, which are maintained anyway.
  • Using the exact same classes makes interop with Java a breeze, as you can pass instances of them back and forth without having to perform conversions or mapping of any kind.

All Kotlin does is introduce its own collection semantics over these existing implementations, in the form of interfaces in the Standard Library. A small bit of compiler magic makes it so that these interfaces are implemented by the existing JDK classes as well.

Take the example of lists - a dynamically resizable, ordered collection. In Java, there is a java.util.List interface which defines what all lists should be capable of doing, and how they are to be used. Most of the time when we need a list in Java, we create java.util.ArrayList instances without thinking twice about it. We might also require ArrayList<Whatever> instances as function parameters, and return ArrayList<Something> types, even though being backed by an array is just an implementation detail of the list, which we rarely make use of.

Kotlin introduces two interfaces for each type of collection. A read-only interface, and a mutable (read-write) interface. For lists, these are List and MutableList, respectively.

List defines just a few basic properties and methods, such as size, isEmpty, contains, iterator, and get. Notably, none of the methods defined in this interface allow you to change the contents of the list. All the mutating methods are defined in MutableList, which extends List. This interface has methods such as add, remove, or clear.

Thanks to the aforementioned compiler magic (mapped types), existing Java collections implement these Kotlin interfaces. A java.util.ArrayList is a MutableList, and a java.util.HashSet is a MutableSet. This eases interoperability tremendously. Imagine having to convert collection types every time you cross the language boundaries!

An overview of some of Kotlin's collection interfaces

You should use these interfaces by default in your code instead of concrete implementation types, especially in public API:

  • In the case of function parameters, this lets clients provide your function with whatever implementation of the collection they wish to use. If your function can operate on anything that's a List, you should ask for just that interface - no reason to require an ArrayList or LinkedList specifically.
  • If this is a return type, using these interfaces lets you change the specific in the future, without breaking client code. You can promise to just return a MutableList of things, and what implementation backs that list is not exposed to your clients.

If you write code that only relies on types and declarations defined in the Kotlin Standard Library, that code will be easily usable on non-JVM targets. If you reference kotlin.MutableList in your imports, that code can be compiled for all platforms, as there's a Kotlin Standard Library implementation of that interface for each. Whether it maps to an existing class directly (which is what happens on the JVM most of the time), wraps an existing class somehow, or is implemented for Kotlin from scratch, again, shouldn't concern you. But if you refer to java.util.TreeSet in your code, that won't fly for the non-JVM targets, as the Java platform classes won't be available there.

Can you still use classes such as java.util.ArrayList directly, if you're only running your Kotlin code on the JVM? Of course.

  • If you don't see your code going multiplatform in the future, using Java collections directly is perfectly okay.
  • If you need a specific implementation for a List or a Set for performance reasons, sometimes you'll have to use the Java classes directly.

Both of these reasons are fading over time, however. For one, as Kotlin Multiplatform is gaining adoption, it's more and more useful to have code around that's easy to reuse across platforms.

At the same time, the Kotlin Standard library is adding more and more specific collection implementations (such as an array-based list). These often use typealiases on the JVM, but they're platform independent as well: see kotlin.collections.ArrayList or kotlin.collections.HashSet for some examples. These Kotlin-defined types will usually show up first in your IDE's autocompletion results, so you'll find yourself being pushed towards using them instead of the Java imports wherever possible. The same thing goes for most exception types, like IllegalArgumentException.

Typealiased concrete implementations in the Standard Library

Creating collections

If you don't need a specific implementation of collection - which should be the case most of the time - you can use the factory functions of the Standard Library to create them, such as listOf, mapOf, mutableListOf, mutableMapOf, and so on. Each collection has corresponding factory functions, both for read-only and mutable collections.

In the case of lists, this is simple - you provide the list of items that make up the list (or in the case of a mutable one, the initial contents). All of these functions are generic, and they take a variable number of arguments:

val list1: List<Int> = listOf(1, 2, 3)
val list2: MutableList<Int> = mutableListOf(1, 2, 3)
val list3: List<Any> = listOf(1, "two", 3.0)

The types for these declarations in the snippet above are added for educational purposes - normally, they can just be inferred. Note how mixing types with no common supertype will yield a List<Any> in the last example - something you'd usually want to avoid.

Sets are created much the same way, with setOf and mutableSetOf, except sets will deduplicate the elements provided:

val set = setOf(1, 2, 3, 1, 3, 4)
println(set) // [1, 2, 3, 4]

Kotlin collections have nice toString implementations, making their contents easy to print!

Creating maps using factory functions is a slightly more interesting endeavour. Here's a map of player names to scores:

val scores = mutableMapOf(
    "Jim" to 2450,
    "Claire" to 1050,
    "Amanda" to 3700,
)

This syntax seems a little magical. to looks like a special keyword which is to be used for creating a Map entry. However, nothing in this piece of code is magic. Here's the signature of the mutableMapOf function:

public fun <K, V> mutableMapOf(vararg pairs: Pair<K, V>): MutableMap<K, V>

As you can see, it just takes a variable number (vararg) of Pair instances. We'll look at vararg in more detail in just a bit.

Pair and Triple are the only "tuple" types in Kotlin, and they're part of the Standard Library. The language doesn't support arbitrary tuples, as data classes can easily serve the role of tuples, and they come with well-named values, which is generally preferable to property names like first and second.

The syntax "Jim" to 2450 actually creates a Pair<String, Int>. It turns out that to is nothing but a function:

public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)

The infix modifier used here allows a function to be called with an operator-like syntax, dropping the . and () symbols from its call site. This function can either be a member or an extension (meaning it has to be called on something), and must have exactly one parameter.

To sum up: using these factory functions for collections keeps your code more generic, and independent of the concrete underlying implementations. You don't know what specific class the Standard Library mutableListOf function will instantiate for you - and you shouldn't care. All you need to know is that it will be an object that satisfies the contract of the MutableList interface.

A note on immutability

Like with var and val, you'll often see that official recommendations, the IDE itself, and the Standard Library APIs are pushing you towards the read-only variants of these interfaces in various ways. You should choose these by default unless you actually need mutability.

Kotlin's read-only collection interfaces are not to be confused with truly immutable collections. Even though you might have a read-only view of a collection through a read-only interface, someone else might still have a reference to it as a more concrete, potentially mutable type. For example, having a List reference does not guarantee that it will always hold the same elements in the same positions, it just means you can't change its contents.

This is especially true when interoperating with Java code. Whether you're giving a List or a MutableList to a Java client, they'll see it as a java.util.List, and see all the mutating methods on that interface. Malicious Kotlin clients can also easily attempt to cast a List given to them as a MutableList, and then modify its contents if the cast succeeds.

Truly immutable collections for Kotlin are currently a work in progress.

Arrays

Arrays are fixed-size, ordered containers. Their contents are contiguous in memory, which yields O(1) (constant time) access to an element at any index, as well as efficient iteration for arrays of primitives. They are a rather special type in Java, which also affects their design and API in Kotlin.

Arrays for reference types are represented by the Array class in Kotlin. For a Java String[], we can create an Array<String> in Kotlin. How? With factory functions, of course.

val colours: Array<String> = arrayOf("green", "yellow", "purple")

All arrays are mutable in Kotlin. They are fixed-size, so you can't add new items or remove items from them, but you can change what values they hold at each index.

What about non-reference types? We don't have the distinction between primitives and boxed types for basic types such as Int in Kotlin. What happens when we create an array of those?

val primes: Array<Int> = arrayOf(2, 3, 5, 7, 11, 13, 17, 19)

We end up with an Array all the same, which, with the Int type parameter, is the equivalent of an Integer[]. This means that every value stored in the array is boxed into an Integer instance. We lose fast iteration here, as the array stores just the references to the values (pointers, if you will), instead of the actual values themselves. When fetching a value, we get to a reference of an Integer very quickly (in constant time), but then we have to resolve the Integer instance that it points to, somewhere in the heap.

Array of Int in memory

To avoid this, for each of the primitive types, Kotlin has corresponding primitive array types: IntArray, DoubleArray, and so on. These come with their own factory methods:

val primes: IntArray = intArrayOf(2, 3, 5, 7, 11, 13, 17, 19)

These are the equivalents of primitive arrays such as int[], and should be used when possible.

Having switched from an Array<Int> to an IntArray, we are now storing the values in the array contiguously in memory, got rid of a layer of indirection, and can iterate blazing fast over these values.

IntArray in memory

More, different ways of creating arrays

There are a couple more notable ways of creating arrays quickly, without having to list all the initial elements. One of them is the following Array constructor:

public inline constructor(size: Int, init: (Int) -> T)

You might have noticed that there are a lot of explicit public visibility modifiers in the Standard Library, even though public is the default visibility in the language. This is an official recommendation and best practice for library authors: mark your public API explicitly public. Turning on explicit API mode can help you keep this in mind.

This constructor takes the size of the array to create, and then a lambda which will create the element of the array for the given index. For example, if we wanted an array that contained the numbers 0 to 99, as strings:

val numbers: Array<String> = Array(100) { i -> i.toString() }

Why can't you initialize the array just with a size? You can certainly do so in Java:

String[] strings = new String[200];

An array like that contains all null values in Java when created. This can't work with an Array<String> in Kotlin, as it's guaranteed that all of its elements are non-null String instances! On the other hand, you can create an Array<String?> very quickly, using arrayOfNulls:

val strings: Array<String?> = arrayOfNulls(200)

In this case, you don't have to provide the values of the elements, as they will all be null by default, which is allowed by this type.

Note the difference between Array<String?> and Array<String>?. In the former case, you definitely have an Array reference, but any of its elements may be null. With the latter, you may or may not have an Array reference, but if you do, it only contains non-null String instances. Finally, there's also Array<String?>?, which you can surely figure out from here.

There is one more case where you can you allocate an array just by specifying its size - for primitive arrays. In this case, you can use a constructor that takes just a single parameter:

val x = IntArray(20)

All the elements of this array will be initialized to 0 (or false, for a BooleanArray).

vararg

You've seen vararg being used in the factory functions for various collections. When a parameter is marked with vararg, any number of values (including zero) can be passed in for that parameter. These values will be collected into an array, which can then be iterated inside the function.

For example, within the following function, words has the type Array<String>:

fun printAll(vararg words: String) {
    for (word in words) {
        println(word)
    }
}

printAll("Nitwit", "Blubber", "Oddment", "Tweak")

On the call site, if you already have your values in an array, you can pass them in as separate values of a vararg parameter using the spread operator *:

val words = arrayOf("Nitwit", "Blubber", "Oddment", "Tweak")
printAll(*words, "Thank", "You")

You can even mix and match regular values and spread values inside the parameter list as you like. Note that the spread operator only works for arrays. It unfortunately doesn't work on the - much more commonly used - List type.

Accessing elements

Once you have a collection, you can call its methods to read and write its contents, just like with Java collections. However, Kotlin also comes with some conveniences on top of regular method calls to make working with collections simpler.

To check if a collection contains an element, use the in keyword (and !in for the inverse):

val primes = listOf(2, 3, 5, 7, 11, 13, 17, 19, 23)

if (4 in primes) {
    println("4 is a prime!")
}
if (4 !in primes) {
    println("4 is not a prime!")
}

You might remember that the same operator works with ranges as well. For example, 5 in 1..10 would evaluate to true.

When working with ordered collections like lists of arrays, you can use indexing syntax [] to access an element at a given index (instead of calling get and set functions):

val names = mutableListOf("Alex", "Bola", "Charlie")
names[0] = "Amal"
println(names[0]) // Amal

Collection processing

Perhaps nothing shows off the power of extensions, higher order functions, and inline functions more than the collection extensions of the Kotlin Standard Library.

Note that a lot of these are available on the Iterable type, which many collection types implement, but we'll use lists in the examples for simplicity.

Take a common operation that you perform all the time with collections: filtering them based on some condition. With Java habits, when you need to filter for, say, words shorter than 5 characters from a list, this is the code you'd likely write:

fun filterShortWords(words: List<String>): List<String> {
    val result: MutableList<String> = mutableListOf()
    for (word in words) {
        if (word.length < 5) {
            result.add(word)
        }
    }
    return result
}

What if you had a list of numbers, and needed just the odd ones? You might do this:

fun filterOddNumbers(numbers: List<Int>): List<Int> {
    val result: MutableList<Int> = mutableListOf()
    for (number in numbers) {
        if (number % 2 == 1) {
            result.add(number)
        }
    }
    return result
}

This is tedious code to write, and every time someone reads it, they need to make sure that it actually does what it looks like it does at first glance. There could easily be a small unexpected detail somewhere in there.

With the Kotlin language features mentioned above, we can encapsulate all of this filtering logic into a higher-order function. This function can receive both the list of elements and the boolean expression to evaluate on each of them to decide whether they'll be added to the list - in the form of a function parameter:

inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
    val result = mutableListOf<T>()
    for (element in this) {
        if (predicate(element)) {
            result.add(element)
        }
    }
    return result
}

This function can then be reused for all your filtering needs, without having to reimplement the filtering logic itself. All you need is the list of items and the rule for what to filter:

words.filter({ word: String -> word.length < 5 })

Remember, this lambda syntax can be simplified in several steps:

words.filter({ word -> word.length < 5 })
words.filter({ it.length < 5 })
words.filter { it.length < 5 }

Note how the type of the lambda's parameter is now being inferred by the generic type parameter of the receiver collection, which is already known (to be a String, in this example).

And if you now need to filter a list of numbers for the odd ones, this is all the code you'll write:

val numbers = listOf(6, 27, 496, 8128)
numbers.filter { it % 2 == 1 }

The filter function is part of the Standard Library.

More examples

There are far too many functions for collection processing in the Standard Library to look at them all, but here are a few more commonly used ones.

forEach is a simple alternative for a for loop. The lambda passed to it is invoked for each element:

val numbers = listOf(1, 2, 3, 4, 5)
numbers.forEach {
    println(it)
}

The map function allows you to transform each element in your list into some other element. Given a list of A, the function receives a function parameter of type (A) -> B, and gives you a list of B. This one-to-one mapping is perhaps the most common collection transformation.

You can use this to double each value in a list, to convert the type of each value, or to extract a piece of data from a larger object:

val numbers = listOf(1, 2, 3, 4, 5, 6)
val doubled = numbers.map { it * 2 } // 2, 4, 6, 8, 10, 12
val doubles = numbers.map { it.toDouble() } // 1.0, 2.0, ...

val people: List<Person> = getPeople()
val names: List<String> = people.map { it.name }

flatMap is a similar function, except it performs a transformation of (A) -> List<B>, and then concatenates all the lists returned for each element into a single, flattened List<B>:

val words = listOf("hello", "there")

val result = words.flatMap { word: String ->
    word.toList() // returns a List<Char>
}

println(result) // [h, e, l, l, o, t, h, e, r, e]

any tells you whether there's an element in the collection matching a predicate, while all tells you whether a predicate's true for all the elements. Both of these functions short-circuit their evaluation without going through all elements if their result can already be determined.

val names = listOf("Jane", "Kyra", "Leah")
val anyK = names.any { it.startsWith("K") } // true, succeeds at Kyra

val numbers = listOf(7, 2, 8, 3, 7, 1)
val allOdd = numbers.all { it % 2 == 1 } // false, fails at 2

partition allows you to split a list in two based on a predicate, with elements matching a predicate ending up in the first list, and the rest in the second. These lists are returned in a Pair, so you can easily destructure the returned value into the two lists:

val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
val (odds, evens) = numbers.partition { it % 2 == 1 }

println(odds) // [1, 3, 5, 7, 9]
println(evens) // [2, 4, 6, 8, 10]

groupBy groups values of a collection based on a key, into several lists. For example, you can group people's names based on their length like so:

val names = listOf("Alex", "Sam", "Teo", "James", "Grey")
val groups: Map<Int, List<String>> = names.groupBy { it.length }

println(groups) // {4=[Alex, Grey], 3=[Sam, Teo], 5=[James]}

chunked and windowed are useful for processing parts of a collection. chunked slices up a list into smaller chunks of a given size. windowed gives you the values of a collection as seen through a sliding window (sliding over by one value at a time by default).

val dailyTemperatures = listOf(21, 30, 26, 29, 29, 26, 30, 23, 27, 24, 23, 25, 30, 28)

val weeklyValues: List<List<Int>> = dailyTemperatures.chunked(7)
println(weeklyValues) // [[21, 30, 26, 29, 29, 26, 30], [23, 27, 24, 23, 25, 30, 28]]

val sevenDayWindows: List<List<Int>> = dailyTemperatures.windowed(7)
println(sevenDayWindows) // [[21, 30, 26, 29, 29, 26, 30], [30, 26, 29, 29, 26, 30, 23], ..., [23, 27, 24, 23, 25, 30, 28]]

Try implementing some of these operations yourself! This is great practice for many Kotlin features, and gives you a better understanding of how these functions will behave. You can also look at their actual implementations by navigating to them in the IDE.

You can find tons of additional examples of list operations in the Kotlin Lists 2021 video on the official Kotlin YouTube channel.


The real power of these operators becomes apparent when you start chaining them. Let's take an example where we have a list of words and want to find the length of the first one that would be sorted after the word kite alphabetically, and has no more than 4 letters. Contrived, but not too contrived. We do similar things in real business logic all the time.

Here's the chain of operations we'd need to perform:

val words = listOf("camel", "pizza", "mug", "box", "shirt")

val result = words
        .filter { it > "kite" }     // the ones after "kite"
        .map { it.length }          // the length of those
        .filter { it <= 4 }         // the ones with <= 4 length
        .first()                    // the first one of these

first will throw an exception if there's no first element. firstOrNull would be a non-throwing alternative to use.

Sequences

Chaining collections can get expensive if your collections are very large, or you're performing lots of operations on them. The operations on iterables and lists are evaluated eagerly, meaning that for each step of the chain, a new list is allocated (for the result), the input is processed, filling the result list, and then that result list is returned. Each step of the way on a chain of operations, an intermediate list is created, only to be thrown away once it's processed as the input of the next step.

This isn't necessarily an issue. Garbage collectors are quite good at getting rid of short-lived objects in memory. However, there are situations where another way of processing data comes in handy. Enter Kotlin's Sequence type.

The definition of Sequence is incredibly simple:

public interface Sequence<T> {
    public operator fun iterator(): Iterator<T>
}

Remember the idea of extension-oriented design from the previous chapter? The definition of the Sequence type is a prime example of that approach. Everything you call on a Sequence will be an extension!

To transform a regular collection to a sequence, use asSequence:

val wordSequence: Sequence<String> = words.asSequence()

Nearly all the operations available for Iterable or List are available on the Sequence type as well. The difference is in how they are implemented. Operations on sequences are lazy, meaning they're only evaluated when their result is required. map, filter, and similar operations don't return materialized, already computed results. Instead, they return a new sequence, which represents that transformed state of the data:

val result: Sequence<Int> = words.asSequence()
        .filter { it > "kite" }     // the ones after "kite"
        .map { it.length }          // the length of those
        .filter { it <= 4 }         // the ones with <= 4 length

This code, so far, processes no data, and it executes nearly instantly, regardless of the size of the initial collection. It just prepares a pipeline of sorts, which is now waiting to be executed.

A Sequence will process its data when we use a terminal operator on it. These have return types which require the existence of the actual, processed, materialized result of the operations. Examples of this would be first or toList:

val words = listOf("camel", "pizza", "mug", "box", "shirt")
val result: Int = words.asSequence()
        .filter { it > "kite" }     // the ones after "kite"
        .map { it.length }          // the length of those
        .filter { it <= 4 }         // the ones with <= 4 length
        .first()

When running the words through this sequence, the processing happens word-by-word instead of operation-by-operation.

  • "camel" is tested in the first filter, and it doesn't pass.
  • "pizza" passes the first filter, gets mapped to 5, and doesn't pass the second filter.
  • "mug" passes the first filter, gets mapped to 3, passes the second filter, and is selected as the first element. The first operator returns, and no more words are processed!

Hopefully you have a feel for how lazy processing can avoid unnecessary work and intermediate collections in many cases. For a great visualization of how sequence processing happens, read this article.

So... When should you use sequences instead of regular collections? Well, you should consider them if you are processing large amounts of data, and if you have lots of operations. How large the data and how numerous the operations have to be for sequences to be more performant or more efficient is very hard to tell. The only way to make a smart decision is to benchmark your concrete use case with real amounts of data, and see whether it's worth it.

Infinite sequences

There is one thing that sequences are uniquely capable of, thanks to their lazy nature. They can represent infinite sequences of data.

For example, the generateSequence function takes an initial seed value and a function that can produce the next element of the sequence based on the previous one. Here's a simple piece of code that prints the first 20 powers of two:

generateSequence(1) { x -> x * 2 }
        .take(20)
        .forEach(::println)

The Sequence returned by generateSequence is infinite, and will keep producing values as needed (though with the Int type, this example will overflow quickly).

Note the use of a method reference as the parameter of the forEach function. Sometimes lambdas are not the most convenient or most efficient way of using collection extensions.

Summary

Kotlin's collection APIs make a distinction between read-only and mutable types, as part of the language's push towards less mutability.

One of the strengths of Kotlin is its extensive collection processing API, which eliminates the need to perform many common collection related tasks manually. Chaining collection operations is a common way of doing advanced data processing with the language.

Sequences offer lazy computations, which can come with great performance benefits in the right situation. They may also be infinitely long.

Sources