20 KiB
title, description, tags
| title | description | tags | ||
|---|---|---|---|---|
| Операции | map, filter, groupBy, fold, zip и остальной арсенал операций над коллекциями |
|
Операции над коллекциями - функции-расширения на Iterable<T>. Их около 150; здесь - те, что покрывают почти всю практику.
Все они делятся на две группы:
- возвращают коллекцию:
map,filter,sorted- можно строить цепочки - возвращают одно значение:
sum,first,count,fold- завершают цепочку
Преобразование
fun main() {
val list = listOf(1, 2, 3)
println(list.map { it * 2 }) // [2, 4, 6]
println(list.mapIndexed { i, x -> "$i:$x" }) // [0:1, 1:2, 2:3]
println(listOf("1", "a", "3").mapNotNull { it.toIntOrNull() }) // [1, 3]
println(list.flatMap { listOf(it, it * 10) }) // [1, 10, 2, 20, 3, 30]
println(listOf(listOf(1, 2), listOf(3)).flatten()) // [1, 2, 3]
println(list.zip(listOf("a", "b"))) // [(1, a), (2, b)]
println(list.withIndex().map { "${it.index}${it.value}" }) // [01, 12, 23]
}
mapNotNull - самая полезная из них: преобразование с отбрасыванием неудач в одном шаге, вместо map + filterNotNull.
flatMap нужен, когда преобразование даёт коллекцию, а результат нужен плоским:
data class Order5(val id: Int, val items: List<String>)
fun main() {
val orders = listOf(
Order5(1, listOf("хлеб", "молоко")),
Order5(2, listOf("сыр")),
)
println(orders.flatMap { it.items }) // [хлеб, молоко, сыр]
println(orders.map { it.items }) // [[хлеб, молоко], [сыр]]
}
Фильтрация
fun main() {
val list = listOf(1, 2, 3, 4, 5)
println(list.filter { it % 2 == 0 }) // [2, 4]
println(list.filterNot { it % 2 == 0 }) // [1, 3, 5]
println(list.filterIndexed { i, _ -> i > 2 }) // [4, 5]
println(listOf(1, null, 3).filterNotNull()) // [1, 3]
println(listOf<Any>(1, "a", 2).filterIsInstance<Int>()) // [1, 2]
println(list.take(2)) // [1, 2]
println(list.takeWhile { it < 3 }) // [1, 2]
println(list.dropWhile { it < 3 }) // [3, 4, 5]
println(list.distinct()) // [1, 2, 3, 4, 5]
println(listOf("aa", "ab", "ba").distinctBy { it.first() }) // [aa, ba]
}
partition делит на две коллекции за один проход:
fun main() {
val (even, odd) = listOf(1, 2, 3, 4).partition { it % 2 == 0 }
println("$even $odd") // [2, 4] [1, 3]
}
Поиск и проверки
fun main() {
val list = listOf(1, 2, 3, 4)
println(list.first { it > 2 }) // 3, бросает если нет
println(list.firstOrNull { it > 10 }) // null
println(list.find { it > 2 }) // 3 — то же, что firstOrNull
println(list.last()) // 4
println(list.single { it == 2 }) // 2, бросает если не ровно один
println(list.singleOrNull { it > 2 }) // null — их два
println(list.any { it > 3 }) // true
println(list.all { it > 0 }) // true
println(list.none { it > 10 }) // true
println(list.count { it % 2 == 0 }) // 2
println(3 in list) // true
println(list.indexOfFirst { it > 2 }) // 2
}
Поведение на пустой коллекции - источник ошибок:
fun main() {
val empty = emptyList<Int>()
println(empty.any { it > 0 }) // false
println(empty.all { it > 0 }) // true — вакуумная истина!
println(empty.none { it > 0 }) // true
println(empty.sum()) // 0
println(empty.firstOrNull()) // null
println(empty.maxOrNull()) // null
}
all на пустой коллекции возвращает true - это математически корректно, но в проверках вида «все элементы валидны» часто неожиданно.
Агрегация
fun main() {
val list = listOf(1, 2, 3, 4)
println(list.sum()) // 10
println(list.average()) // 2.5
println(list.maxOrNull()) // 4
println(list.minOrNull()) // 1
println(list.sumOf { it * 10 }) // 100
println(list.maxByOrNull { -it }) // 1
println(list.maxOfOrNull { it * 2 }) // 8
println(list.joinToString(prefix = "[", postfix = "]")) // [1, 2, 3, 4]
println(list.joinToString(limit = 2, truncated = "…")) // 1, 2, …
}
maxBy против maxOf - частая путаница:
data class User8(val name: String, val age: Int)
fun main() {
val users = listOf(User8("Иван", 30), User8("Пётр", 25))
println(users.maxByOrNull { it.age }) // User8(name=Иван, age=30) — элемент
println(users.maxOfOrNull { it.age }) // 30 — значение
}
fold и reduce
fun main() {
val list = listOf(1, 2, 3, 4)
println(list.fold(0) { acc, x -> acc + x }) // 10
println(list.fold(1) { acc, x -> acc * x }) // 24
println(list.reduce { acc, x -> acc + x }) // 10
println(list.runningFold(0) { acc, x -> acc + x }) // [0, 1, 3, 6, 10]
println(list.foldRight(0) { x, acc -> acc * 10 + x }) // 4321 — справа налево
println(emptyList<Int>().fold(0) { a, b -> a + b }) // 0
try {
emptyList<Int>().reduce { a, b -> a + b }
} catch (e: UnsupportedOperationException) {
println("reduce падает на пустой коллекции")
}
}
fold |
reduce |
|
|---|---|---|
| начальное значение | задаёте вы | первый элемент |
| тип результата | любой | тип элемента |
| пустая коллекция | вернёт начальное | исключение |
fold умеет менять тип, и это его главное применение:
fun main() {
val words = listOf("раз", "два", "три")
val lengths: Map<String, Int> = words.fold(mutableMapOf()) { acc, w ->
acc[w] = w.length
acc
}
println(lengths) // {раз=3, два=3, три=3}
println(words.fold(0) { acc, w -> acc + w.length }) // 9 — List<String> -> Int
}
Группировка
fun main() {
val words = listOf("яблоко", "арбуз", "банан", "боб")
println(words.groupBy { it.first() })
// {я=[яблоко], а=[арбуз], б=[банан, боб]}
println(words.groupBy(keySelector = { it.first() }, valueTransform = { it.length }))
// {я=[6], а=[5], б=[5, 3]}
println(words.groupingBy { it.first() }.eachCount())
// {я=1, а=1, б=2}
println(words.groupingBy { it.first() }.fold(0) { acc, w -> acc + w.length })
// {я=6, а=5, б=8}
}
groupingBy отличается от groupBy тем, что не создаёт промежуточные списки - он сразу агрегирует. Для подсчёта и суммирования по группам это и быстрее, и короче.
associate
fun main() {
val users = listOf("Иван", "Пётр", "Анна")
println(users.associateWith { it.length }) // {Иван=4, Пётр=4, Анна=4}
println(users.associateBy { it.first() }) // {И=Иван, П=Пётр, А=Анна}
println(users.associate { it.first() to it.length }) // {И=4, П=4, А=4}
}
| Функция | Ключ | Значение |
|---|---|---|
associateWith |
элемент | результат лямбды |
associateBy |
результат лямбды | элемент |
associate |
из Pair |
из Pair |
associateBy против groupBy - важная разница:
fun main() {
val words = listOf("банан", "боб")
println(words.associateBy { it.first() }) // {б=боб} — потеря!
println(words.groupBy { it.first() }) // {б=[банан, боб]}
}
associateBy при дубликате ключа молча перезаписывает. Если ключ не уникален, нужен groupBy.
Сортировка
fun main() {
val list = listOf(3, 1, 2)
println(list.sorted()) // [1, 2, 3]
println(list.sortedDescending()) // [3, 2, 1]
println(list.reversed()) // [2, 1, 3] — не сортировка!
println(list.shuffled().size) // 3
val users = listOf("Пётр" to 25, "Иван" to 30, "Анна" to 25)
println(users.sortedBy { it.second }) // [(Пётр, 25), (Анна, 25), (Иван, 30)]
println(users.sortedWith(compareBy({ it.second }, { it.first })))
// [(Анна, 25), (Пётр, 25), (Иван, 30)] — по возрасту, потом по имени
println(users.sortedWith(compareByDescending<Pair<String, Int>> { it.second }
.thenBy { it.first }))
// [(Иван, 30), (Анна, 25), (Пётр, 25)]
}
Сортировка Kotlin стабильна: элементы с равными ключами сохраняют исходный порядок.
На MutableList есть сортировка на месте - без ed в имени:
fun main() {
val list = mutableListOf(3, 1, 2)
list.sort() // на месте
println(list) // [1, 2, 3]
list.sortByDescending { it }
println(list) // [3, 2, 1]
val copy = list.sorted() // новая коллекция
println("$list / $copy") // [3, 2, 1] / [1, 2, 3]
}
Соглашение в stdlib: sort() меняет на месте, sorted() возвращает копию. То же с reverse()/reversed(), shuffle()/shuffled().
Разбиение и объединение
fun main() {
val list = listOf(1, 2, 3, 4, 5)
println(list.chunked(2)) // [[1, 2], [3, 4], [5]]
println(list.chunked(2) { it.sum() }) // [3, 7, 5]
println(list.windowed(3)) // [[1,2,3], [2,3,4], [3,4,5]]
println(list.windowed(2, step = 2, partialWindows = true)) // [[1,2], [3,4], [5]]
println(list.zipWithNext()) // [(1,2), (2,3), (3,4), (4,5)]
println(list.zipWithNext { a, b -> b - a }) // [1, 1, 1, 1]
}
windowed - скользящее окно: удобно для расчёта разниц, средних и сглаживания.
Операции на Map
fun main() {
val map = mapOf("a" to 1, "b" to 2, "c" to 3)
println(map.filter { (_, v) -> v > 1 }) // {b=2, c=3}
println(map.mapValues { it.value * 10 }) // {a=10, b=20, c=30}
println(map.mapKeys { it.key.uppercase() }) // {A=1, B=2, C=3}
println(map.filterKeys { it != "a" }) // {b=2, c=3}
println(map.maxByOrNull { it.value }?.key) // c
println(map.entries.sumOf { it.value }) // 6
println(map.toList().map { (k, v) -> "$k$v" }) // [a1, b2, c3]
}
Деструктуризация в лямбде ({ (k, v) -> }) работает для Map.Entry и для Pair.
Практический пример
data class Sale(val region: String, val product: String, val amount: Long)
fun main() {
val sales = listOf(
Sale("Север", "хлеб", 100),
Sale("Север", "молоко", 200),
Sale("Юг", "хлеб", 150),
Sale("Юг", "сыр", 300),
)
val byRegion = sales.groupBy { it.region }.mapValues { (_, v) -> v.sumOf { it.amount } }
println(byRegion) // {Север=300, Юг=450}
val top = sales.maxByOrNull { it.amount }
println("${top?.product}: ${top?.amount}") // сыр: 300
val products = sales.map { it.product }.distinct().sorted()
println(products) // [молоко, сыр, хлеб]
val report = byRegion.entries
.sortedByDescending { it.value }
.joinToString("\n") { "${it.key}: ${it.value}" }
println(report) // Юг: 450\nСевер: 300
}
[!info] Отличия от Java
Java Stream Kotlin stream().map(f).collect(toList())map(f)stream().filter(p).collect(toList())filter(p)collect(groupingBy(f))groupBy(f)collect(toMap(k, v))associate { k to v }reduce(0, Integer::sum)fold(0) { a, b -> a + b }mapToInt(f).sum()sumOf(f)flatMap(x -> x.stream())flatMap { it }collect(partitioningBy(p))partition(p)anyMatch(p)any(p)нет аналога mapNotNull,windowed,zipWithNext,groupingByГлавное - не нужен
.stream()и.collect(). В Java любая операция требует входа в стрим и выхода из него; это ~25 символов обвязки на каждую цепочку. В Kotlin операции объявлены прямо наIterable, поэтому короткие цепочки читаются как одно выражение:fun main() { println(listOf(1, 2, 3).filter { it > 1 }.sumOf { it * 10 }) // 50 }List.of(1, 2, 3).stream().filter(x -> x > 1).mapToInt(x -> x * 10).sum();Цена - энергичность. Java-стрим ленив: промежуточные операции не создают коллекций. Kotlin-операции на
Iterableсоздают новый список на каждом шаге. Для цепочки из 3 операций по списку из 1000 элементов это 3 списка. Ленивый аналог - Sequence, и он почти дословно соответствуетStream.
mapNotNullиgroupingByаналогов в Java не имеют. В Java фильтрация с преобразованием - этоmap+filter(Objects::nonNull)илиflatMapсOptional.stream(). Подсчёт по группам -groupingBy(f, counting()), то есть вложенные коллекторы, которые плохо читаются.
Optionalв цепочках не нужен. Java-стримы возвращаютOptionalизfindFirstиmax; в Kotlin это nullable-тип, с которым работают обычные?.и?::fun main() { println(listOf(1, 2, 3).maxOrNull() ?: 0) // 3 }Сравнить: Функции в Java
[!warning] Подводные камни Каждая операция создаёт новую коллекцию:
fun main() { val big = List(100_000) { it } val result = big.map { it * 2 }.filter { it % 3 == 0 }.take(5) println(result) // [0, 6, 12, 18, 24] }
mapобработал все 100 000 элементов и создал список,filter- ещё один, и только потомtakeвзял пять. СasSequence()работы было бы на порядки меньше.
allна пустой коллекции -true:fun main() { val errors = emptyList<String>() println(errors.all { it.isNotBlank() }) // true println(errors.isNotEmpty() && errors.all { it.isNotBlank() }) // false }Проверка «все данные валидны» на пустом входе пройдёт. Если пустота недопустима, проверяйте её отдельно.
associateByтеряет дубликаты молча - при повторе ключа остаётся последний элемент. Для неуникальных ключей нуженgroupBy.
reduceпадает на пустой коллекции,fold- нет. В коде, где коллекция может быть пустой,reduce- потенциальное исключение.
sortedByс nullable-ключом ставитnullпервыми:fun main() { val list = listOf("b" to 2, "a" to null, "c" to 1) println(list.sortedBy { it.second }) // [(a, null), (c, 1), (b, 2)] println(list.sortedWith(compareBy(nullsLast()) { it.second })) // [(c, 1), (b, 2), (a, null)] }
reversed()- не сортировка, а переворот.listOf(3, 1, 2).reversed()даёт[2, 1, 3].
sort()иsorted()легко перепутать.sort()молча меняет исходную коллекцию - если она пришла извне, это неожиданная мутация:fun process(items: MutableList<Int>): List<Int> { items.sort() // испортили аргумент вызывающего return items } fun main() { val original = mutableListOf(3, 1, 2) process(original) println(original) // [1, 2, 3] — вызывающий этого не просил }Принимайте
ListвместоMutableList- тогдаsort()просто не скомпилируется.
first()иsingle()бросают, а не возвращаютnull. Версии сOrNullбезопасны;singleвдобавок падает, если элементов больше одного.
flatMap { it }наList<List<T>>работает, ноflatten()яснее.Цепочка из
filterпослеmapделает лишнюю работу.filterпередmapобрабатывает меньше элементов:fun main() { val list = List(10) { it } println(list.map { it * 2 }.filter { it > 10 }) // [12, 14, 16, 18], 10 преобразований println(list.filter { it > 5 }.map { it * 2 }) // [12, 14, 16, 18], 4 преобразования }Здесь результаты совпадают, а работы во втором случае вдвое с лишним меньше. Но совпадают они не всегда: перестановка
filterиmapменяет смысл, если предикат проверяет уже преобразованное значение. Сначала убедитесь в эквивалентности, потом выбирайте по стоимости.
joinToStringпо умолчанию без ограничения длины. Для списка на миллион элементов он построит огромную строку; в логах используйтеlimit.
Далее: Sequence