1. Чому звітам потрібні чисті обчислення
Звіти — саме те місце в програмі, де розробник особливо легко починає «і рахувати, і друкувати, і дорогою трохи підправляти дані». Здається, що так швидше: ось тут println, тут сортування «на місці», тут якийсь лічильник у var — «та й так же все зрозуміло». Проблема в тому, що звіт зазвичай викликається багато разів і в різних сценаріях. А будь-які приховані ефекти згодом перетворюються на загадки рівня «чому сьогодні сума інша, хоча витрати ті самі».
У наших консольних проєктах звіт — це майже завжди похідне подання даних. Він не має змінювати самі дані, інакше ви починаєте жити у паралельних реальностях: спочатку «дані як є», потім — «дані, які звіт випадково підправив». І вгадайте, яка реальність переможе. Зазвичай та, у якій баги.
Що таке чиста функція і які ефекти заважають
Чиста функція — дуже приземлена ідея: якщо ви дали їй одні й ті самі вхідні дані, вона має повернути один і той самий результат. І водночас вона не повинна непомітно «робити щось іще»: друкувати в консоль, змінювати глобальні змінні, переписувати вхідний список, залежати від випадковості або поточного часу.
У Kotlin це зручно ще й тому, що мова підштовхує до охайної роботи з колекціями. Є інтерфейси лише для читання (List, Map), а є змінювані варіанти (MutableList, MutableMap). Колекція лише для читання не дає вам «випадково» змінити її вміст. Це зменшує шанс зламати дані на рівному місці.
Друк і введення всередині розрахунку
Друк (println) — один із найпідступніших побічних ефектів, бо він здається нешкідливим: «ну я ж просто подивлюся проміжний результат». Але щойно println опиняється всередині функції звіту, ця функція перестає бути «порахувати й повернути». Вона перетворюється на «порахувати й дорогою шуміти». Потім ви викликаєте звіт з іншого місця — і раптом консоль завалена повідомленнями, про які ви вже забули, звідки вони взялися.
Нормальна межа така: функція розрахунку повертає дані (число, список пар, мапу підсумків), а друк відбувається зовні — у main або в шарі CLI. Тоді звіт можна тестувати «в голові»: вхід → вихід, без відволікальних чинників.
Мутація зовнішнього стану і чому var тут підозрілий
Другий клас ефектів — зміна чогось «ззовні»: зовнішнього var, глобальної змінної, спільної колекції стану застосунку. У звітах це особливо небезпечно, тому що звіт зазвичай викликають як «подивитися поточний стан», а не «змінити стан». Якщо звіт змінює стан, то він стає командою, хоча за змістом це запит.
var сам по собі не зло. Але якщо ви бачите у звіті зовнішній лічильник var total = 0, який живе десь «ззовні» і накопичує значення між викликами, — це майже гарантована майбутня головоломка. «Чому другий друк звіту показує суму вдвічі більшу?» — тому що var памʼятає минуле життя.
2. Межа ефектів: розрахунок, рендер, друк
Щоб звіти були читабельними й передбачуваними, запровадимо просте розділення: спочатку рахуємо дані, потім робимо з них гарний текст. Це не «архітектура заради архітектури», а практична економія нервів. Якщо числа неправильні — ви лагодите розрахунок. Якщо текст негарний — ви лагодите рендер. І ці задачі не заважають одна одній.
Зручно мислити про межу так: «усе, що повертає String, — це представлення; усе, що повертає Int, List<...>, Map<...>, — це обчислення». Так, бувають винятки, але для нашого рівня й для консольного застосунку це правило майже завжди робить код кращим.
Невелика схема (у нашому трекері витрат):
flowchart LR
A["Список витрат
List of Pair(String, Int)"] --> B["Розрахунок звіту
(чисті функції)"]
B --> C["Підсумкові дані
Int / Map / List of Pair"]
C --> D["Рендер у рядок
(StringBuilder локально)"]
D --> E["println(...) в CLI"]
Зміст діаграми простий: друк — лише в самому кінці, на межі програми. Усе до нього намагаємося робити без побічних ефектів.
Дозволена локальна мутація: StringBuilder і локальні MutableMap
Важливе уточнення: «мінімум побічних ефектів» не означає «жодних var ніколи». Це означає інше: ми не мутуємо зовнішній стан і вхідні дані. А якщо мутація потрібна, то робимо її локально, під контролем, і повертаємо результат назовні.
Класичний приклад — StringBuilder. Він мутується (до нього додаються фрагменти тексту), але це відбувається всередині функції рендера. Назовні ж функція віддає готовий рядок. Це хороший компроміс: ефективно й читабельно.
Ще один приклад — локальна MutableMap усередині функції агрегації. Якщо ви створюєте MutableMap у функції й заповнюєте її, не чіпаючи зовнішні змінні та не повертаючи саме змінюване посилання «для подальших пригод», — це зазвичай допустимо. Сенс той самий: локальна мутація заради результату, який ви повернете як Map.
3. fold як «чистий лічильник» замість зовнішніх var
Коли новачок хоче порахувати суму, він майже завжди пише лічильник і цикл. Це нормально, і ми так робили раніше. Але у звітах зручніше виражати накопичення результату через fold. Тоді накопичення відбувається «всередині виразу», а не через зовнішню мутацію. Технічно всередині fold теж є накопичувач, але він локальний і не «витікає» назовні. У підсумку ваш код легше читати й складніше випадково зламати.
Порівняймо два підходи.
Варіант із зовнішнім var:
fun calcTotalBad(expenses: List<Pair<String, Int>>): Int {
var total = 0
for (e in expenses) {
total += e.second
}
return total
}
Він працює. Але якщо ви почнете «для зручності» друкувати всередині циклу або винесете total кудись назовні, ви швидко потрапите у світ неочікуваних ефектів.
Варіант через fold:
fun calcTotal(expenses: List<Pair<String, Int>>): Int =
expenses.fold(0) { acc, item -> acc + item.second }
Тут читається буквально: «згорни список в одне число, починаючи з нуля». І, що важливо, ця функція не вміє «випадково» зберегти стан між викликами — у неї немає місця, де він міг би жити.
Міні-перевірка:
fun main() {
val expenses = listOf("food" to 10, "taxi" to 20)
println(calcTotal(expenses)) // 30
println(calcTotal(expenses)) // 30
}
Двічі викликали — двічі отримали однакове. І звіт не «втомився» між викликами.
4. Не мутуємо вхідні дані
Проблема «мутації входу» у звітах часто виглядає так: у вас є список витрат у стані застосунку (зазвичай MutableList), ви передали його у звіт, а звіт вирішив «для зручності» щось у ньому змінити. Наприклад, видалити некоректні записи, відсортувати, нормалізувати категорії прямо у вихідному списку. А потім користувач повертається до списку й бачить, що дані змінилися. Хоча він нічого не робив — він лише попросив звіт.
Щоб цього не відбувалося, тримаємося двох правил.
Перше правило — приймати в розрахункових функціях типи лише для читання: List, Map, а не MutableList, MutableMap. Навіть якщо у вас всередині застосунку зберігається MutableList, назовні (у звіт) ви віддаєте його як List. Це не робить колекцію «магічно незмінюваною», але помітно зменшує шанс, що ви випадково почнете її змінювати: компілятор просто не дасть.
Друге правило — якщо вам дуже потрібно «змінювати дані для розрахунку», робіть перетворення, яке повертає новий результат (map, filter, sorted тощо), а не переписуйте вхідний список. Трансформації колекцій у Kotlin за замовчуванням будують нову колекцію, а не мутують початкову. Саме це нам і потрібно для звітів.
Простий приклад нормалізації без мутації входу:
fun normalizeCategory(raw: String): String =
raw.trim().lowercase()
fun normalizeExpenses(expenses: List<Pair<String, Int>>): List<Pair<String, Int>> =
expenses.map { (cat, amount) -> normalizeCategory(cat) to amount }
Ми нічого не змінюємо «всередині» expenses, а створюємо новий список. Старий лишається як був.
5. Чисті звіти в міні‑трекері витрат
Тепер зберемо це на практиці. Уявімо, що в нас уже є консольний трекер витрат, який зберігає операції як пари (категорія, сума). Ми хочемо писати звіти так, щоб вони не друкували всередині себе, не правили вхідні дані й не залежали від зовнішніх змінних. Водночас звіти мають легко комбінуватися: один рахує числа, другий робить текст, третій друкує.
Ми розвиватимемо один і той самий стиль: «чисті обчислення → чистий рендер → ефект (println) у main». Це допоможе нам і далі збирати набір звітів, не перетворюючи main на звалище логіки.
Форма даних і нормалізація
Почнемо з того, що зафіксуємо: витрата — це Pair<String, Int>, де first — категорія, second — сума. Категорія може прийти «брудною» (пробіли, різний регістр), тож ми нормалізуємо її заздалегідь. Нормалізацію зручно зробити окремою функцією, тому що вона повторюється в кожному звіті.
fun normalizeCategory(raw: String): String =
raw.trim().lowercase()
fun normalizeExpenses(expenses: List<Pair<String, Int>>): List<Pair<String, Int>> =
expenses.map { (cat, amount) -> normalizeCategory(cat) to amount }
Перевіримо на міні-наборі:
fun main() {
val raw = listOf(" Food " to 120, "FOOD" to 80, "taxi" to 250)
val normalized = normalizeExpenses(raw)
println(normalized) // [(food, 120), (food, 80), (taxi, 250)]
}
Важливий момент: raw не змінився — ми створили новий список. Це і є «без мутації входу».
Чисті функції розрахунку: total і totalsByCategory
Тепер напишемо чисті функції розрахунку. Почнемо із загальної суми. Ми вже обговорювали fold: він ідеально підходить, бо виражає «накопичення результату» без зовнішніх лічильників.
fun calcTotal(expenses: List<Pair<String, Int>>): Int =
expenses.fold(0) { acc, item -> acc + item.second }
Перевірка:
fun main() {
val expenses = listOf("food" to 10, "taxi" to 20, "books" to 5)
println(calcTotal(expenses)) // 35
}
Тепер — суми за категоріями. У нас є два читабельні шляхи, і обидва — без мутації входу.
Перший шлях: groupBy + mapValues. groupBy перетворює список на Map<K, List<V>>, де ключ — категорія, а значення — список записів цієї категорії. Це «переломний момент» конвеєра, коли форма даних змінюється радикально.
fun totalsByCategory(expenses: List<Pair<String, Int>>): Map<String, Int> {
val grouped = expenses.groupBy { it.first } // Map<String, List<Pair<String, Int>>>
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}
}
mapValues — це стандартна трансформація мап, яка будує нову мапу, не змінюючи початкову. Ми отримали підсумкову структуру Map<String, Int>, і далі звіт може працювати вже з числами, а не з початковими записами.
Другий шлях (інколи зручніший, коли хочеться одного проходу): акуратно згортаємо список у мапу через fold, використовуючи локальну MutableMap. Це локальна мутація, але вона повністю захована всередині функції й не чіпає вхідний список. Наприкінці ми повертаємо Map як «підсумкові дані».
fun totalsByCategoryFold(expenses: List<Pair<String, Int>>): Map<String, Int> =
expenses.fold(mutableMapOf()) { acc, (cat, amount) ->
acc[cat] = (acc[cat] ?: 0) + amount
acc
}
Перевірка:
fun main() {
val expenses = listOf("food" to 10, "food" to 5, "taxi" to 7)
println(totalsByCategoryFold(expenses)) // {food=15, taxi=7}
}
Обидва варіанти коректні. У навчальних прикладах зазвичай простіше читати перший (через groupBy), бо він буквально показує кроки: «згрупували → порахували суму в кожній групі».
Чистий рендер: перетворюємо підсумкові дані на рядок
Тепер зробимо функцію, яка не рахує, а гарно оформлює. Її задача — взяти вже пораховані рядки/числа й повернути String. Тут допустима локальна мутація StringBuilder, тому що вона обмежена однією функцією й дає передбачуваний підсумок.
fun renderTotals(rows: List<Pair<String, Int>>): String {
val sb = StringBuilder()
for ((category, sum) in rows) {
sb.append(category).append(": ").append(sum).append('\n')
}
return sb.toString()
}
Перевіримо:
fun main() {
val rows = listOf("taxi" to 250, "food" to 200)
print(renderTotals(rows))
// taxi: 250
// food: 200
}
Зверніть увагу: renderTotals нічого не друкує сам. Він віддає рядок, а друк — це рішення коду, який викликає. Це і є охайна межа ефектів.
6. Типові помилки у «чистих» звітах
Помилка № 1: звіт «рахує» через println.
Часто трапляється стиль, коли «порахував і одразу вивів», а інколи ще й дорогою роздрукував проміжні значення «для налагодження». У момент написання це здається зручним, але потім звіт неможливо перевикористати: ви захочете показати його в іншому форматі або обʼєднати з іншим звітом, а він уже шумить у консоль. Ліки прості: розрахунок повертає дані, рендер повертає рядок, друк живе ззовні.
Помилка № 2: функція звіту приймає MutableList і змінює його «для зручності».
Це особливо підступно, бо за змістом звіт — «подивитися», а не «змінити». Якщо всередині звіту ви видаляєте елементи, правите категорії або змінюєте порядок, то після виклику звіту стан застосунку вже інший. Зазвичай це спливає як «плаваючий» баг: звіт викликали один раз — усе ок, викликали вдруге — усе дивно. Допомагає приймати List і повертати новий результат, а не правити вхід.
Помилка № 3: зовнішній лічильник у var, який живе довше одного виклику.
Інколи «для пришвидшення» або «для простоти» суму кладуть у змінну ззовні, а звіт її доповнює. За кілька викликів ви отримуєте накопичення «за всі часи», хоча хотіли «за поточні дані». fold майже завжди розвʼязує це: він виражає накопичення локально й не дає випадково зберегти минуле між викликами.
Помилка № 4: змішування нормалізації й форматування в одному місці.
Коли всередині рендера ви раптом починаєте робити trim().lowercase() і ще замінювати пробіли, звіти стають неузгодженими: десь категорія нормалізована, а десь ні. У підсумку один звіт покаже Food, інший — food, третій — узагалі food . Краще домовитися так: нормалізація — ранній крок, спільний для всіх звітів, і він повертає «чисті дані», а форматування — фінальний крок і працює вже з нормалізованими значеннями.
Помилка № 5: повертати «майже готовий текст» надто рано й втрачати можливість доопрацювання.
Якщо ви надто рано перетворюєте дані на рядки, то потім незручно сортувати, фільтрувати за порогом, брати top‑N тощо: доводиться парсити назад або городити додаткові структури. Хороша звичка — тримати дані числами й парами до самого кінця, і лише останнім кроком робити String. Тоді звіт лишається гнучким, і його легко розширювати наступними кроками конвеєра.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ