"Collections Without Collectors" — Collections, Sequences and Strings
A Java utility sorts a "read-only" Kotlin list in place, and a nightly job drowns the garbage collector in intermediate lists. We look at what Kotlin collections are on the JVM, the operations that replace Collectors, sequences versus eager chains versus Streams, arrays, strings and regex, and kotlin.time and Uuid.
Story Opening
Kabir reproduced the second bug report before his coffee had cooled. The store-operations team’s utility was four lines of Java:
// The store-operations team's utility: sorts whatever list it is given, in place.public final class LegacyReports { public static void sortInPlace(List<String> skus) { Collections.sort(skus); }}And his own class handed out its internal list through a read-only type:
// Fragment of story/ReadOnlyLeak.ktclass Assortment { private val skus = mutableListOf("SHW-2040", "SHW-1001", "SHW-3001")
// Read-only to Kotlin callers. To Java, it is the same java.util.List, set() and all. fun skus(): List<String> = skusThe Kotlin compiler would not let any Kotlin caller add to, remove from or sort that list. Java’s Collections.sort did it anyway, and the next caller of skus() got the new order.
“So List is just a suggestion,” he said. “Do I pull in Guava’s ImmutableList?”
“You could,” Lena said. “First find out what List actually is. It isn’t immutable, and it isn’t lazy either. That second part is your GC log.”
Java → Kotlin: The Quick Map
| Java | Kotlin | Note |
|---|---|---|
List<T> (with add, set…) | List<T> (read-only) and MutableList<T> | Both are java.util.List at runtime |
A Java method returning List<String> | (Mutable)List<String!>! in the IDE | A platform type (Part 2): you choose read-only or mutable |
List.of(…) (immutable, throws on set) | listOf(…) | Read-only interface; the object may still be mutable |
stream().map(…).collect(toList()) | map { … } | Eager: returns a List straight away |
A lazy Stream pipeline | asSequence().map { … }… | Lazy, element by element; no primitive variants |
Collectors.groupingBy / partitioningBy | groupBy / partition | partition returns a Pair |
Collectors.toMap(k, v) | associateBy { } / associate { } | Duplicate keys: last wins, no exception |
max(comparator) → Optional | maxBy { } / maxByOrNull { } | No Optional: most throwing accessors have an OrNull twin |
IntStream.range(0, n).mapToObj(…) | List(n) { … } | |
int[] / Integer[] | IntArray / Array<Int> | |
"a.b".split("\\.") (regex) | "a.b".split(".") (literal) | Use Regex(…) for a pattern |
Duration, Instant, UUID | kotlin.time.Duration, kotlin.time.Instant, kotlin.uuid.Uuid | Convert at Java boundaries |
Conceptual Deep-Dive
Read-only is a view, not a value
Kotlin splits every collection interface in two: a read-only one (List, Set, Map, Collection, Iterable) and a mutable one that extends it (MutableList, MutableSet…). On the JVM there is only one of each, so both halves map onto the same Java interface:
| Kotlin type | In bytecode | What the compiler allows |
|---|---|---|
List<T> | java.util.List<T> | get, size, iteration, operations |
MutableList<T> | java.util.List<T> | All of the above, plus add, set, remove… |
Map<K, V> / MutableMap<K, V> | java.util.Map<K, V> | Read-only / read-write |
Assortment.skus() compiles to public final java.util.List<java.lang.String> skus(), the same signature a Java method would have. That is the design: Kotlin can pass its lists to any Java API and accept any Java list, with no copying and no wrappers. The price is that “read-only” exists only in Kotlin’s type checker. The object underneath is usually a plain java.util.ArrayList, and anything that isn’t Kotlin code, such as Java, reflection or a cast, can mutate it.
It is also why List<out E> can be covariant, so that a List<Product> is usable as a List<Any>: a list you can only read can’t be used to insert the wrong type. Part 8 builds on that.
So the mental model to unlearn is “immutable by type”. That is weaker than Java’s List.of, which throws on modification, and much weaker than a persistent collection.
Eager by default, lazy when asked
A Java developer reads products.map { … }.filter { … } as a Stream pipeline: lazy, fused into one pass, nothing happening until a terminal operation. Kotlin’s collection operations are the opposite. They run immediately and return a new List, and most of the transforming ones (map, filter, groupBy…) are inline functions (Part 5). A three-step chain over two million products makes three lists of up to two million elements, which is what the nightly job’s GC log was showing.
That is a deliberate default. For the small collections most code handles, eager inline operations are usually the cheapest option: a loop in your own bytecode, no lambda objects, no pipeline machinery, and results you can reuse and print. When the data is large, the chain is long, or you stop early (first, take), switch to a Sequence: Kotlin’s lazy equivalent of a Stream, processed element by element through the whole chain.
Technical Explanation
What the collection factories really return
fun main() { val shelf = mutableListOf("SHW-1001", "SHW-2040") val view: List<String> = shelf // the same object, seen through a narrower interface
// view.add("SHW-3001") // error: unresolved reference 'add'. shelf += "SHW-3001" println(view) // -> [SHW-1001, SHW-2040, SHW-3001]
// What the factories return at runtime: ordinary JDK collections, mostly. println(listOf("a", "b")::class.java.name) // -> java.util.Arrays$ArrayList println(listOf("a")::class.java.name) // -> java.util.Collections$SingletonList println(mutableListOf("a")::class.java.name) // -> java.util.ArrayList println(listOf("b", "a").map { it }::class.java.name) // -> java.util.ArrayList println(mapOf("a" to 1, "b" to 2)::class.java.name) // -> java.util.LinkedHashMap println(setOf("a", "b")::class.java.name) // -> java.util.LinkedHashSet
// A cast defeats the read-only interface, because the object underneath is mutable. val mapped: List<String> = listOf("b", "a").map { it.uppercase() } (mapped as MutableList<String>).add("C") println(mapped) // -> [B, A, C]
// buildList is the exception: its result is read-only at runtime too. val built: List<String> = buildList { add("x") } try { (built as MutableList<String>).add("y") } catch (e: UnsupportedOperationException) { println("buildList result is read-only") // -> buildList result is read-only }}listOf("a", "b") is Arrays.asList underneath, which supports set (so Collections.sort works on it) but not add. map, filter and friends return ArrayLists, fully mutable to anyone who casts or calls from Java. buildList is the exception, returning a list that throws on modification at runtime too. Don’t build on the exact classes; they are implementation details. Build on the rule: a read-only type protects you from your own code, not from code that doesn’t respect it. When a list crosses into code you don’t control, return a copy (toList()), an unmodifiable wrapper, or a buildList result. For persistent collections with structural sharing, look at the separate kotlinx.collections.immutable library.
Builders
fun main() { val skus = listOf("SHW-1001", "SHW-2040") val prices = mapOf("SHW-1001" to 16_500L, "SHW-2040" to 72_000L) // 'to' builds a Pair
// Builders: mutate inside, read-only outside. No temporary mutable variable leaks out. val shelfLabels = buildList { add("AISLE 7") for (sku in skus) add("$sku ₹${prices.getValue(sku) / 100}") if (size > 2) add("END") } println(shelfLabels) // -> [AISLE 7, SHW-1001 ₹165, SHW-2040 ₹720, END]
val bySku = buildMap { put("SHW-1001", "Toor Dal 1kg") putAll(mapOf("SHW-2040" to "Basmati 5kg")) } println(bySku) // -> {SHW-1001=Toor Dal 1kg, SHW-2040=Basmati 5kg}
// Size-and-initializer constructors, like IntStream.range(...).mapToObj(...).toList(). println(List(3) { "slot-$it" }) // -> [slot-0, slot-1, slot-2]
// [] returns null for a missing key; getValue (used above) throws instead. println(prices["SHW-9999"]) // -> null}buildList, buildSet and buildMap give you a mutable collection inside a lambda (with a receiver, Part 5) and a read-only one outside. They replace the Java pattern of a local ArrayList, a loop, and Collections.unmodifiableList at the end. mapOf("SHW-1001" to 16_500L) uses the to infix function from Part 6 to build Pairs.
The operations that replace Collectors
data class Product(val sku: String, val name: String, val category: String, val pricePaise: Long, val units: Int)
val catalog = listOf( Product("SHW-1001", "Toor Dal 1kg", "staples", 16_500, 120), Product("SHW-1002", "Moong Dal 1kg", "staples", 14_200, 0), Product("SHW-2040", "Basmati 5kg", "staples", 72_000, 35), Product("SHW-3001", "Ghee 500ml", "dairy", 34_000, 8), Product("SHW-3002", "Paneer 200g", "dairy", 9_000, 40), Product("SHW-5001", "Masala Chips", "snacks", 2_000, 300),)
fun main() { // stream().map(...).collect(toList()) -> map println(catalog.filter { it.units == 0 }.map { it.sku }) // -> [SHW-1002]
// Collectors.groupingBy -> groupBy (a LinkedHashMap: insertion order kept) val byCategory: Map<String, List<Product>> = catalog.groupBy { it.category } println(byCategory.mapValues { (_, products) -> products.size }) // -> {staples=3, dairy=2, snacks=1}
// Collectors.toMap(Product::getSku, identity()) -> associateBy (duplicate keys: last wins, no exception) val bySku: Map<String, Product> = catalog.associateBy { it.sku } println(bySku.getValue("SHW-3001").name) // -> Ghee 500ml val relabelled = catalog + Product("SHW-3001", "Ghee 1L", "dairy", 64_000, 2) println(relabelled.associateBy { it.sku }.getValue("SHW-3001").name) // -> Ghee 1L
// Collectors.partitioningBy -> partition, returning a Pair you destructure val (inStock, soldOut) = catalog.partition { it.units > 0 } println("${inStock.size} in stock, ${soldOut.size} sold out") // -> 5 in stock, 1 sold out
// mapToLong(...).sum() -> sumOf; reduce with identity -> fold println(catalog.sumOf { it.pricePaise * it.units }) // -> 5732000 println(catalog.fold(0) { total, product -> total + product.units }) // -> 503
// max(comparing(...)) -> maxBy / maxByOrNull (no Optional) println(catalog.maxBy { it.pricePaise }.name) // -> Basmati 5kg println(catalog.filter { it.category == "frozen" }.maxByOrNull { it.pricePaise }) // -> null
// flatMap, zip, chunked, windowed val tags = mapOf("SHW-1001" to listOf("protein", "staple"), "SHW-3002" to listOf("protein", "fresh")) println(tags.values.flatMap { it }.distinct()) // -> [protein, staple, fresh] println(listOf("A1", "A2", "A3").zip(listOf(120, 35))) // -> [(A1, 120), (A2, 35)] println(catalog.map { it.sku }.chunked(4)) // -> [[SHW-1001, SHW-1002, SHW-2040, SHW-3001], [SHW-3002, SHW-5001]] val dailySales = listOf(40, 42, 39, 61, 75) println(dailySales.windowed(3) { it.average().toInt() }) // -> [40, 47, 58]
// Counting per key without building the groups: groupingBy + eachCount println(catalog.groupingBy { it.category }.eachCount()) // -> {staples=3, dairy=2, snacks=1}
// isSorted / isSortedBy (Stable since 2.4.0) println(dailySales.isSorted()) // -> false println(catalog.isSortedBy { it.sku }) // -> true}There is no collect step, because every operation already returns a collection, and no Collector protocol for custom terminal operations: fold, groupingBy { } or a buildMap { } with a loop cover those cases. groupBy returns a LinkedHashMap of lists; when you only need counts or sums per key, groupingBy { } plus eachCount, fold, reduce or aggregate computes them without building the lists. isSorted() and isSortedBy { } became Stable in 2.4.0.
Sequences: Kotlin’s Streams
import kotlin.streams.asSequence
// The same pipeline twice, for the bytecode comparison.fun eagerTotal(prices: List<Long>): Long = prices.map { it * 105 / 100 }.filter { it > 20_000 }.sum()
fun lazyTotal(prices: List<Long>): Long = prices.asSequence().map { it * 105 / 100 }.filter { it > 20_000 }.sum()
fun main() { val prices = listOf(16_500L, 14_200L, 72_000L, 34_000L, 9_000L)
// Eager: each step runs over the whole list and builds a new list. val trace = mutableListOf<String>() val firstEager = prices .map { trace += "map $it"; it * 105 / 100 } .filter { trace += "filter $it"; it > 20_000 } .first() println(firstEager) // -> 75600 println(trace.size) // -> 10
// Lazy: each element goes through the whole chain; first() stops the pipeline early. trace.clear() val firstLazy = prices.asSequence() .map { trace += "map $it"; it * 105 / 100 } .filter { trace += "filter $it"; it > 20_000 } .first() println(firstLazy) // -> 75600 println(trace) // -> [map 16500, filter 17325, map 14200, filter 14910, map 72000, filter 75600]
println(eagerTotal(prices) == lazyTotal(prices)) // -> true
// Infinite sequences, like Stream.iterate: only what you take is computed. val reorderDates = generateSequence(1) { it + 7 }.take(4).toList() println(reorderDates) // -> [1, 8, 15, 22]
// A sequence builder: yield values from ordinary code. val labels = sequence { yield("HEADER") for (aisle in 1..2) yield("AISLE $aisle") yield("FOOTER") } println(labels.joinToString(" / ")) // -> HEADER / AISLE 1 / AISLE 2 / FOOTER
// Java Streams convert to sequences (and back with asStream()). println(java.util.stream.Stream.of("b", "a").asSequence().sorted().toList()) // -> [a, b]}The two traces tell the story. The eager chain ran map five times and filter five times, building two lists, to return the first match. The sequence ran each element through map and then filter, and stopped at the third element, because first() asked for only one.
The bytecode shows the two designs. eagerTotal is two inlined loops, each filling a new ArrayList:
// javap -c SequencesKt.eagerTotal (simplified) 15: new java/util/ArrayList // list #1, for map 37: invokeinterface Iterable.iterator ... 90: invokestatic java/lang/Long.valueOf // boxing each mapped value 96: invokeinterface Collection.add 102: goto 44 120: new java/util/ArrayList // list #2, for filter ... 214: invokestatic CollectionsKt.sumOfLong:(Ljava/lang/Iterable;)JlazyTotal is a pipeline of objects, like a Stream:
// javap -c SequencesKt.lazyTotal (simplified) 10: invokestatic CollectionsKt.asSequence:(Ljava/lang/Iterable;)Lkotlin/sequences/Sequence; 13: invokedynamic invoke:()Lkotlin/jvm/functions/Function1; // the map lambda, as an object 18: invokestatic SequencesKt.map:(Lkotlin/sequences/Sequence;Lkotlin/jvm/functions/Function1;)Lkotlin/sequences/Sequence; 21: invokedynamic invoke:()Lkotlin/jvm/functions/Function1; // the filter lambda 26: invokestatic SequencesKt.filter:(Lkotlin/sequences/Sequence;Lkotlin/jvm/functions/Function1;)Lkotlin/sequences/Sequence; 29: invokestatic SequencesKt.sumOfLong:(Lkotlin/sequences/Sequence;)JNeither is “the fast one”. Eager chains pay in intermediate collections; sequences pay a wrapper sequence and an iterator per step, and a virtual call per element per step. Both box primitives, because List<Long> and Sequence<Long> hold java.lang.Long. Rules of thumb:
Use a List chain when… | Use a Sequence when… |
|---|---|
| The collection is small or medium (thousands) | It is large, or comes from a file or a database cursor |
| The chain is one or two steps | It is long, with several intermediate results |
| You need the whole result | You stop early: first, take, any, find |
| You want to reuse or inspect intermediate results | The source is infinite (generateSequence, sequence { }) |
Compared with Java Streams: a Sequence can be iterated more than once if its source allows it (a sequence over an iterator or a file can’t), has no parallel mode, and has no primitive variants like LongStream, so numeric pipelines box. Its terminal operations have the same names as on lists (toList, sumOf, groupBy). Streams remain available from Kotlin and are the right tool for parallel or primitive-heavy processing.
Arrays
fun totalUnits(vararg units: Int): Int = units.sum() // vararg Int arrives as an IntArray (int[])
fun main() { val boxed: Array<Int> = arrayOf(120, 0, 35) // Integer[] in bytecode val primitive: IntArray = intArrayOf(120, 0, 35) // int[] in bytecode println(boxed.javaClass.simpleName + " / " + primitive.javaClass.simpleName) // -> Integer[] / int[]
// Arrays are Java arrays: identity equals and an unhelpful toString. println(primitive.toString().startsWith("[I@")) // -> true println(primitive.contentToString()) // -> [120, 0, 35] println(intArrayOf(1, 2) == intArrayOf(1, 2)) // -> false println(intArrayOf(1, 2) contentEquals intArrayOf(1, 2)) // -> true
// The collection operations work on arrays too, and return Lists. println(primitive.filter { it > 0 }) // -> [120, 35]
// Spread operator: pass an array where varargs are expected. println(totalUnits(*primitive, 5)) // -> 160
// Sized constructor with an initializer. println(IntArray(3) { it * 10 }.contentToString()) // -> [0, 10, 20]}The Kotlin twist is the naming: arrayOf(120, 0, 35) reads like the obvious choice and gives you Integer[], boxed. intArrayOf (and LongArray, ByteArray…) give the primitive arrays. Otherwise arrays are Java arrays, identity == included, with contentEquals and contentToString for what you meant. A vararg Int parameter arrives as an IntArray, and the spread operator * passes an existing array into one.
Strings and regex
fun main() { // split takes a literal delimiter, not a regex as in Java. println("SHW.1001.MH".split(".")) // -> [SHW, 1001, MH] println("SHW.1001.MH".split(Regex("\\d+"))) // -> [SHW., .MH]
// And it keeps trailing empty strings, which Java's split drops. println("dal,rice,,".split(",")) // -> [dal, rice, , ]
// trim() removes Unicode whitespace, such as the no-break spaces web forms like to send. // Java's trim() only removes characters up to U+0020, and strip() doesn't count U+00A0. val fromForm = "\u00A0Toor Dal\u00A0" println(fromForm.trim().length) // -> 8 @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") // deliberately calling Java's String.trim() println((fromForm as java.lang.String).trim().length) // -> 10
// Small helpers that replace a utility class. val code = "SHW-1001:MH" println(code.substringBefore(":")) // -> SHW-1001 println("12a".toIntOrNull() ?: -1) // -> -1
// Regex: find, findAll, destructured groups. val sku = Regex("""([A-Z]{3})-(\d{4})""") val match = sku.find("label: SHW-1001, alt SHW-2040") val (prefix, number) = match!!.destructured println("$prefix / $number") // -> SHW / 1001 println(sku.findAll("SHW-1001 SHW-2040").map { it.value }.toList()) // -> [SHW-1001, SHW-2040] println("SHW-1001" matches sku) // -> true}Kotlin’s String is java.lang.String with several hundred extension functions, so most of StringUtils is already there: substringBefore, padStart, removePrefix, toIntOrNull, lines, isBlank. Two of them share a name with a Java method and behave differently: split and trim (see the Gotchas). Regex wraps java.util.regex.Pattern. find returns a nullable MatchResult, findAll a lazy sequence, and destructured gives the groups as component1(), component2()….
kotlin.time and kotlin.uuid
import kotlin.time.Durationimport kotlin.time.Duration.Companion.hoursimport kotlin.time.Duration.Companion.millisecondsimport kotlin.time.Duration.Companion.minutesimport kotlin.time.Instantimport kotlin.time.measureTimedValueimport kotlin.time.toJavaInstantimport kotlin.uuid.Uuidimport kotlin.uuid.toJavaUuid
fun main() { // Duration: a value class over a Long, with unit-safe arithmetic. val shelfLife: Duration = 36.hours + 30.minutes println(shelfLife) // -> 1d 12h 30m println(shelfLife.inWholeMinutes) // -> 2190 println(250.milliseconds < 1.minutes) // -> true
// measureTimedValue: the result and how long it took, without System.nanoTime() bookkeeping. val (total, took) = measureTimedValue { (1..1_000).sum() } println("total=$total took=$took") // -> total=500500 took=…
// kotlin.time.Instant, convertible to java.time at framework boundaries. val restock = Instant.parse("2026-10-03T06:00:00Z") println(restock + 2.hours) // -> 2026-10-03T08:00:00Z println(restock.toJavaInstant().javaClass.name) // -> java.time.Instant
// kotlin.uuid.Uuid (Stable since 2.4.0), convertible to java.util.UUID. val id = Uuid.parse("550e8400-e29b-41d4-a716-446655440000") println(id.toJavaUuid().version()) // -> 4 println(Uuid.random().toString().length) // -> 36}Duration is a value class over a Long (Part 4), so 36.hours + 30.minutes costs no allocation, and units can’t be mixed up the way long timeoutMs parameters can. measureTimedValue replaces the System.nanoTime() bookkeeping. kotlin.time.Instant and kotlin.uuid.Uuid are the multiplatform counterparts of java.time.Instant and java.util.UUID, with toJavaInstant() and toJavaUuid() (and the reverse) for the boundaries.
Status in 2.4.20: Duration, measureTime, Clock, Instant, Uuid.parse, Uuid.random() and the Java conversions are all Stable, with no opt-in. The newer Uuid.generateV4() and Uuid.generateV7() still need @OptIn(ExperimentalUuidApi::class).
Step-by-Step Hands-On: The Nightly Stock Report, Rewritten
Code: kotlin-for-java-survivors/language/part07-collections-without-collectors (file report/StockReport.kt).
The nightly job reads a shelf-reading export, one line per shelf per store, and produces a report per store: total units and the SKUs at or below their reorder point. The first version read the export into a List, then ran filter, map, groupBy and mapValues over it, holding several export-sized lists at once. Kabir rewrites it.
Step 1 — The input and output types.
// Fragment of report/StockReport.kt// Step 1: one reading per shelf, and the report the nightly job produces per store.data class ShelfReading(val store: String, val sku: String, val units: Int, val reorderAt: Int)
data class StoreReport(val store: String, val totalUnits: Int, val lowStock: List<String>)Step 2 — Parse lazily. A Sequence<String> in, a Sequence<ShelfReading> out. Nothing runs yet. In production the lines come from File.useLines { }, which hands you the file as a sequence and closes it afterwards. List provides component1() to component5(), so the split line destructures (Part 4); a short line throws IndexOutOfBoundsException:
// Fragment of report/StockReport.kt// Step 2: parse lazily. In production the input is a file with millions of lines (File.useLines);// a Sequence keeps one line in flight instead of a List of all of them.fun parse(lines: Sequence<String>): Sequence<ShelfReading> = lines .filter { it.isNotBlank() && !it.startsWith("#") } .map { line -> val (store, sku, units, reorderAt) = line.split(",").map { it.trim() } ShelfReading(store, sku, units.toInt(), reorderAt.toInt()) }Step 3 — Aggregate as the readings stream past. groupingBy on a sequence plus fold keeps one mutable Totals per store, not every reading. The initialValueSelector form matters (see the Gotchas): it creates an accumulator per key, like a Collector’s supplier.
// Fragment of report/StockReport.kt// Step 3: aggregate per store as the readings stream past. groupingBy + fold keeps one// mutable running total per store, not every reading.private class Totals { var units = 0 val lowStock = mutableListOf<String>()}
fun report(readings: Sequence<ShelfReading>): List<StoreReport> = readings .groupingBy { it.store } .fold( initialValueSelector = { _, _ -> Totals() }, // one accumulator per store operation = { _, totals, reading -> totals.units += reading.units if (reading.units <= reading.reorderAt) totals.lowStock += reading.sku totals }, ) .map { (store, totals) -> StoreReport(store, totals.units, totals.lowStock.sorted().readOnlyForJava()) } .sortedBy { it.store } .readOnlyForJava()Step 4 — Hand Java a list it can’t change. readOnlyForJava() is a one-line extension (Part 6) over Collections.unmodifiableList, applied to every list the report exposes. Kotlin callers see a List, as before. Java callers get an UnsupportedOperationException instead of silently changing shared state. (The Assortment from the opening gets the other fix, a copy: fun skusSnapshot(): List<String> = skus.toList().)
// Fragment of report/StockReport.kt// Step 4: a read-only list that Java can't modify either.fun <T> List<T>.readOnlyForJava(): List<T> = Collections.unmodifiableList(this)Step 5 — Run it.
// Fragment of report/StockReport.kt// Step 5: run it, publish in batches, and let the Java utility try its luck.fun main() { val export = """ # store,sku,units,reorderAt PUN-014,SHW-1001,4,10 PUN-014,SHW-2040,35,5 MUM-002,SHW-1001,60,10 PUN-014,SHW-3001,0,4 MUM-002,SHW-3002,2,6 BLR-007,SHW-5001,300,50 """.trimIndent()
val reports = report(parse(export.lineSequence())) reports.forEach(::println) // -> StoreReport(store=BLR-007, totalUnits=300, lowStock=[]) // -> StoreReport(store=MUM-002, totalUnits=62, lowStock=[SHW-3002]) // -> StoreReport(store=PUN-014, totalUnits=39, lowStock=[SHW-1001, SHW-3001])
// Two stores per message: batches like these suit a Kafka producer. println(reports.chunked(2).map { batch -> batch.map { it.store } }) // -> [[BLR-007, MUM-002], [PUN-014]]
try { LegacyReports.sortInPlace(reports.last().lowStock) } catch (e: UnsupportedOperationException) { println("Java can't sort it in place either") // -> Java can't sort it in place either }}The pipeline now holds one line, one reading and one Totals per store, however large the export. The only collections it builds are the final, small per-store results. chunked(2) prepares the batches that Part 14 will publish to Kafka.
Tips, Tricks & Gotchas
Gotcha —
splitandtrimaren’t Java’s. Java’s"SHW.1001.MH".split(".")returns an empty array, because.is a regex. Kotlin’ssplit(".")splits on the literal dot, and keeps trailing empty strings that Java drops ("dal,rice,,"gives four elements, not two). Kotlin’strim()removes Unicode whitespace such as the no-break space, which Java’strim()andstrip()leave in place. A ported CSV parser or form handler changes behaviour in all three cases.
Gotcha —
remove(1)on aMutableList<Int>removes the value 1. Java’sList<Integer>hasremove(int index)andremove(Object), andremove(1)picks the index. Kotlin names themremoveAt(index)andremove(element), so ported code that removed by index now removes by value.
Gotcha —
fold(initialValue)shares one accumulator across all keys. On aGrouping, the initial value is used as-is for every key. With a mutable accumulator, every store ends up with every SKU. Usefold(initialValueSelector = { _, _ -> Acc() }) { … }.
Gotcha —
reversed()is a copy. On JDK 21+,List.reversed()is a live view of the original. Kotlin hides that JDK member on its own list types, soreversed()resolves to the stdlib extension and returns a snapshot: later additions don’t show up. Kotlin’sasReversed()is the live view.
All four, demonstrated:
class Shelf { val skus = mutableListOf<String>()}
fun main() { // remove(1) on a MutableList<Int> removes the element 1. Java's remove(int) removes index 1. val units = mutableListOf(5, 1, 7) units.remove(1) println(units) // -> [5, 7] units.removeAt(1) println(units) // -> [5]
// reversed() is a copy. Java 21's List.reversed() is a live view; Kotlin's extension wins. val aisle = mutableListOf("A1", "A2") val backwards = aisle.reversed() aisle += "A3" println(backwards) // -> [A2, A1]
// Grouping.fold(initialValue) shares ONE initial object between all keys. val readings = listOf("PUN" to "SHW-1001", "MUM" to "SHW-2040", "PUN" to "SHW-3001") val shared = readings.groupingBy { it.first }.fold(Shelf()) { shelf, (_, sku) -> shelf.apply { skus += sku } } println(shared.mapValues { it.value.skus }) // -> {PUN=[SHW-1001, SHW-2040, SHW-3001], MUM=[SHW-1001, SHW-2040, SHW-3001]}
// initialValueSelector creates one accumulator per key, like a Collector's supplier. val perStore = readings.groupingBy { it.first }.fold( initialValueSelector = { _, _ -> Shelf() }, operation = { _, shelf, (_, sku) -> shelf.apply { skus += sku } }, ) println(perStore.mapValues { it.value.skus }) // -> {PUN=[SHW-1001, SHW-3001], MUM=[SHW-2040]}}Tip — keep
associateByfor unique keys.Collectors.toMapthrows on a duplicate key;associateBysilently keeps the last value (the relabelledSHW-3001above). When duplicates are possible,groupByand check for lists with more than one element.
Key Takeaways
| Concept | Remember |
|---|---|
| Read-only interfaces | List/MutableList both compile to java.util.List; read-only is a compile-time view, not immutability |
| Factory results | Mostly ordinary JDK collections; only buildList & co. are read-only at runtime |
| Operations | Eager, inline, return new collections; no collect step |
| Java-named traps | split (literal, keeps empties), trim (Unicode), remove(Int) (by value), reversed() (copy) |
Grouping.fold | Use initialValueSelector for mutable accumulators |
| Sequences | Lazy, element by element; for large data, long chains, early exit, infinite sources |
| Streams | Still usable; asSequence() / asStream() convert; Streams for parallelism |
| Arrays | IntArray = int[], Array<Int> = Integer[]; contentEquals/contentToString |
| Time and IDs | Duration, Instant, Uuid Stable; Uuid.generateV4/V7 need opt-in |
Story Closing
The rewritten nightly job ran in a flat line on the GC dashboard. Assortment now handed out a copy, so the Java utility could sort it without touching Kabir’s list, and the report’s unmodifiable lists made one more in-place sort in the store-operations code fail loudly in their own test. They fixed it with one ArrayList copy on their side.
Kabir’s next task was to make the report reusable. The same pipeline should run over products, stores and suppliers, read from Shelfwise’s shared Java data-access library. He opened its main interface and found this:
public interface LegacyRepository<T extends Entity<ID>, ID extends Comparable<? super ID>> { List<? extends T> findAll(Predicate<? super T> filter, Comparator<? super T> order);}He drew the type hierarchy on the whiteboard to work out which way each extends and super pointed. Lena watched him get halfway, then wrote out and in next to two of the arrows.
In Part 8, Kabir learns how Kotlin moves variance from every use of a type to its declaration, and why that removes most of the wildcards.
This is Part 7 of a 16-part series: “Kotlin for Java Survivors: Life After Semicolons.”