Files
obsidian/Kotlin/Коллекции/Операции.md
T

20 KiB
Raw Blame History

title, description, tags
title description tags
Операции map, filter, groupBy, fold, zip и остальной арсенал операций над коллекциями
kotlin
collections

Операции над коллекциями - функции-расширения на 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