article banner (priority)

Collection processing in Kotlin: Ending

This is a chapter from the book Functional Kotlin. You can find it on LeanPub or Amazon.

When we need to transform an iterable into a string, and toString is not enough, we use the joinToString function. In its simplest form, it just presents elements one after another, separated with commas. However, joinToString is highly customisable with optional arguments:

  • separator (", " by default) - decides what should be between the values in the produced string.
  • prefix ("" by default) and postfix ("" by default) - decide what should be at the beginning and at the end of the string. prefix and postfix are also displayed for an empty collection.
  • limit (-1 by default, which means no limit) and truncated ("..." by default) - limit decides how many elements can be displayed. Once the limit is reached, truncated is shown instead of the rest of the elements.
  • transform (toString by default) - decides how each element should be transformed to String.
fun main() { val names = listOf("Maja", "Norbert", "Ola") println(names.joinToString()) // Maja, Norbert, Ola println(names.joinToString { it.uppercase() }) // MAJA, NORBERT, OLA println(names.joinToString(separator = "; ")) // Maja; Norbert; Ola println(names.joinToString(limit = 2)) // Maja, Norbert, ... println(names.joinToString(limit = 2, truncated = "etc.")) // Maja, Norbert, etc. println( names.joinToString( prefix = "{names=[", postfix = "]}" ) ) // {names=[Maja, Norbert, Ola]} }

Map, Set and String processing

Most of the presented functions are extensions on either Collection or on Iterable, therefore they can be used not only on lists but also on sets. However, in addition to List and Set, there is also the third most important data structure: Map. It does not implement Collection or Iterable, so it needs custom collection processing functions. It has them! Most of the functions we have covered so far are also defined for the Map interface.

The biggest difference between collection and map processing methods stems from the fact that elements in maps are represented by both a key and a value. So, in functional arguments (predicates, transformations, selectors), instead of operating on values we operate on entries (the Map.Entry interface represents both a key and a value). When values are transformed (like in map or flatMap), the result type is List, unless we explicitly transform just keys or values (like in mapValues or mapKeys).

data class User(val id: Int, val name: String) fun main() { val names: Map<Int, String> = mapOf(0 to "Alex", 1 to "Ben") println(names) // {0=Alex, 1=Ben} val users: List<User> = names .map { User(it.key, it.value) } println(users) // [User(id=0, name=Alex), User(id=1, name=Ben)] val usersById: Map<Int, User> = users .associateBy { it.id } println(usersById) // {0=User(id=0, name=Alex), 1=User(id=1, name=Ben)} val namesById: Map<Int, String> = usersById .mapValues { it.value.name } println(names) // {0=Alex, 1=Ben} val usersByName: Map<String, User> = usersById .mapKeys { it.value.name } println(usersByName) // {Alex=User(id=0, name=Alex), Ben=User(id=1, name=Ben)} }

String is another important type. It is considered a collection of characters, but it does not implement Iterable or Collection. However, to support string processing, most collection processing functions are also implemented for String. However, String also supports many other operations, but these are better explained in the third part of the Kotlin for developers series: Advanced Kotlin.

Using them all together

Collection processing functions are often connected together, thus forming a flow that explains how a collection is processed step by step. Let's see a few practical examples. I will assume that we are writing an application for a university.

Let's assume that we have a list of students, and we need to find those who deserve internships. For this, students need to pass each semester and have an average grade above 4.0. Out of these students, we need to find the 10 with the highest grade and sort them in official order. In the end, we need to form a list that can be printed. This is how this processing could be implemented:

students.filter { it.passing && it.averageGrade > 4.0 } .sortByDescending { it.averageGrade } .take(10) .sortedWith(compareBy({ it.surname }, { it.name })) .joinToString(separator = "\n") { "${it.name} ${it.surname}" }

Let's complicate this example a little by assuming that we need to assign the students to the appropriate internship amount. Once the students are sorted, we can zip them with the internships we prepared for the best students.

students.filter { it.passing && it.averageGrade > 4.0 } .sortedByDescending { it.averageGrade } .zip(INTERNSHIPS) .sortedWith( compareBy( { it.first.surname }, { it.first.name } ) ) .joinToString(separator = "\n") { (student, internship) -> "${student.name} ${student.surname}, $$internship" } private val INTERNSHIPS = List(5) { 5_000 } + List(10) { 3_000 }

To randomly divide the students into groups, you can use shuffled and chunked.

students.shuffled() .chunked(GROUP_SIZE)

To find the student with the highest result in each group, you can use groupBy and maxByOrNull.

students.groupBy { it.group } .map { it.values.maxByOrNull { it.result } }

These are just a few examples, but I’m sure you can find lots of great examples of collection processing in most bigger Kotlin projects. The collection processing operations have expanded the language capabilities such that Data Science, traditionally the realm of Python, and competitive coding challenges are very approachable and natural in Kotlin. Its usage is universal and inter-domain, and I hope you will find the methods we have covered in this chapter useful.