JavaRush /Курсы /Kotlin SELF /Коллекции объектов: MutableList<Expense>, операции ...

Коллекции объектов: MutableList<Expense>, операции и миграция от Pair

Kotlin SELF
30 уровень , 3 лекция
Открыта

1. Expense вместо Pair: читаемость и смысл

Когда мы только учились коллекциям, очень естественно было хранить расход как Pair<String, Int>: "Coffee" to 300. Это честный путь новичка: быстро, компактно, мозг не перегревается. Но у Pair есть «побочный эффект»: через неделю вы открываете код и видите it.second, и у вас начинается маленькая внутренняя паника — «а second это сумма? категория? количество? уровень стресса?».

Класс Expense решает проблему простым способом: он даёт имена. Мы больше не работаем с «первым» и «вторым», мы работаем с title и amount. А самое приятное — все инструменты коллекций (циклы, filter, map, sortedByDescending, fold) продолжают работать, только лямбды становятся понятнее.

Сравним «как было» и «как стало» в одной таблице:

Подход Как выглядит элемент Как читается код Типичные чувства
Pair<String, Int>
"Coffee" to 300
it.first, it.second
«надеюсь, я не перепутал…»
Expense
Expense("Coffee", 300)
it.title, it.amount
«о, это хотя бы по-человечески»

Мини-заготовка для нашего приложения (консольный учёт расходов) будет такой:


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
}

Заметьте: expensesval, но .add(...) работает без проблем, потому что меняется содержимое списка, а не переменная.

3. Печать списка расходов

Когда список хранит числа, мы часто печатали их просто так. Когда список хранит объекты, печать — это уже форматирование. Пользователю (и вам через два дня) нужны строки вроде 1) Coffee300.

Сначала сделаем простую функцию печати. Важно: мы пока не углубляемся в «красивые принтеры», нам нужен простой, понятный цикл.

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.

1
Задача
Kotlin SELF, 30 уровень, 3 лекция
Недоступна
Список трат
Список трат
1
Задача
Kotlin SELF, 30 уровень, 3 лекция
Недоступна
Чек с нумерацией
Чек с нумерацией
1
Задача
Kotlin SELF, 30 уровень, 3 лекция
Недоступна
Быстрое добавление
Быстрое добавление
1
Задача
Kotlin SELF, 30 уровень, 3 лекция
Недоступна
Удаление по номеру
Удаление по номеру
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ