1. Expense замість Pair: читабельність і зміст
Коли ми тільки вчилися працювати з колекціями, цілком природно було зберігати витрату як Pair<String, Int>: "Coffee" to 300. Це чесний шлях новачка: швидко, компактно, мозок не перегрівається. Але в Pair є «побічний ефект»: за тиждень ви відкриваєте код і бачите it.second, і всередині починається маленька паніка: «А second — це сума? категорія? кількість? рівень стресу?».
Клас Expense розвʼязує цю проблему дуже просто: він дає імена. Ми більше не працюємо з «першим» і «другим» — ми працюємо з title і amount. А найприємніше те, що всі інструменти колекцій (цикли, filter, map, sortedByDescending, fold) продовжують працювати. Просто лямбди стають зрозумілішими.
Порівняймо «як було» і «як стало» в одній таблиці:
| Підхід | Як виглядає елемент | Як читається код | Типові відчуття |
|---|---|---|---|
|
|
|
«Сподіваюся, я нічого не переплутав…» |
|
|
|
«О, це хоча б по-людському» |
Невелика заготовка для нашого застосунку (консольного обліку витрат) буде така:
class Expense(val title: String, val amount: Int)
2. Створюємо колекцію: mutableListOf<Expense>()
Переходимо до практики. Ми хочемо зберігати багато витрат, отже, нам потрібна колекція. У межах курсу ми використовуємо MutableList, тому що список має вміти і поповнюватися, і зменшуватися: сьогодні купили каву — додали, завтра знайшли помилку — видалили.
Важливий момент, який часто дивує новачків: список можна зберігати у val і все одно змінювати його вміст. val забороняє перепризначати посилання, але не забороняє викликати методи мутабельного обʼєкта (наприклад, add). Це корисна звичка: тримати посилання стабільним.
Приклад:
fun main() {
val expenses = mutableListOf<Expense>()
expenses.add(Expense("Coffee", 300))
expenses.add(Expense("Taxi", 1200))
println(expenses.size) // 2
}
Зауважте: expenses — val, але .add(...) працює без проблем, тому що змінюється вміст списку, а не змінна.
3. Виведення списку витрат
Коли список зберігає числа, ми часто виводили їх просто як є. Коли список зберігає обʼєкти, виведення — це вже форматування. Користувачу (і вам за два дні) потрібні рядки на кшталт 1) Coffee — 300.
Спочатку зробимо просту функцію виведення. Важливо: ми поки не заглиблюємося в «красиві принтери» — нам потрібен простий і зрозумілий цикл.
class Expense(val title: String, val amount: Int)
fun printExpenses(expenses: List<Expense>) {
if (expenses.isEmpty()) {
println("Витрат поки немає.")
return
}
for (i in expenses.indices) {
val e = expenses[i]
println("${i + 1}) ${e.title} — ${e.amount}")
}
}
Тут ми використовуємо indices, щоб не помилитися з межами списку. Якби ми написали 0..expenses.size, то випадково залізли б на індекс size (а це вже «за парканом»).
Перевірмо:
fun main() {
val expenses = mutableListOf(
Expense("Coffee", 300),
Expense("Taxi", 1200),
)
printExpenses(expenses)
// 1) Coffee — 300
// 2) Taxi — 1200
}
4. Додавання витрати
Списки стають по-справжньому корисними, коли користувачі можуть щось до них додавати. Зробімо команду add, яка запитає назву й суму, створить Expense і додасть його до expenses.
Щоб не перетворювати main() на «макарони з кетчупом», винесемо введення суми в невелику функцію. Ми вже вміємо trim() і toIntOrNull(), тому робимо за класичним патерном: «прочитали → підготували → розпарсили».
fun readIntOrNull(prompt: String): Int? {
print(prompt)
return readln().trim().toIntOrNull()
}
Тепер функція додавання:
class Expense(val title: String, val amount: Int)
fun addExpense(expenses: MutableList<Expense>) {
print("Назва: ")
val title = readln().trim()
val amount = readIntOrNull("Сума: ")
if (title.isBlank() || amount == null || amount <= 0) {
println("Помилка: потрібні непорожня назва і сума > 0.")
return
}
expenses.add(Expense(title, amount))
println("Додано: $title — $amount")
}
Зверніть увагу на стиль: ми робимо прості перевірки й ранній return. Це тримає код лінійним і читабельним.
5. Видалення витрати: removeAt() та індекси
Видалення — улюблене місце, де новачки знайомляться з «індексом поза межами». Причому знайомляться зазвичай раптово, без попередження, і на високій швидкості.
Ми будемо видаляти за номером у списку, який бачить користувач (тобто починаючи з 1), але removeAt() працює з індексами (починаючи з 0). Тому робимо перетворення: number -> index = number - 1 і обовʼязково перевіряємо межі через indices.
fun removeExpense(expenses: MutableList<Expense>) {
if (expenses.isEmpty()) {
println("Видаляти нічого: список порожній.")
return
}
val number = readIntOrNull("Номер витрати для видалення: ")
if (number == null) {
println("Помилка: введіть число.")
return
}
val index = number - 1
if (index !in expenses.indices) {
println("Помилка: немає витрати з номером $number.")
return
}
val removed = expenses.removeAt(index)
println("Видалено: ${removed.title} — ${removed.amount}")
}
Тут важлива думка: список — це «живий організм». Після видалення елементи зсуваються, і це нормально. Тому якщо користувач запамʼятав, що «таксі було під номером 2», а потім видалив пункт 1, то таксі стане номером 1. Це не баг — це математика.
6. Операції над обʼєктами в колекціях
Зараз буде приємна частина. Ті операції, які ви вже використовували для рядків і чисел, так само застосовуються й до обʼєктів. Різниця лише в тому, що в лямбді ви зазвичай берете поле обʼєкта: it.amount, it.title.
filter + map: великі витрати та їхні назви
Операції map() і filter() — базові трансформації колекцій.
Приклад: знайдемо витрати >= 1000 і отримаємо їхні назви.
fun largeExpenseTitles(expenses: List<Expense>): List<String> {
return expenses
.filter { it.amount >= 1000 }
.map { it.title }
}
Перевірка:
fun main() {
val expenses = listOf(
Expense("Coffee", 300),
Expense("Taxi", 1200),
Expense("Lunch", 900),
)
println(largeExpenseTitles(expenses)) // [Taxi]
}
sortedByDescending: сортуємо за сумою
Сортування особливо добре читається на обʼєктах, тому що ви прямо кажете: «сортуємо за amount»:
fun topExpenses(expenses: List<Expense>, n: Int): List<Expense> {
return expenses
.sortedByDescending { it.amount }
.take(n)
}
Перевірка:
fun main() {
val expenses = listOf(
Expense("Coffee", 300),
Expense("Taxi", 1200),
Expense("Laptop stand", 2500),
)
val top2 = topExpenses(expenses, 2)
for (e in top2) {
println("${e.title} — ${e.amount}")
}
// Laptop stand — 2500
// Taxi — 1200
}
fold: рахуємо загальний підсумок
fold — це акуратне згортання колекції в одне значення. У нашому випадку — у загальний підсумок.
fun totalAmount(expenses: List<Expense>): Int {
return expenses.fold(0) { acc, e -> acc + e.amount }
}
Перевірка:
fun main() {
val expenses = listOf(
Expense("Coffee", 300),
Expense("Taxi", 1200),
)
println(totalAmount(expenses)) // 1500
}
Щоб «закріпити в голові», ось мінісхема пайплайна (коли ви робите звіт):
flowchart TD
A[expenses: List⟨Expense⟩] --> B[filter amount >= 1000]
B --> C[sortedByDescending amount]
C --> D["take(3)"]
D --> E[map to 'title — amount']
E --> F[joinToString + друк]
7. Міні-CLI: add/list/remove/total/top/exit
Тепер зберемо все в мінімальний, але цілісний «облік витрат». Ми не будуємо ідеальну архітектуру — натомість збираємо читабельний каркас, у якому видно, як список обʼєктів живе й оновлюється.
Командний цикл
main() із командним циклом:
fun main() {
val expenses = mutableListOf<Expense>()
while (true) {
print("> ")
when (readln().trim().lowercase()) {
"add" -> addExpense(expenses)
"list" -> printExpenses(expenses)
"remove" -> removeExpense(expenses)
"total" -> println("Разом: ${totalAmount(expenses)}")
"exit" -> return
else -> println("Команди: add, list, remove, total, exit")
}
}
}
Команда top: швидкий звіт
І маленький бонус — команда top, щоб відчути задоволення від пайплайна:
fun printTop(expenses: List<Expense>) {
val top = expenses.sortedByDescending { it.amount }.take(3)
if (top.isEmpty()) {
println("Витрат поки немає.")
return
}
println("Топ-3 витрат:")
for (e in top) {
println("${e.title} — ${e.amount}")
}
}
Якщо хочете підʼєднати в main():
"top" -> printTop(expenses)
Міграція від Pair до Expense без «великого вибуху»
На практиці міграція рідко буває «в один коміт і без болю». Зазвичай частина коду ще живе зі старим форматом, а частина — з новим. І це нормально: головне — мати зрозумілий місток між ними.
Уявімо, що десь у нас залишилася стара колекція:
val legacy: MutableList<Pair<String, Int>> = mutableListOf(
"Coffee" to 300,
"Taxi" to 1200
)
Зробімо функцію міграції:
fun migrate(pairs: List<Pair<String, Int>>): MutableList<Expense> {
return pairs.map { (title, amount) -> Expense(title, amount) }.toMutableList()
}
Тут ми використовуємо деконструкцію пари (title, amount). Після міграції весь «предметний код» краще писати вже через Expense.
Перевірка:
fun main() {
val legacy = listOf("Coffee" to 300, "Taxi" to 1200)
val expenses = migrate(legacy)
printExpenses(expenses)
// 1) Coffee — 300
// 2) Taxi — 1200
}
Якщо вам потрібно тимчасово конвертувати назад (наприклад, якийсь старий звіт ще очікує Pair), це теж можна зробити. Але краще сприймати це як тимчасову милицю:
fun toPairs(expenses: List<Expense>): List<Pair<String, Int>> {
return expenses.map { it.title to it.amount }
}
Нюанс про посилання: що лежить у MutableList<Expense>
Зараз буде тема, на якій часто «пливуть», бо її не видно очима. Коли список зберігає Int, усе просто: там значення. Коли список зберігає обʼєкти, усередині лежать посилання на обʼєкти. Тобто елемент списку — це не «копія витрати», а «вказівник на той самий обʼєкт».
Щоб це відчути руками, візьмемо навмисно змінюваний клас:
class Tag(var name: String)
fun main() {
val tags = mutableListOf(Tag("food"))
val t = tags[0]
t.name = "groceries"
println(tags[0].name) // groceries
}
Ми змінили t.name, і це відразу видно через tags[0], тому що t і tags[0] вказують на один і той самий обʼєкт.
Це не «страшно» і не «погано». Це просто модель роботи обʼєктів. Але з неї випливає практичне правило: якщо ви робите властивості обʼєктів var, то зміна обʼєкта в одному місці може «спливти» в іншому, тому що це все той самий обʼєкт.
8. Типові помилки
Помилка № 1: продовжувати зберігати предметні сутності в Pair, бо «так коротше».
На короткій дистанції Pair справді здається швидшим. Але щойно в пари зʼявляється зміст («назва витрати» і «сума витрати»), first/second починає заважати читанню. Переїзд на Expense(title, amount) зазвичай робить код довшим на кілька символів, зате зменшує кількість «а це точно що?» на порядок.
Помилка № 2: робити паралельні списки замість одного списку обʼєктів.
Частий антипатерн: val titles = mutableListOf<String>() і val amounts = mutableListOf<Int>(), а далі ви «синхронно» додаєте й видаляєте елементи. Це працює рівно до першого бага, після якого списки розсинхронізуються, і у вас раптово виходить «Taxi — 300», а «Coffee — 1200». Один список MutableList<Expense> прибирає цілу категорію таких проблем.
Помилка № 3: забути про зміщення індексів (номер з 1, індекс з 0).
Користувач вводить «1», а ви за звичкою робите removeAt(1) і видаляєте другий елемент. Це та сама помилка, через яку люди починають підозрювати, що компʼютер їх не поважає. Лікується просто: робите index = number - 1 і перевіряєте index in expenses.indices.
Помилка № 4: не перевіряти межі перед removeAt() і доступом за індексом.
removeAt() не зобовʼязаний «здогадуватися», що користувач ввів 999. Він чесно кине виняток. У навчальному проєкті це виглядає як «програма впала й пішла в захід сонця». Тому перевірка через indices — не бюрократія, а елементарна ввічливість до себе з майбутнього.
Помилка № 5: перетворювати ланцюжки filter/map/sortedByDescending на нечитабельну «локшину».
Операції з колекціями потужні — і через це зʼявляється спокуса написати один рядок на пів екрана. Якщо ви самі не можете швидко прочитати його за тиждень, краще розбити на проміжні val із промовистими назвами. Це не «зайвий код». Це страховка для власних очей.
Помилка № 6: думати, що val expenses = mutableListOf(...) забороняє змінювати список.
Це поширена плутанина: val забороняє перепризначити змінну, але не забороняє змінювати обʼєкт, на який вона посилається. Для MutableList це означає, що add/remove дозволені, і це нормальна поведінка Kotlin.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ