1. Договоримся о данных: как отчёты получают расходы
Когда говорят «сделай отчёт», новичок часто представляет себе огромный println, который каким-то чудом всё посчитает и красиво нарисует. На практике отчёт живёт дольше, чем вдохновение: к нему возвращаются, его дополняют, его сравнивают с другими отчётами. Поэтому первое взрослое решение — договориться о форме входных данных и держать её стабильной хотя бы на этом этапе курса (без классов и ООП).
В нашем практическом консольном приложении про расходы (условный «мини‑трекер») будем считать, что один расход — это пара:
- first: категория (String)
- second: сумма (Int) в условных «единицах», главное: целое число
То есть весь список расходов — это List<Pair<String, Int>>.
Вот небольшой пример данных, с которыми будем работать дальше:
fun main() {
val expenses: List<Pair<String, Int>> = listOf(
" food " to 120,
"FOOD" to 80,
"taxi" to 250,
"Books" to 90
)
println(expenses)
// [( food , 120), (FOOD, 80), (taxi, 250), (Books, 90)]
}
Да, категории тут «грязные» (пробелы, разный регистр). Это не ошибка: это реалистично. Пользователь всегда найдёт способ ввести данные так, чтобы ваша программа почувствовала себя неуверенно. Мы просто будем умнее.
2. Общий шаг: нормализация категории
Если вы хотя бы раз группировали строки «как есть», вы видели магию в кавычках: "Food" и " food " внезапно становятся разными категориями, и отчёт начинает выглядеть так, будто человек питается в трёх параллельных вселенных. Чтобы этого не случалось, нормализация — обязательный общий шаг для всех отчётов. И да, нормализация — это не «красивый вывод», а приведение данных к единому внутреннему виду до вычислений.
Сделаем маленькую функцию нормализации категории: trim() + lowercase(). Она должна быть предсказуемой, короткой и одинаковой везде.
fun normalizeCategory(raw: String): String {
return raw.trim().lowercase()
}
fun main() {
println(normalizeCategory(" FOOD ")) // food
println(normalizeCategory("Books")) // books
}
Теперь применим нормализацию ко всем расходам. Обратите внимание: мы не меняем исходный список «на месте», а строим новый (это помогает держать вычисления чистыми и уменьшает сюрпризы).
fun normalizeExpenses(expenses: List<Pair<String, Int>>): List<Pair<String, Int>> {
return expenses.map { (cat, amount) ->
normalizeCategory(cat) to amount
}
}
fun main() {
val raw = listOf(" FOOD " to 10, "food" to 5)
val normalized = normalizeExpenses(raw)
println(normalized) // [(food, 10), (food, 5)]
}
Почти все наши отчёты дальше будут начинаться словами: «берём расходы → нормализуем категории → считаем». Это и есть «единый стиль».
3. Каркас отчётов: расчёт отдельно, вывод отдельно
Очень хочется «сделать красиво» сразу: в процессе подсчёта печатать строки, добавлять эмодзи (но мы в Kotlin‑курсе, а не в Telegram‑стикерах), выравнивать пробелами… и в итоге смешать расчёт и вывод так, что поменять формат станет больнее, чем вставать в понедельник.
Поэтому фиксируем правило дня:
- функции расчёта возвращают числа/структуры (Int, Map, List<Pair<...>>)
- функции рендера превращают результат в String (или печатают, но лучше возвращают строку)
- main (или командный обработчик) решает, что и когда показывать
Схематично это выглядит так:
flowchart LR
A[raw expenses] --> B[normalize]
B --> C[calculate totals]
C --> D[optional: filter threshold]
D --> E[optional: sort + top-N]
E --> F[render string]
В этой лекции мы реализуем 4 отчёта (total, byCategory, top, threshold) так, чтобы они ощущались как родственники, а не как случайные прохожие.
4. Отчёт total: сумма всех расходов
Отчёт total кажется самым простым: «сложи все суммы». Но именно на таких местах начинаются типичные ошибки новичков: внешний счётчик, забыли обнулить, пустой список «сломал логику», где-то в середине внезапно println и т.д. Хорошая новость: у Kotlin есть fold, который как раз предназначен для аккуратного накопления результата.
Сделаем функцию расчёта общей суммы. Мы суммируем second у каждой пары.
fun calcTotal(expenses: List<Pair<String, Int>>): Int {
return expenses.fold(0) { acc, item ->
acc + item.second
}
}
fun main() {
val expenses = listOf("food" to 10, "taxi" to 20)
println(calcTotal(expenses)) // 30
}
Почему fold(0), а не reduce? Потому что fold умеет работать даже с пустым списком: она стартует с начального значения, и результат будет предсказуемым.
Теперь сделаем рендер для total. Пусть это будет простая строка:
fun renderTotal(total: Int): String {
return "Total: $total"
}
fun main() {
println(renderTotal(60)) // Total: 60
}
И сразу маленький «сборочный» пример: нормализация + total.
fun main() {
val raw = listOf(" FOOD " to 120, "taxi" to 250)
val expenses = normalizeExpenses(raw)
val total = calcTotal(expenses)
println(renderTotal(total)) // Total: 370
}
5. Отчёт byCategory: сумма по категориям
Отчёт «по категориям» — это первый момент, когда данные меняют форму: мы переходим от списка отдельных операций к агрегированным итогам. И это нормально: отчёт как раз для того и существует, чтобы превращать «сырые события» в «понятные итоги». Главное — не пугаться смены типов и делать её осознанно.
Наша цель: получить Map<String, Int>, где ключ — категория, значение — сумма по этой категории.
Сделаем это в два понятных шага: groupBy → mapValues + fold.
fun totalsByCategory(expenses: List<Pair<String, Int>>): Map<String, Int> {
val grouped: Map<String, List<Pair<String, Int>>> =
expenses.groupBy { it.first }
return grouped.mapValues { (_, items) ->
items.fold(0) { acc, item -> acc + item.second }
}
}
fun main() {
val expenses = listOf("food" to 10, "food" to 5, "taxi" to 7)
println(totalsByCategory(expenses)) // {food=15, taxi=7}
}
Тут полезно на секунду остановиться и проговорить: groupBy создаёт списки внутри карты. Это удобно для понимания, но иногда тяжеловато по памяти. В прошлых днях вы видели groupingBy { ... }.eachCount() и вообще идею, что можно группировать «без явных списков». Сегодня мы не гоняемся за оптимизацией, мы гоняемся за читабельностью.
Теперь рендер. Важный нюанс: у Map порядок печати не стоит считать «контрактом отчёта». Если вы хотите детерминированный вывод, вы сортируете сами. Это прямо связано с темой упорядочивания коллекций: сортировка по ключу/значению должна быть явной, иначе порядок становится «как повезёт».
Сделаем рендер «табличкой». Для простоты: сортируем по названию категории.
fun renderByCategory(totals: Map<String, Int>): String {
val rows: List<Pair<String, Int>> = totals.entries
.map { it.key to it.value }
.sortedBy { (cat, _) -> cat }
val sb = StringBuilder()
for ((cat, sum) in rows) {
sb.append(cat).append(": ").append(sum).append('\n')
}
return sb.toString()
}
fun main() {
val totals = mapOf("taxi" to 250, "food" to 200)
print(renderByCategory(totals))
// food: 200
// taxi: 250
}
Обратите внимание: мы возвращаем строку. А печатать её или писать в файл (когда-нибудь потом) — решение вызывающего кода.
6. threshold: порог как отдельный шаг
Порог (threshold) часто появляется в отчётах как «покажи только значимые строки». Например: «покажи категории, где сумма >= 100». Ловушка здесь простая: порог можно применить в неправильный момент и получить странные результаты. Чтобы не спорить с будущим собой, делаем порог отдельным чистым шагом, который работает с уже агрегированными данными.
То есть threshold — это фильтрация итогов, а не фильтрация отдельных операций. Если фильтровать операции, логика становится другой: вы начнёте выбрасывать мелкие покупки, хотя категория суммарно могла быть большой.
Сделаем функцию, которая принимает Map<String, Int> и возвращает список пар, удобный для дальнейшей сортировки/рендера:
fun applyThreshold(
totals: Map<String, Int>,
threshold: Int
): List<Pair<String, Int>> {
return totals.entries
.map { it.key to it.value }
.filter { (_, sum) -> sum >= threshold }
}
fun main() {
val totals = mapOf("taxi" to 250, "food" to 90, "books" to 110)
println(applyThreshold(totals, threshold = 100))
// [(taxi, 250), (books, 110)]
}
Сейчас это выглядит как «ну да, фильтр». Но смысл в архитектуре: этот шаг можно вставлять в разные отчёты одинаково, и код будет читаться как конструктор: «посчитали → отфильтровали → отсортировали → вывели».
7. Отчёт top: топ‑N категорий по сумме
Top‑N — это типичный «представленческий» шаг: мы уже всё посчитали, а теперь решаем, что показать первым. Сортировки и take(n) логично делать ближе к выводу: они не меняют смысл данных, они меняют то, как мы их представляем пользователю.
Сделаем функцию topCategories, которая принимает уже агрегированные итоги. А порог подключим как опцию: сначала фильтр по порогу, потом сортировка по убыванию, потом take(n).
fun topCategories(
totals: Map<String, Int>,
n: Int,
threshold: Int
): List<Pair<String, Int>> {
return totals.entries
.map { it.key to it.value }
.filter { (_, sum) -> sum >= threshold }
.sortedByDescending { (_, sum) -> sum }
.take(n)
}
fun main() {
val totals = mapOf("taxi" to 250, "food" to 200, "books" to 90)
println(topCategories(totals, n = 2, threshold = 100))
// [(taxi, 250), (food, 200)]
}
Здесь порядок особенно важен. Если вы сначала сделаете take(n), а потом примените порог, вы можете «потерять» строки: вы взяли первые N до фильтрации, а потом часть выкинули, и отчёт внезапно показал меньше строк, чем мог бы. Поэтому фильтрация по порогу должна быть раньше take(n).
Теперь рендер топа. Он почти такой же, как рендер byCategory, но порядок уже задан (мы сортировали по сумме). Поэтому рендер просто печатает строки в текущем порядке:
fun renderTop(items: List<Pair<String, Int>>): String {
val sb = StringBuilder()
for ((cat, sum) in items) {
sb.append(cat).append(": ").append(sum).append('\n')
}
return sb.toString()
}
fun main() {
val top = listOf("taxi" to 250, "food" to 200)
print(renderTop(top))
// taxi: 250
// food: 200
}
8. Собираем отчёты v0 в единый стиль
Когда функций становится больше двух, мозг начинает ценить ритуалы. Хороший стиль — это не «красиво», а «предсказуемо»: вы открываете файл через неделю и сразу понимаете, где что искать. Поэтому соберём всё в небольшой пример, похожий на то, как будет жить код в нашем приложении (пусть пока прямо в одном файле).
Обратите внимание на имена промежуточных val: они описывают шаги пайплайна.
fun main() {
val rawExpenses = listOf(
" food " to 120,
"FOOD" to 80,
"taxi" to 250,
"Books" to 90,
"books " to 30
)
val expenses = normalizeExpenses(rawExpenses)
val total = calcTotal(expenses)
println(renderTotal(total)) // Total: 570
val totalsByCat = totalsByCategory(expenses)
print(renderByCategory(totalsByCat))
// books: 120
// food: 200
// taxi: 250
val top = topCategories(totalsByCat, n = 2, threshold = 100)
print(renderTop(top))
// taxi: 250
// food: 200
}
Это уже выглядит как набор отчётов, а не как один «божественный println». Да, пока всё простое. Но именно такой стиль выдерживает усложнение: вы добавляете отчёт — и он ложится в ту же схему.
Контракты отчётов: что принимаем и что возвращаем
Когда вы пишете много функций, полезно держать контракты на виду. Это снижает шанс «впихнуть невпихуемое» и начать передавать Map туда, где ждут List. Ниже — компактная таблица наших функций v0.
| Функция | Принимает | Возвращает | Смысл |
|---|---|---|---|
|
|
|
Приводим категории к единому виду |
|
|
|
Сумма всех расходов |
|
|
|
Суммы по категориям |
|
, |
|
Отбираем категории по порогу |
|
, , |
|
Топ категорий по сумме |
|
числа/структуры | |
Готовим текст отчёта |
Эта табличка — не «документация ради документации», а способ не потеряться, когда отчётов станет больше.
9. Типичные ошибки
Ошибка №1: считать top по «сырым» операциям, а не по агрегированным итогам.
Если взять исходный список расходов и просто отсортировать его по сумме, вы получите «самые большие отдельные траты», а не «самые дорогие категории». Иногда это даже полезный отчёт, но это другой отчёт. Чтобы top был именно по категориям, сначала делайте totalsByCategory, а уже потом сортируйте итоги.
Ошибка №2: применять threshold после take(n) и удивляться, почему строк меньше, чем ожидалось.
Логика «сначала взять топ‑2, потом отфильтровать по порогу» приводит к тому, что вы можете выкинуть одну строку и остаться с топ‑1, хотя в данных были кандидаты, которые прошли бы порог и могли занять место. Порог — это фильтр кандидатов, а take(n) — это финальный «обрезатель», поэтому порог ставят раньше.
Ошибка №3: печатать внутри функций расчёта.
Как только вы делаете println внутри totalsByCategory или calcTotal, вы ломаете переиспользуемость. Сегодня вы печатаете в консоль, завтра захотите собрать одну большую строку, послезавтра — сохранить в файл. Если расчёт возвращает значение, а вывод делается отдельно, вы не загоняете себя в угол.
Ошибка №4: ожидать, что Map «сам по себе» печатается в нужном порядке.
Даже если «у вас сейчас так совпало», это не контракт. Отчёт должен быть детерминированным: либо сортируйте по ключу, либо по значению, либо явно документируйте порядок. В наших примерах мы перед выводом превращаем entries в список пар и сортируем — это делает результат стабильным и читаемым.
Ошибка №5: забыть нормализацию категории и получить “FOOD”, “food” и “ food ” как три разных мира.
Это классика: вы группируете по строке, а строка «грязная». В отчётах это особенно больно, потому что человек смотрит на итог и делает выводы. Поэтому нормализация — общий шаг для всех отчётов, и лучше держать её в одной функции, чтобы не разъехались правила.
Ошибка №6: использовать reduce там, где список может быть пустым, и ловить исключение.
reduce требует хотя бы один элемент, иначе ей «не с чего начинать». Для отчётов пустой список — нормальная ситуация (пользователь ещё ничего не добавил), поэтому для сумм и накоплений выбирайте fold со стартовым значением. Это делает поведение не только безопасным, но и логичным: сумма пустого набора — ноль, и никто не плачет.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ