Free Handbook · Every example compiled & verified

Collections

Kotlin lists, sets and maps: read-only vs mutable, arrays vs lists, the transformation functions you use daily, and lazy sequences.

0 / 136 lessons🔥 0 day streak
ShareXLinkedIn

Module 05 · what you'll be able to do

  • Choose between List, Set and Map, and between their read-only and mutable versions
  • Explain when to use an Array or IntArray instead of a List
  • Replace hand-written loops with map, filter, groupBy, associate, fold and friends
  • Use a Sequence to process large or infinite data lazily, and say when it is not worth it
  • Recognise and fix ConcurrentModificationException and UnsupportedOperationException
01

Lists: read-only vs mutable

A List is an ordered collection that allows duplicates and is indexed from 0. Kotlin splits every collection type into two interfaces: List<T> has only reading operations (size, get, contains, indexOf), and MutableList<T> adds add, remove, set and clear. listOf() gives you the first, mutableListOf() the second.

kotlinMain.kt
fun main() {
    val langs = listOf("Kotlin", "Java", "Go")
    println(langs[0])
    println(langs.size)
    println("Go" in langs)
    println(langs.indexOf("Java"))

    val cart = mutableListOf("milk")
    cart.add("eggs")
    cart += "bread"          // same as add
    cart[0] = "oat milk"     // same as set(0, ...)
    cart.removeAt(1)
    println(cart)
    println(cart.first() + " / " + cart.last())
}
Outputcompiled & run with real Kotlin
Kotlin
3
true
1
[oat milk, bread]
oat milk / bread
Your turn

Call cart.add(1, "butter") before printing and predict where it lands.

val is about the variable, not the list
val cart = mutableListOf(...) means the variable cart always points at the same list. The list itself can still grow and shrink. Read-only-ness comes from the type (List vs MutableList), not from val.

When a function needs to build a list step by step but should hand back a read-only one, use buildList { }. Inside the block you have a MutableList; what comes out is a List. buildSet and buildMap work the same way.

kotlinMain.kt
fun evensUpTo(n: Int): List<Int> = buildList {
    for (i in 0..n) {
        if (i % 2 == 0) add(i)
    }
    add(-1)   // a sentinel at the end
}

fun main() {
    val evens = evensUpTo(8)
    println(evens)
    println(List(4) { it * it })      // List(size) { index -> value }
    println(emptyList<String>().isEmpty())
}
Outputcompiled & run with real Kotlin
[0, 2, 4, 6, 8, -1]
[0, 1, 4, 9]
true
Error you will hit

Calling add on a read-only List

kotlin
fun main() {
    val names = listOf("Ada", "Linus")
    names.add("Grace")
    println(names)
}
Main.kt:3:11: error: unresolved reference 'add' on receiver of type 'List<String>'.
    names.add("Grace")
          ^^^
Why the compiler said that

listOf returns the read-only List interface, which simply has no add function. The compiler is not saying the list is frozen at runtime; it is saying the type you hold does not offer mutation.

The fix

If the list really needs to change, create it with mutableListOf. If you only need a new list with one more item, use names + "Grace", which returns a fresh list and leaves the original alone.

kotlin
fun main() {
    val names = mutableListOf("Ada", "Linus")
    names.add("Grace")
    println(names)
}
02

Sets and maps

A Set holds each value at most once — perfect for "have I seen this?" checks. A Map stores key-value pairs with unique keys; build one with mapOf(key to value), where to creates a Pair. The default implementations (LinkedHashSet and LinkedHashMap) remember insertion order, so printing them is predictable.

kotlinMain.kt
fun main() {
    val tags = mutableSetOf("kotlin", "jvm")
    println(tags.add("kotlin"))   // already there
    println(tags.add("android"))
    println(tags)

    val a = setOf(1, 2, 3)
    val b = setOf(2, 3, 4)
    println(a union b)
    println(a intersect b)
    println(a subtract b)
}
Outputcompiled & run with real Kotlin
false
true
[kotlin, jvm, android]
[1, 2, 3, 4]
[2, 3]
[1]

Reading a map with map[key] returns a nullable value, because the key might be missing. You then decide what "missing" means: ?: default, getOrDefault, or getValue (throws if absent). For counting and caching, getOrPut reads a value or inserts one in a single call.

kotlinMain.kt
fun main() {
    val prices = mapOf("tea" to 3, "coffee" to 4)
    println(prices["tea"])
    println(prices["juice"])
    println(prices["juice"] ?: 0)
    println(prices.keys)

    val counts = mutableMapOf<Char, Int>()
    for (c in "banana") {
        counts[c] = counts.getOrDefault(c, 0) + 1
    }
    println(counts)

    val cache = mutableMapOf<Int, String>()
    println(cache.getOrPut(1) { "computed" })
    println(cache.getOrPut(1) { "not called" })
}
Outputcompiled & run with real Kotlin
3
null
0
[tea, coffee]
{b=1, a=3, n=2}
computed
computed
Your turn

Rewrite the banana counter as one line: "banana".groupingBy { it }.eachCount().

The default constructors keep insertion order; hashSetOf/hashMapOf trade that for a little speed.
NeedRead-onlyMutableOrder
Ordered, duplicates allowedlistOfmutableListOf / arrayListOfindex order
Unique valuessetOfmutableSetOfinsertion order
Unique values, sortedsortedSetOfsortedSetOf (a TreeSet)sorted
Key to valuemapOfmutableMapOfinsertion order
Fastest set/map, order irrelevant—hashSetOf / hashMapOfunspecified
03

Arrays vs lists

An Array<T> has a fixed size and mutable slots; it maps straight to a Java array. For numbers, Kotlin has specialised arrays — IntArray, DoubleArray, BooleanArray — that store raw primitives with no boxing, which matters for large numeric data. In everyday code, prefer List: it has a read-only view, proper equals, and prints nicely.

kotlinMain.kt
fun main() {
    val scores = intArrayOf(90, 75, 60)
    scores[1] = 80                  // slots are mutable
    println(scores.size)
    println(scores.sum())
    println(scores.contentToString())

    val a = arrayOf("x", "y")
    val b = arrayOf("x", "y")
    println(a == b)                 // reference comparison
    println(a.contentEquals(b))
    println(listOf("x", "y") == listOf("x", "y"))

    val asList = scores.toList()
    println(asList)
}
Outputcompiled & run with real Kotlin
3
230
[90, 80, 60]
false
true
true
[90, 80, 60]

println(scores) on its own would print something like [I@1b6d3586 — the JVM type code and a hash, not the contents.

Array / IntArray

  • Fixed size, set at creation
  • Always mutable slots
  • == compares references; use contentEquals
  • IntArray stores raw ints: fast and compact
  • Needed for main(args), varargs and Java APIs

List / MutableList

  • MutableList grows and shrinks
  • Read-only view available
  • == compares contents
  • Stores boxed Integer objects
  • The default for application code
Error you will hit

Index out of bounds at runtime

kotlin
fun main() {
    val scores = intArrayOf(90, 75, 60)
    println(scores[3])
}
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
	at MainKt.main(Main.kt:3)
	at MainKt.main(Main.kt)
Why the compiler said that

Indexes run from 0 to size - 1, so a three-element array has indexes 0, 1 and 2. The compiler does not check index values; the JVM does, at runtime. A List throws the sibling IndexOutOfBoundsException.

The fix

Use lastIndex or indices instead of hand-computed numbers, or a safe accessor that returns null instead of throwing: getOrNull.

kotlin
fun main() {
    val scores = intArrayOf(90, 75, 60)
    println(scores[scores.lastIndex])
    println(scores.getOrNull(3) ?: "no fourth score")
}
04

Iterating collections

A for loop works on anything with an iterator(). When you need the position as well, ask for it with withIndex() or forEachIndexed rather than keeping a counter by hand. Maps iterate as entries, which destructure into key and value.

kotlinMain.kt
fun main() {
    val podium = listOf("Ada", "Linus", "Grace")
    for ((i, name) in podium.withIndex()) {
        println("${i + 1}. $name")
    }
    podium.forEachIndexed { i, name -> if (i == 0) println("winner: $name") }
    for (i in podium.indices.reversed()) print("${podium[i]} ")
    println()

    val stock = mapOf("apples" to 4, "pears" to 0)
    for ((fruit, n) in stock) println("$fruit=$n")
}
Outputcompiled & run with real Kotlin
1. Ada
2. Linus
3. Grace
winner: Ada
Grace Linus Ada 
apples=4
pears=0
VisualizeWhat withIndex() hands the loopStep 1 / 6
val podium = listOf("Ada", "Linus")
for ((i, name) in podium.withIndex()) {
println("${i + 1}. $name")
}
Line 1

A two-element read-only list is created.

Variables now
podium[Ada, Linus]
All 6 steps as a table
StepLineWhat happenedVariables now
11A two-element read-only list is created.podium = [Ada, Linus]
22withIndex() wraps the list lazily; the first item is IndexedValue(index=0, value=Ada), destructured into i and name.i = 0 name = Ada
33The body prints the 1-based position.
42Next item: IndexedValue(1, Linus).i = 1 name = Linus
53Prints the second line.
62The iterator has no more items, so the loop ends.
05

Transformations: map, filter, groupBy, fold and more

Most loops in real code do one of a handful of things: transform every item, keep some items, group them, or boil them down to one value. Kotlin has a named function for each, and every one returns a new collection — the original is never changed. Chaining them reads like a sentence describing what you want.

kotlinMain.kt
data class Order(val customer: String, val item: String, val total: Int)

fun main() {
    val orders = listOf(
        Order("ada", "book", 30),
        Order("linus", "pen", 5),
        Order("ada", "lamp", 45),
        Order("grace", "book", 30),
    )
    println(orders.map { it.total })
    println(orders.filter { it.total >= 30 }.map { it.item })
    println(orders.sumOf { it.total })
    println(orders.maxBy { it.total }.item)
    println(orders.sortedByDescending { it.total }.map { it.customer })
    println(orders.groupBy { it.customer }.mapValues { (_, os) -> os.sumOf { it.total } })
    println(orders.associate { it.item to it.total })
    val (big, small) = orders.partition { it.total > 10 }
    println("${big.size} big, ${small.size} small")
    println(orders.map { it.customer }.distinct())
    println(orders.any { it.total > 40 } to orders.all { it.total > 1 })
}
Outputcompiled & run with real Kotlin
[30, 5, 45, 30]
[book, lamp, book]
110
lamp
[ada, ada, grace, linus]
{ada=75, linus=5, grace=30}
{book=30, pen=5, lamp=45}
3 big, 1 small
[ada, linus, grace]
(true, true)

associate keeps the LAST value for a repeated key; groupBy keeps all of them in a list.

Your turn

Use associateBy { it.customer } and explain why ada maps to the lamp order.

A second group reshapes collections: flatMap turns each item into a list and concatenates the results, zip pairs two lists by position, chunked(n) cuts a list into pieces of size n, and windowed(n) slides an n-wide window across it. fold and reduce combine everything into one value: fold starts from a value you give; reduce starts from the first element.

kotlinMain.kt
fun main() {
    val sentences = listOf("kotlin is fun", "lists are lists")
    println(sentences.flatMap { it.split(" ") })

    val names = listOf("Ada", "Linus", "Grace")
    val ages = listOf(36, 28)
    println(names.zip(ages))              // stops at the shorter list

    val readings = listOf(3, 5, 4, 8, 6, 7)
    println(readings.chunked(4))
    println(readings.windowed(3).map { it.average() })

    println(readings.fold(0) { acc, n -> acc + n })
    println(readings.reduce { acc, n -> maxOf(acc, n) })
    println(names.fold("") { acc, s -> acc + s.first() })
}
Outputcompiled & run with real Kotlin
[kotlin, is, fun, lists, are, lists]
[(Ada, 36), (Linus, 28)]
[[3, 5, 4, 8], [6, 7]]
[4.0, 5.666666666666667, 6.0, 7.0]
33
8
ALG
Visualizefold step by stepStep 1 / 6
val xs = listOf(3, 5, 4)
val total = xs.fold(10) { acc, n ->
acc + n
}
println(total)
Line 1

The list is created.

Variables now
xs[3, 5, 4]
All 6 steps as a table
StepLineWhat happenedVariables now
11The list is created.xs = [3, 5, 4]
22fold starts with the initial value you passed.acc = 10
33First element: the lambda returns 10 + 3, which becomes the next acc.n = 3 acc = 13
43Second element.n = 5 acc = 18
53Third element. No elements left, so fold returns the final accumulator.n = 4 acc = 22
65Prints the result.total = 22
Error you will hit

reduce on an empty collection

kotlin
fun main() {
    val empty = emptyList<Int>()
    println(empty.reduce { acc, n -> acc + n })
}
Exception in thread "main" java.lang.UnsupportedOperationException: Empty collection can't be reduced.
	at MainKt.main(Main.kt:7)
	at MainKt.main(Main.kt)
Why the compiler said that

reduce uses the first element as its starting value, and an empty list has no first element. (The odd line number 7 in a 4-line file is because reduce is an inline function: its body is copied into main, and the JVM reports a line inside that copied code.)

The fix

Use fold with an explicit starting value, which is well defined for an empty list, or reduceOrNull and handle the null. For sums specifically, sum() returns 0 on an empty list.

kotlin
fun main() {
    val empty = emptyList<Int>()
    println(empty.fold(0) { acc, n -> acc + n })
    println(empty.reduceOrNull { acc, n -> acc + n } ?: "nothing to add")
}
Readable chains beat clever ones
Five chained calls on one line is hard to review. Put each step on its own line, starting with the dot, and name the intermediate result if the chain needs a comment. When one step gets complicated, extract it into a named function and pass it by reference: orders.filter(::isRefundable).
06

Sequences: lazy collections

Every list operation is eager: list.map { }.filter { } builds a full intermediate list after map, then another after filter. A Sequence is lazy: operations are recorded, and each element flows through the whole chain one at a time only when a terminal operation (toList, first, sum, count) asks for results. The print order shows the difference.

kotlinMain.kt
fun main() {
    val xs = listOf(1, 2, 3, 4)

    println("-- list (eager)")
    val a = xs.map { println("map $it"); it * 10 }
        .first { println("check $it"); it > 15 }
    println(a)

    println("-- sequence (lazy)")
    val b = xs.asSequence()
        .map { println("map $it"); it * 10 }
        .first { println("check $it"); it > 15 }
    println(b)
}
Outputcompiled & run with real Kotlin
-- list (eager)
map 1
map 2
map 3
map 4
check 10
check 20
20
-- sequence (lazy)
map 1
check 10
map 2
check 20
20

The sequence stopped as soon as it had an answer; it never mapped 3 or 4.

Because nothing is computed until asked, a sequence can even be infinite. generateSequence(seed) { next } produces values forever; take(n) or takeWhile cuts it short. The sequence { } builder lets you yield values from ordinary code.

kotlinMain.kt
fun main() {
    val powersOfTwo = generateSequence(1) { it * 2 }
    println(powersOfTwo.take(8).toList())
    println(powersOfTwo.first { it > 1000 })

    val fibonacci = sequence {
        var a = 0
        var b = 1
        while (true) {
            yield(a)
            val next = a + b
            a = b
            b = next
        }
    }
    println(fibonacci.takeWhile { it < 60 }.joinToString(" "))
}
Outputcompiled & run with real Kotlin
[1, 2, 4, 8, 16, 32, 64, 128]
1024
0 1 1 2 3 5 8 13 21 34 55
Sequences are not automatically faster
Each sequence step adds a small per-element overhead. For a few hundred items and one or two operations, a plain list is usually as fast or faster. Reach for asSequence() when the collection is large, the chain is long, or you stop early with first/take. Also, some sequences can be walked only once (for example lines read from a file with useLines); call toList() if you need the results twice.
07

Modification pitfalls: ConcurrentModification and read-only casts

Two runtime exceptions catch almost every Kotlin beginner. The first comes from changing a collection while a for loop is walking it. The second comes from believing that a read-only List can be cast back to MutableList.

Error you will hit

ConcurrentModificationException: removing inside a for loop

kotlin
fun main() {
    val nums = mutableListOf(1, 2, 3, 4)
    for (n in nums) {
        if (n % 2 == 0) nums.remove(n)
    }
    println(nums)
}
Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(Unknown Source)
	at java.base/java.util.ArrayList$Itr.next(Unknown Source)
	at MainKt.main(Main.kt:3)
	at MainKt.main(Main.kt)
Why the compiler said that

The for loop uses an iterator that remembers how many times the list had been modified when it started. remove changes that count, and the next call to next() (line 3, the loop header) notices and throws. "Concurrent" here does not mean threads — one thread is enough.

The fix

Say what you want instead of how: removeAll { } (or removeIf) edits the list in one safe pass, and filter builds a new list. If you must use an iterator, call iterator.remove() on the iterator itself.

kotlin
fun main() {
    val nums = mutableListOf(1, 2, 3, 4)
    nums.removeAll { it % 2 == 0 }
    println(nums)                     // [1, 3]
}
Error you will hit

UnsupportedOperationException: casting a read-only list to MutableList

kotlin
fun main() {
    val ids = listOf(1, 2, 3)
    val mutable = ids as MutableList<Int>
    mutable.add(4)
    println(mutable)
}
Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.AbstractList.add(Unknown Source)
	at java.base/java.util.AbstractList.add(Unknown Source)
	at MainKt.main(Main.kt:4)
	at MainKt.main(Main.kt)
Why the compiler said that

On the JVM, List and MutableList both map to java.util.List, so the cast succeeds. But the object listOf returned is a fixed-size list whose add just throws. The cast lied to the compiler, and the runtime caught it.

The fix

Never cast a read-only collection to its mutable type. Make a mutable copy with toMutableList(), which you are then free to change.

kotlin
fun main() {
    val ids = listOf(1, 2, 3)
    val mutable = ids.toMutableList()
    mutable.add(4)
    println(mutable)                  // [1, 2, 3, 4]
}

The opposite surprise also exists: read-only is not immutable. A List reference can point at a MutableList that someone else is still changing. If a class must hand out a snapshot that cannot move under the caller, return a copy with toList().

kotlinMain.kt
class Team {
    private val members = mutableListOf("Ada")
    val view: List<String> get() = members          // read-only view
    val snapshot: List<String> get() = members.toList() // copy
    fun join(name: String) { members += name }
}

fun main() {
    val team = Team()
    val v = team.view
    val s = team.snapshot
    team.join("Linus")
    println(v)
    println(s)
}
Outputcompiled & run with real Kotlin
[Ada, Linus]
[Ada]

The view saw the change even though its type is the read-only List.

read-only collection
A List, Set or Map type with no mutating functions. The underlying object may still change through another reference.
mutable collection
MutableList, MutableSet, MutableMap — add, remove and replace elements.
IntArray
A fixed-size array of raw int values with no boxing; Kotlin's equivalent of Java int[].
Pair / to
a to b creates Pair(a, b); used to build map entries.
transformation
A function such as map, filter or groupBy that returns a new collection and leaves the original unchanged.
fold / reduce
Combine all elements into one value; fold starts from a given value, reduce from the first element.
Sequence
A lazy collection: elements are processed one at a time through the whole chain when a terminal operation runs.
terminal operation
The call that makes a sequence actually compute, e.g. toList, first, count, sum.
ConcurrentModificationException
Thrown when a collection is structurally changed while an iterator is walking it.
Quick check

What does listOf(1, 2, 3).asSequence().map { it * 2 } do on its own, with nothing after it?

Quick check

listOf("a" to 1, "b" to 2, "a" to 3).associate { it } gives…

Frequently asked questions

What is the difference between List and MutableList in Kotlin?
List is a read-only interface: you can read elements but not add, remove or replace them. MutableList extends it with those operations. Read-only does not mean immutable — another reference of type MutableList can still change the same object.
Should I use Array or List in Kotlin?
Use List by default. Use IntArray, DoubleArray and friends for large amounts of numeric data where avoiding boxing matters, and Array when a Java API or a vararg requires one.
When should I use a Sequence instead of a List?
When the collection is large, the chain of operations is long, you stop early with first or take, or the data is infinite. For small collections with one or two operations, a list is simpler and usually just as fast.

Finish the Kotlin handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.