Skip to main content
kotlin in depth advanced patterns for java engineers

Collections, Sequences, and Functional Patterns

6 min read Chapter 10 of 21

The Collection Hierarchy You Thought You Knew

Coming from Java, you’re used to a single collection hierarchy: java.util.List gives you both read and write access. The interface declares get(), add(), remove(), and set() on the same type. If you want immutability, you wrap the list with Collections.unmodifiableList() — a runtime check that throws UnsupportedOperationException when you try to modify it. The compiler has zero knowledge of your intent.

Kotlin takes a different approach. The collection hierarchy splits into two parallel trees:

Read-only interfaces: IterableCollectionList / Set, plus Map

Mutable interfaces: MutableIterableMutableCollectionMutableList / MutableSet, plus MutableMap

Each mutable interface extends its read-only counterpart. MutableList<E> extends List<E> and adds add(), remove(), set(), and other mutation methods. This means the compiler enforces the contract: if a function accepts List<String>, the calling code cannot pass elements through add() — the method doesn’t exist on the type.

fun processTitles(titles: List<String>) {
    // titles.add("New Title")  // Won't compile — List has no add()
    val first = titles[0]       // Works — List has get() via operator
    println(first.uppercase())
}

fun buildTitles(titles: MutableList<String>) {
    titles.add("New Title")     // Compiles — MutableList has add()
}

In Java, the equivalent would look like this — with no compile-time distinction:

void processTitles(List<String> titles) {
    titles.add("New Title");  // Compiles fine. Might throw at runtime.
    String first = titles.get(0);
    System.out.println(first.toUpperCase());
}

The Java version compiles regardless of whether the list is actually modifiable. You discover the error when Collections.unmodifiableList() throws at runtime — typically in production, at the worst possible time.

The JVM Reality Behind the Abstraction

Here is where things get interesting. Kotlin’s read-only guarantee is a compiler-level contract, not a runtime enforcement mechanism. There is no special KotlinReadOnlyList class on the JVM. When you write listOf("a", "b", "c"), the runtime object is java.util.Arrays$ArrayList — a standard Java list.

You can verify this yourself:

val names = listOf("Alice", "Bob", "Charlie")
println(names::class)           // class java.util.Arrays$ArrayList
println(names::class.java)      // class java.util.Arrays$ArrayList
println(names is MutableList)   // true — at runtime, the object IS mutable

That last line is the critical insight. The Kotlin compiler prevents you from calling add() on a List reference, but the underlying JVM object fully supports mutation. The read-only contract exists only in the type system.

When This Bites You: Java Interop

This matters the moment your Kotlin List crosses into Java code. Java has no knowledge of Kotlin’s read-only types — it sees a java.util.List with full mutation capabilities.

// Java code receiving a Kotlin List
public class JavaService {
    public void processNames(List<String> names) {
        names.add("Intruder");  // Compiles and runs. Your Kotlin List is now modified.
        names.set(0, "Replaced");
    }
}
// Kotlin code — your "read-only" list is now corrupted
val names = mutableListOf("Alice", "Bob")
val readOnlyView: List<String> = names  // read-only reference

JavaService().processNames(readOnlyView)
println(names)  // [Replaced, Bob, Intruder] — modified through Java

This is not a design flaw — it’s a deliberate trade-off. Kotlin could have created wrapper classes that truly prevent mutation (like Collections.unmodifiableList()), but that would mean every collection crossing the Kotlin/Java boundary requires wrapping and unwrapping, with associated performance costs and identity confusion.

The practical defense: when you expose a collection to Java code and need to guarantee immutability, use toList() to create a defensive copy, or use a truly immutable collection library like kotlinx.collections.immutable:

val names = mutableListOf("Alice", "Bob")

// Defensive copy — Java gets its own list
JavaService().processNames(names.toList())
println(names)  // [Alice, Bob] — unchanged

Collection Factory Functions and Their Return Types

Kotlin provides factory functions that give you the appropriate type. Understanding what they return matters for performance:

// Read-only factories
val list = listOf(1, 2, 3)           // Arrays$ArrayList (fixed-size)
val set = setOf("a", "b", "c")       // LinkedHashSet (maintains insertion order)
val map = mapOf("k" to 1, "v" to 2)  // LinkedHashMap

// Mutable factories
val mList = mutableListOf(1, 2, 3)   // ArrayList
val mSet = mutableSetOf("a", "b")    // LinkedHashSet
val mMap = mutableMapOf("k" to 1)    // LinkedHashMap

// Empty collections (singletons — no allocation per call)
val empty = emptyList<String>()      // EmptyList singleton
val emptyM = emptyMap<String, Int>() // EmptyMap singleton

// Sized constructors when you know capacity
val sized = ArrayList<String>(1000)  // Pre-allocated, avoids resizing

Note the to infix function: "k" to 1 creates a Pair<String, Int>. Each mapOf() call allocates one Pair per entry, plus the map itself. For large maps built in a hot path, prefer:

val map = buildMap(capacity = 1000) {
    put("key1", value1)
    put("key2", value2)
    // No Pair allocations
}

buildList, buildSet, and buildMap let you use mutable operations during construction and return a read-only result. They’re the equivalent of Java’s builder pattern, but integrated into the standard library.

Eager Pipelines and Their Cost

Kotlin’s collection API provides a rich set of functional operations: filter, map, flatMap, groupBy, associate, zip, windowed, and dozens more. These are extension functions on Iterable, and they are eager — each operation executes immediately and produces a new collection.

val result = employees
    .filter { it.department == "Engineering" }   // Creates List<Employee>
    .map { it.salary }                            // Creates List<Double>
    .filter { it > 100_000 }                      // Creates List<Double>
    .sorted()                                     // Creates List<Double>
    .take(10)                                     // Creates List<Double>

Five operations, five intermediate lists allocated. For a list of 10,000 employees, you’ve allocated and discarded four temporary lists before reaching the final result. Each intermediate list iterates the entire previous result, even when take(10) means you only need the first 10 qualifying elements.

Sequence vs Collection Pipeline

This is where sequences enter the picture.

Sequences: Lazy Element-by-Element Processing

Convert any collection to a Sequence by calling .asSequence(), and the pipeline transforms from eager to lazy:

val result = employees.asSequence()
    .filter { it.department == "Engineering" }
    .map { it.salary }
    .filter { it > 100_000 }
    .sorted()                 // Note: sorted() forces intermediate collection
    .take(10)
    .toList()                 // Terminal operation — triggers evaluation

No intermediate collections are allocated (except at sorted(), which requires seeing all elements). Elements flow through the pipeline one at a time: the first employee is filtered, mapped, filtered again, and either emitted or discarded before the second employee enters the pipeline.

The difference becomes dramatic with short-circuiting operations. If your pipeline ends with first() or take(5), a sequence processes only as many elements as needed to produce the result. An eager collection pipeline processes the entire input at every step.

The tradeoff, the decision criteria, and the internal mechanics are covered in depth in the next section.

What’s Ahead

This chapter splits into two focused sections:

Section 1 — Lazy Sequences vs Eager Collections digs into the performance characteristics, shows benchmarks at different scales, walks through the internal implementation of Sequence, and compares against Java’s Stream API.

Section 2 — Scope Functions, Destructuring, and Operator Overloading covers the five scope functions with precise semantics (not the hand-wavy “use let for null checks” advice you’ve seen elsewhere), destructuring declarations and their componentN() mechanism, and operator overloading conventions with practical patterns and anti-patterns.