Working with Collections in Kotlin: Best Practices Inspired by Effective Kotlin
Introduction
Collections are essential for managing groups of data in Kotlin. This guide introduces Kotlin collections, provides practical examples of common operations, and outlines best practices inspired by Effective Kotlin by Marcin Moskala to ensure your code is efficient and maintainable.
We encounter collections in almost every Kotlin application, so mastering them is crucial for writing clean and efficient code, and understanding how to work with collections effectively can significantly enhance your Kotlin programming skills.
Rules of thumb
1. Choose the right collection type
Select the appropriate collection based on your use case: List for ordered elements, Set for unique items, and Map for key-value pairs. Each collection type has its strengths and weaknesses, so understanding their characteristics will help you make informed decisions.
Understanding Kotlin's collection types is crucial:
Collection Type | Characteristics | Ideal Use Cases |
---|---|---|
List | Ordered, allows duplicates | Indexed access, preserving insertion order |
Set | Unordered, unique elements only | Checking existence, removing duplicates |
Map | Key-value pairs | Quick lookups based on unique keys |
2. Prefer immutability (but not always!)
Always use immutable collections by default. Mutable collections should be internal and encapsulated. This reduces the risk of unintended modifications and makes your code more predictable. If you find yourself needing to modify a collection, consider whether you can achieve the same result with a new immutable collection.
Prefer immutable collections (listOf
, setOf
, mapOf
) to enhance predictability. Defaulting to mutable collections can feel convenient but can lead to unintended side effects. Examples of unfortunate side effects include accidental modifications leading to bugs that are hard to track down. Immutable collections are thread-safe and can be shared without concern for concurrent modifications.
For the following examples, we will use listOf
for immutable collections and mutableListOf
for mutable collections, but the same principles apply to other collection types like setOf
, mapOf
, and their mutable counterparts.
Defining an immutable collection is straightforward:
val immutableList = listOf(1, 2, 3)
immutableList.add(4) // ❌ Error: Unresolved reference 'add'.
These lists are read-only, meaning you cannot add or remove elements after creation. This immutability helps prevent bugs and makes your code easier to reason about.
Similarly a mutable collection can be defined as follows:
private val mutableList = mutableListOf(1, 2, 3)
mutableList.add(4) // ✅ Works fine, mutableList is now [1, 2, 3, 4]
When should you use mutable collections?
The biggest advantage of mutable collections is performance. If you need to frequently modify a collection, using a mutable collection can be more efficient than creating new immutable collections each time.
However, you should still encapsulate mutable collections to prevent unintended modifications from outside your class or function.
private val _mutableList = mutableListOf(1, 2, 3)
val immutableList: List<Int> get() = _mutableList
fun addElement(element: Int) {
_mutableList.add(element) // Only this function can modify the collection
}
3. Avoid unnecessary intermediate collections
Chaining operations like map
and filter
creates intermediate collections. Minimize these by using sequences. Sequences allow you to process collections lazily, which can significantly improve performance, especially with large datasets. This means that operations are performed one element at a time, rather than creating intermediate collections for each operation.
Let's say you want to filter and map a list of numbers:
val numbers = listOf(1, 2, 3, 4, 5)
val result = numbers.filter { it % 2 == 0 } // Intermediate collections created here
.map { it * 2 } // Intermediate collections created here
.toList() // Final collection created here
println(result) // [4, 8]
This creates two intermediate collections: one for the filtered numbers and another for the mapped results. Instead, you can use a sequence to avoid these intermediate collections:
val numbers = listOf(1, 2, 3, 4, 5)
val result = numbers.asSequence()
.filter { it % 2 == 0 } // Process elements lazily
.map { it * 2 } // Process elements lazily
.toList() // Final collection created here
println(result) // [4, 8]
This approach processes each element only once, improving performance and reducing memory overhead.
4. Use Sequences for performance with large datasets
Using sequences is not always the best choice, especially for small collections, but it shines with larger datasets or complex operations. Sequences process operations lazily (one element at a time), improving performance for large collections.
5. Understand collection processing order
Collections execute operations eagerly (one operation fully before the next), while sequences process lazily (one element at a time). This means that with collections, all elements are processed before moving to the next operation, while sequences allow for more efficient processing by only evaluating elements as needed.
Generally I tend to reccommend this order of operations for collections: filter
→ map
→ sorted
. This order ensures that you filter out unnecessary elements before mapping and sorting, which can lead to better performance (duh).
6. Prefer built-in collection functions
Leverage Kotlin’s powerful built-in functions for readability and efficiency. Below is a table containing some of the collection functions you should be familiar with:
Category / Family | Key Functions (comma‑separated) | Typical Use |
---|---|---|
Transformation | map , mapNotNull , flatMap , flatten , associateBy | Convert or reshape elements / structure |
Filtering | filter , filterNotNull , distinct , distinctBy | Keep, drop, or deduplicate elements |
Grouping & Aggregation | groupBy , groupingBy , fold , reduce | Bucket data or fold into a single value |
Combining & Splitting | zip , partition , chunked , windowed | Merge collections or slice one into parts |
Searching & Retrieval | find , firstOrNull , contains | Locate specific elements quickly |
For usage, refer to the the official Kotlin documentation.
7. Consider associating elements to a map
When you need to associate elements with keys, use associateBy
or associate
. This allows you to create a map from a collection, making lookups efficient.
val people = listOf(Person("Alice", 30), Person("Bob", 25))
val peopleByName = people.associateBy { it.name } // Creates a Map<String, Person>
println(peopleByName["Alice"]) // Person(name=Alice, age=30)
This is in contrast to using a list and searching through it, which would be less efficient:
val people = listOf(Person("Alice", 30), Person("Bob", 25))
val alice = people.find { it.name == "Alice" } // O(n) search
println(alice) // Person(name=Alice, age=30)
Conclusion
In this post, I've skimmed through some of the best practices for working with collections in Kotlin, inspired by Effective Kotlin. By choosing the right collection type, preferring immutability, avoiding unnecessary intermediate collections, and leveraging sequences, you can write more efficient and maintainable code.