JavaRush /Курси /Kotlin SELF /Чисті обчислення у звітах: мінімум ефектів і без мутації ...

Чисті обчислення у звітах: мінімум ефектів і без мутації входу

Kotlin SELF
Рівень 26 , Лекція 2
Відкрита

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. Тоді звіт лишається гнучким, і його легко розширювати наступними кроками конвеєра.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ