1. Частоти: що саме ми хочемо отримати
Коли ви вперше чуєте слово «частоти», легко уявити собі нудну статистику. Насправді це одна з найкорисніших мікрооперацій над даними. Частоти відповідають на запитання «скільки разів трапилося…». Скільки витрат у кожній категорії, скільки разів користувач вводив ту чи іншу команду, скільки слів кожного типу є в тексті — усе це про частоти.
Якщо говорити мовою Kotlin-типів, зазвичай нам потрібна така структура:
- ключ — це «що рахуємо» (категорія, слово, перша літера, число тощо)
- значення — це «скільки разів трапилося»
Тобто результатом буде мапа частот:
Map<K, Int>
Приклад «людською мовою»:
- "food" -> 12 (у категорії food є 12 записів)
- "taxi" -> 3
- "kotlin" -> 2
У цей момент мозок часто підказує: «давайте зробимо MutableMap і вручну збільшуватимемо лічильник». Це нормальний план. Але стандартна бібліотека Kotlin уміє зробити те саме в один рядок. Саме тут і зʼявляється звʼязка groupingBy { ... }.eachCount().
2. Чому groupBy не завжди підходить для підрахунку
Коли ви вже знаєте groupBy, виникає цілком природна думка: «Окей, згрупую, а потім візьму size у кожної групи». Працює? Працює. Зручно? Іноді так. Але є нюанс: groupBy повертає Map<K, List<T>>. Тобто для кожного ключа він зберігає цілий список елементів.
А якщо вам потрібна лише відповідь «скільки», зберігати списки вже зайве:
- списки займають памʼять;
- списки треба створювати й наповнювати;
- потім ви все одно перетворюєте список на число (size) і викидаєте подробиці.
Це трохи схоже на ситуацію: вам потрібно знати, «скільки людей прийшло на вечірку», а ви замість цього заводите окрему кімнату для кожного імені, заганяєте туди людей, а потім рахуєте, скільки людей у кімнаті… і тут же всіх виганяєте. Потужно, але трохи театрально.
Щоб «просто порахувати», Kotlin пропонує інший шлях. Спершу ви кажете: «я вмію обчислювати ключ для кожного елемента». А потім просите: «порахуй, скільки елементів має кожен ключ». Саме це й робить groupingBy { ... }.eachCount().
3. Що таке groupingBy { ... } і чому це ще не Map
Після groupBy у багатьох виникає очікування: «будь-яке групування повертає Map». Але groupingBy працює інакше. Він повертає не мапу, а спеціальний обʼєкт типу Grouping — своєрідну «заготівлю для групування». У ньому зберігається логіка обчислення ключа, а конкретний результат зʼявляється пізніше — коли ви застосовуєте агрегувальну операцію.
Спробуймо поглянути на це інтуїтивно, а не лише через типи. Думку можна розбити на два кроки:
- «Ось правило, як з елемента отримати ключ»
- «Застосуйте до цього правила операцію: наприклад, порахуйте елементи»
Код виглядає так:
fun main() {
val words = listOf("one", "two", "one")
val grouping = words.groupingBy { it } // поки що не Map!
val freq = grouping.eachCount() // ось тепер Map<String, Int>
println(freq) // {one=2, two=1}
}
Тут важливо відчути: groupingBy { ... } — це як «оформити заявку», а eachCount() — як «отримати готовий звіт».
4. eachCount(): отримуємо мапу частот Map<K, Int>
Коли ви викликаєте eachCount(), Kotlin робить рівно те, що нам потрібно: повертає мапу, у якій для кожного ключа записано кількість елементів.
Найбазовіший приклад: частоти слів
fun main() {
val words = listOf("one", "two", "one", "three", "two")
val freq: Map<String, Int> = words
.groupingBy { it }
.eachCount()
println(freq) // {one=2, two=2, three=1}
}
Зауважте приємний момент: нам не потрібні ні MutableMap, ні put, ні «якщо помітили ключ — збільш, інакше — постав 1». Kotlin робить усе це сам.
Приклад з обчислюваним ключем: групуємо за першою літерою
Цей приклад добрий тим, що ключ (Char) відрізняється від початкового елемента (String).
fun main() {
val words = listOf("one", "two", "three", "four", "five")
val freqByFirstChar = words.groupingBy { it.first() }.eachCount()
for ((ch, count) in freqByFirstChar) {
println("$ch -> $count")
// o -> 1
// t -> 2
// f -> 2
}
}
Тут ви одночасно закріплюєте одразу три навички: лямбду в groupingBy, роботу з Char і обхід Map через деконструкцію (key, value).
5. Нормалізація ключа перед підрахунком
У реальних даних найчастіше проблема не в підрахунку, а в тому, що «одне й те саме» виглядає по-різному. Пробіли, регістр, випадкові зайві символи — усе це створює «хибно різні ключі».
Тут варто запамʼятати просте правило: нормалізуйте ключ прямо всередині groupingBy { ... }. Саме там вирішується, що вважати «однаковим».
fun main() {
val drinks = listOf("Tea", " tea ", "TEA", "Coffee")
val freq = drinks
.groupingBy { it.trim().lowercase() }
.eachCount()
println(freq) // {tea=3, coffee=1}
}
Це один із тих моментів, де Kotlin буквально підштовхує до правильного дизайну: «ключ групування» — окреме поняття. Не «як у початковому введенні», а «як у вашій внутрішній моделі даних».
Якщо у вашому проєкті категорії помітно «пливуть» (наприклад, користувач увів " Taxi " замість "taxi"), нормалізація не просто поліпшує статистику — вона рятує сам сенс звіту.
6. Практика: рахуємо кількість витрат за категоріями
Відсьогодні в курсі вже є практичний консольний проєкт (ми стартували його на колекціях): умовний облік витрат. Поки що без ООП, тож записи зберігатимемо максимально просто — як Triple(category, amount, note).
Зробімо невелику заготовку даних і функцію, яка рахує «скільки записів у кожній категорії». Зверніть увагу: сьогодні ми рахуємо кількість записів, а не суму грошей. Сума — теж важлива річ, але це вже інша операція.
Дані та підрахунок кількості за категорією
fun main() {
val expenses: List<Triple<String, Int, String>> = listOf(
Triple("food", 120, "groceries"),
Triple("Food ", 80, "snack"),
Triple("taxi", 250, "airport"),
Triple("TAXI", 90, "home")
)
val countsByCategory = expenses
.groupingBy { (category, _, _) -> category.trim().lowercase() }
.eachCount()
println(countsByCategory) // {food=2, taxi=2}
}
Тут корисно зупинитися й «прочитати» лямбду очима: (category, _, _) -> .... Це звичайна деконструкція Triple, яку ви вже знаєте з теми про Pair/Triple. Ми прямо кажемо: «ключ — це нормалізована категорія».
Виносимо у функцію (щоб main не розростався)
fun countExpensesByCategory(expenses: List<Triple<String, Int, String>>): Map<String, Int> {
return expenses
.groupingBy { (category, _, _) -> category.trim().lowercase() }
.eachCount()
}
fun main() {
val expenses = listOf(
Triple("food", 120, "groceries"),
Triple("Food ", 80, "snack"),
Triple("taxi", 250, "airport")
)
val counts = countExpensesByCategory(expenses)
println(counts) // {food=2, taxi=1}
}
Із практичного погляду це дуже важливий крок: countExpensesByCategory(...) стає маленькою цеглинкою для звітів. Вона повертає чистий результат (мапу), а не друкує все сама. Отже, пізніше ми зможемо красиво форматувати вивід так, як нам потрібно.
7. Ключ може бути будь-яким: рядки, числа, булеві значення
Іноді здається, що частоти — це «тільки для слів». Насправді ключем може бути будь-який тип: Int, Char, Boolean, навіть складніші речі (але ми поки не ускладнюємо — бережемо психіку).
Частоти чисел
fun main() {
val numbers = listOf(10, 10, 20, 30, 30, 30)
val freq = numbers.groupingBy { it }.eachCount()
for ((n, count) in freq) {
println("$n -> $count")
// 10 -> 2
// 20 -> 1
// 30 -> 3
}
}
Частоти «парне / непарне»
Ключем буде Boolean: true для парних і false для непарних. Звучить дивно рівно доти, доки ви не побачите, наскільки це зручно.
fun main() {
val numbers = listOf(1, 2, 3, 4, 5, 6)
val freq = numbers
.groupingBy { it % 2 == 0 }
.eachCount()
println(freq) // {false=3, true=3}
}
Якщо потім ви захочете вивести це людськими словами («парних стільки-то»), це вже буде питання форматування, а не підрахунку. Тобто відповідальність розділено правильно.
Ручний лічильник vs eachCount()
Іноді корисно один раз побачити, «як було б вручну», щоб краще оцінити, що дає стандартна бібліотека. Уявіть: вам дали завдання «порахувати частоти категорій», і ви пишете це через MutableMap.
fun main() {
val categories = listOf("food", "food", "taxi")
val counts = mutableMapOf<String, Int>()
for (c in categories) {
val old = counts[c] ?: 0
counts[c] = old + 1
}
println(counts) // {food=2, taxi=1}
}
Це нормальний код. Він навіть корисний, щоб зрозуміти суть алгоритму. Але в реальному проєкті хочеться коротше й виразніше:
fun main() {
val categories = listOf("food", "food", "taxi")
val counts = categories.groupingBy { it }.eachCount()
println(counts) // {food=2, taxi=1}
}
Важлива думка: eachCount() — не «магія з космосу». Це просто акуратна, стандартна реалізація шаблону «лічильник за ключем», яка робить код менш шумним.
8. Типові помилки під час роботи з groupingBy().eachCount()
Помилка №1: очікувати, що groupingBy { ... } одразу поверне Map.
Після groupBy рука сама пише val m = list.groupingBy { ... }, і хочеться тут же звертатися як до мапи: m["key"]. Але groupingBy повертає проміжний обʼєкт (за змістом — «план групування»), а мапу ви отримуєте лише після операції на кшталт eachCount(). Якщо у вас у голові крутиться запитання «чому не виходить звертатися як до Map», значить, ви просто забули про другий крок.
Помилка №2: рахувати частоти за «брудним» ключем і дивуватися дивним результатам.
Якщо категорії "Taxi", " taxi " і "TAXI" вважаються різними, проблема не в eachCount(). Він чесно рахує те, що ви попросили рахувати. Рішення — нормалізувати ключ у groupingBy: найчастіше вистачає trim().lowercase(). Це особливо критично у звітах: неправильний ключ = неправильна статистика.
Помилка №3: плутати «кількість записів» і «суму значень».
eachCount() рахує, скільки елементів потрапило в групу. Якщо у вас витрати ("food", 100) і ("food", 200), результат за ключем "food" буде 2, а не 300. Це не помилка й не підступ — це різні завдання. Сьогодні ми розвʼязуємо завдання «скільки», а не «на яку суму».
Помилка №4: друкувати мапу й чекати гарного порядку.
Коли ви робите println(freq), Kotlin друкує мапу «як вийде» (порядок залежить від внутрішньої реалізації). Для налагодження це нормально, але для звіту майже завжди потрібен окремий крок: відсортувати, відформатувати, вирівняти. Якщо у вас зʼявляється відчуття «усе правильно, але виглядає негарно», ви вперлися не в проблему підрахунку, а в задачу подання результату. І це абсолютно очікувано.
Помилка №5: робити надто складний ключ прямо всередині лямбди й втрачати читабельність.
Іноді хочеться написати в groupingBy пів екрана логіки. Формально можна, але код швидко стає нечитабельним. Якщо ключ обчислюється складно, краще винести його в окрему функцію (наприклад, normalizeCategory(category: String): String), а в groupingBy залишити один зрозумілий рядок.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ