JavaRush /Курсы /Kotlin SELF /Generics на коллекциях + минимальная структура данных для...

Generics на коллекциях + минимальная структура данных для практического проекта

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

1. Зачем нужны generics и как читать типы коллекций

Если вы только начинаете, generics легко воспринимаются как «эти странные треугольные скобки, которые мешают жить». Но на практике это ваша страховка от глупых ошибок. Kotlin действительно умеет выводить типы, но вывод типа — это не телепатия: компилятор опирается на то, что вы ему дали. Если вы храните данные в коллекциях, важно, чтобы компилятор точно знал, что внутри лежит, иначе он не сможет защитить вас от случайной мешанины значений.

Представьте кухонную банку с надписью «Сахар». Generics — это и есть эта надпись. Вы можете насыпать туда что угодно, но если надпись «Сахар», вы хотя бы не будете пытаться отсыпать оттуда «пару болтов» в чай. И наоборот: если банку не подписать, то однажды вы обнаружите в каше флешку, потому что «ну она тоже как-то туда попала».

В Kotlin надпись выглядит так: List<String>, MutableList<Int>, Map<String, Int>. И это не просто украшение — это контракт, который компилятор проверяет за вас. Общая идея generics в Kotlin описывается как «обобщённые параметры типа» (type parameters).

Сейчас будет момент, когда мозг может попытаться «выключить звук», поэтому давайте договоримся: мы читаем generics не как «символы», а как русскую фразу. Увидели List<T> — произнесли: «список элементов типа T». Увидели Map<K, V> — произнесли: «карта от ключа типа K к значению типа V». После пары десятков повторений это становится автоматикой, примерно как чтение слова println.

У Kotlin-коллекций есть интерфейсы и иерархия, но сегодня нам важнее именно контракт типов: контейнер хранит элементы заданного вида. Условно:

  • List<T> хранит элементы в порядке и даёт доступ по индексу.
  • Set<T> хранит уникальные элементы.
  • Map<K, V> хранит пары «ключ → значение».

Небольшая таблица «как читать»

Запись типа Как читать по-русски Пример значений
List<String>
список строк
["milk", "bread"]
MutableList<Int>
изменяемый список целых
[10, 20, 30]
Set<String>
множество уникальных строк
{"kotlin", "jvm"}
Map<String, Int>
карта «строка → целое»
{"tea" -> 90}

И да, это всё не про то, что «там реально лежит», а про то, что разрешено класть и доставать.

Вывод типа: почему с непустой коллекцией легко, а с пустой — нет

Одна из самых частых ситуаций у начинающих: «Я создал пустой список, а Kotlin ругается. Он же умный». Kotlin умный, но не гадалка. Если вы пишете mutableListOf() без элементов, компилятор не видит ни одного значения и не понимает, какой T вы имели в виду. И это честно: список «чего именно» — строк, чисел, тройных записей, котиков.

С непустыми коллекциями всё проще: Kotlin видит элементы и выводит тип по ним. Это стандартное поведение type inference: типовые аргументы можно не писать, если они однозначно выводятся из контекста.

Пример: тип выводится сам, потому что есть элементы

fun main() {
    val names = listOf("Ann", "Bob")    // List<String>
    val nums = mutableListOf(1, 2, 3)   // MutableList<Int>

    println(names) // [Ann, Bob]
    println(nums)  // [1, 2, 3]
}

Пример: пустая коллекция — тип нужно подсказать

fun main() {
    val tags = mutableSetOf<String>()
    tags.add("kotlin")

    println(tags) // [kotlin]
}

Почему здесь всё хорошо? Потому что вы сказали <String>, и компилятор успокоился.

Два способа подсказать тип

Иногда удобнее писать тип справа:

fun main() {
    val ids = mutableListOf<Int>()
    ids.add(10)

    println(ids) // [10]
}

Иногда — слева, особенно если тип длинный:

fun main() {
    val ids: MutableList<Int> = mutableListOf()
    ids.add(10)

    println(ids) // [10]
}

Оба варианта работают одинаково; выбирайте тот, который делает код читаемее.

2. Самая важная правда про Map: map[key] возвращает V?

С List обычно всё интуитивно: индекс либо есть, либо вылетит ошибка, и вы быстро поймёте, что натворили. А вот Map ведёт себя по-другому, потому что ключ может отсутствовать, и это вообще нормальная ситуация. Поэтому при чтении по ключу Kotlin даёт вам не V, а V?. Иначе вы бы постоянно ловили падения там, где можно обойтись вежливой логикой.

Пример: чтение даёт Int?

fun main() {
    val prices: Map<String, Int> = mapOf("tea" to 90)

    val teaPrice: Int? = prices["tea"]
    val milkPrice: Int? = prices["milk"]

    println(teaPrice)  // 90
    println(milkPrice) // null
}

Elvis ?: как запасной план

fun main() {
    val prices = mapOf("tea" to 90)

    val milkPrice = prices["milk"] ?: 0
    println(milkPrice) // 0
}

И это не «костыль». Это нормальный, честный контракт: «может быть значение, а может быть null».

Когда containsKey уместнее, чем ?:

Иногда вы хотите отличать ситуацию «нет ключа» от «значение по ключу равно 0». Тогда логичнее сначала проверить наличие ключа:

fun main() {
    val stock = mapOf("apple" to 0)

    val key = "banana"
    if (stock.containsKey(key)) {
        println("Ключ есть, значение = ${stock[key]}") // не выполнится
    } else {
        println("Ключа нет") // Ключа нет
    }
}

3. Вложенные generics без боли: как не убить читаемость

На этом месте многие начинают писать такие типы, что хочется вызвать священника и «очистить проект от демонов»: MutableMap<String, MutableList<Triple<String, String, Int>>>. Это законно, но читаемость страдает. Нам сейчас важно научиться двум приёмам: во-первых, не стесняться промежуточных val, во-вторых, делать типы заметными в коде через хорошие имена переменных.

Это значит, что наша задача — не «написать тип», а «написать тип так, чтобы его не боялись люди».

Плохая читаемость, но компилируется

fun main() {
    val data: MutableMap<String, MutableList<Triple<String, String, Int>>> = mutableMapOf()
    println(data) // {}
}

Это выглядит как формула из органической химии. Может, она правильная, но «пить» её не хочется.

Лучше: разложить на смысловые куски

fun main() {
    val byCategory: MutableMap<String, MutableList<Triple<String, String, Int>>> = mutableMapOf()
    val foodRecords: MutableList<Triple<String, String, Int>> = mutableListOf()

    foodRecords.add(Triple("food", "coffee", 300))
    byCategory["food"] = foodRecords

    println(byCategory) // {food=[(food, coffee, 300)]}
}

Мы ещё не учили «красивые операции коллекций», поэтому используем базовые вещи: создать, добавить, присвоить по ключу.

4. Минимальная структура данных для практического проекта

Сейчас мы сделаем «скелет» данных для проекта так, чтобы он уже был полезным, но не требовал классов и сложной архитектуры. Представьте, что мы пишем консольный мини‑трекер расходов. У каждой записи есть категория, описание и сумма. Это идеально ложится на Triple<String, String, Int>: три поля, простой контракт. Да, Triple не самый читабельный формат на больших проектах, но для старта — нормальный временный инструмент, если вы дисциплинированно соблюдаете порядок полей.

Параллельно нам нужен контейнер, где будет храниться много записей. Для этого подойдёт MutableList<Record>, где Record — это наш Triple<...>.

Шаг 1: договоримся о форме записи

fun main() {
    val record = Triple("food", "coffee", 300)

    println(record.first)  // food
    println(record.second) // coffee
    println(record.third)  // 300
}

Да, first/second/third — не мечта по читаемости, но пока пойдёт.

Шаг 2: хранилище записей в списке

fun main() {
    val records: MutableList<Triple<String, String, Int>> = mutableListOf()

    records.add(Triple("food", "coffee", 300))
    records.add(Triple("transport", "taxi", 1200))

    println(records.size) // 2
}

MutableList в Kotlin — это стандартный изменяемый список, который умеет add/remove и прочие операции изменения.

Шаг 3: добавим идентификатор без классов

Очень частая задача — уметь ссылаться на запись по id. Если бы у нас были классы, мы бы сделали поле id. Но мы пока без ООП, поэтому используем Map<Int, Record>.

Тут важно помнить контракт Map: ключи уникальны, и доступ по ключу может вернуть null.

fun main() {
    val byId: MutableMap<Int, Triple<String, String, Int>> = mutableMapOf()

    byId[1] = Triple("food", "coffee", 300)
    byId[2] = Triple("transport", "taxi", 1200)

    val found = byId[2]
    println(found) // (transport, taxi, 1200)
}

Шаг 4: минимальное состояние проекта в памяти

Теперь соберём всё вместе: список записей (чтобы хранить порядок добавления) и карта по id (чтобы быстро находить). Да, это два хранилища, и да — это ручная дисциплина синхронизации. Но мы делаем минимальную версию без архитектурных усложнений.

fun main() {
    var nextId = 1
    val records: MutableList<Pair<Int, Triple<String, String, Int>>> = mutableListOf()
    val byId: MutableMap<Int, Triple<String, String, Int>> = mutableMapOf()

    val id = nextId++
    val record = Triple("food", "coffee", 300)

    records.add(id to record)
    byId[id] = record

    println(records) // [(1, (food, coffee, 300))]
}

Здесь мы используем to (инфиксная функция) для создания Pair, чтобы запись выглядела как «id to record». Это читается почти как русский текст: «id соответствует record».

Generics как дизайн API: List<T> vs MutableList<T>

В этот момент возникает тонкий, но очень практичный принцип: если функция не должна менять коллекцию, лучше принимать read‑only тип (List, Map, Set) вместо mutable‑версии. Это делает код безопаснее: вы сразу показываете «я тут только читаю». В Kotlin это стандартная идея разделения read‑only и mutable коллекций.

Покажем на маленьком примере: у нас есть функция, которая печатает записи. Ей не нужно уметь add/remove, ей нужно только пройтись и вывести.

fun printRecords(records: List<Pair<Int, Triple<String, String, Int>>>) {
    for ((id, record) in records) {
        val category = record.first
        val title = record.second
        val amount = record.third

        println("#$id [$category] $title = $amount")
    }
}

fun main() {
    val records = mutableListOf<Pair<Int, Triple<String, String, Int>>>()
    records.add(1 to Triple("food", "coffee", 300))

    printRecords(records)
    // #1 [food] coffee = 300
}

Обратите внимание: мы храним в MutableList, потому что будем добавлять записи, но передаём в функцию как List, потому что функция — «только читатель».

Нормализация ключей: привычка, которая спасает Map

Карта (Map) — прекрасный инструмент, пока вы не начинаете использовать в качестве ключей «грязные строки» из ввода пользователя. Пользователь пишет " Food ", потом "food", потом "FOOD", и ваша программа внезапно решает, что это три разные категории. А программа не виновата: вы сами дали ей три разные строки.

Поэтому закрепим простую привычку: прежде чем использовать строку как ключ, нормализуем её. Здесь нам достаточно trim() и lowercase().

fun normalizeKey(raw: String): String {
    return raw.trim().lowercase()
}

fun main() {
    val categories: MutableSet<String> = mutableSetOf()

    categories.add(normalizeKey(" Food "))
    categories.add(normalizeKey("food"))
    categories.add(normalizeKey("FOOD"))

    println(categories)      // [food]
    println(categories.size) // 1
}

Это хороший пример того, как Set (уникальность) и нормализация строки вместе дают предсказуемое поведение.

5. Типичные ошибки

Ошибка №1: «Kotlin же умный, пусть сам догадается» — и создание пустых коллекций без типа.
Пустая коллекция не содержит элементов, а значит, компилятору не из чего вывести T. В результате вы получаете ошибку вывода типа. Лечится просто: задавайте тип явно либо через <T> у фабрики (mutableListOf<String>()), либо через тип слева (val xs: MutableList<String> = mutableListOf()).

Ошибка №2: путаница между Map<K, V> и результатом map[key].
Даже если значение в Map — не nullable (V), чтение по ключу возвращает V?, потому что ключ может отсутствовать. Если забыть об этом и попытаться использовать результат как «точно не null», вы быстро упрётесь в ошибку компиляции (в лучшем случае) или начнёте злоупотреблять !! (в худшем). Нормальная стратегия — ?: или явная проверка наличия ключа.

Ошибка №3: создание монструозных вложенных generic-типов без попытки сделать их читаемыми.
Тип вроде MutableMap<String, MutableList<Triple<String, String, Int>>> может быть корректным, но читать его тяжело, особенно новичку (и вам самим через неделю). Спасают промежуточные переменные с человеческими именами и маленькие шаги: сначала список, потом карта, потом связь между ними.

Ошибка №4: передача MutableList и MutableMap во все функции подряд «на всякий случай».
Это ломает границы ответственности: любая функция начинает иметь право «случайно удалить половину данных». Если функция должна только читать, принимайте List/Map/Set read‑only типов. Kotlin специально разделяет интерфейсы коллекций на read‑only и mutable, чтобы вы могли писать более предсказуемый код.

Ошибка №5: использование сырого ввода пользователя как ключа без нормализации.
Если вы не делаете trim().lowercase(), то "food", " Food " и "FOOD" становятся разными ключами. Программа выглядит «сломано», хотя на самом деле она просто слишком буквально воспринимает строку. Сделайте нормализацию привычкой — и Map начнёт вести себя как инструмент, а не как генератор загадок.

1
Задача
Kotlin SELF, 19 уровень, 4 лекция
Недоступна
Корзина на старте
Корзина на старте
1
Задача
Kotlin SELF, 19 уровень, 4 лекция
Недоступна
Теги без хаоса
Теги без хаоса
1
Задача
Kotlin SELF, 19 уровень, 4 лекция
Недоступна
Справочник цен
Справочник цен
1
Задача
Kotlin SELF, 19 уровень, 4 лекция
Недоступна
Расходы по папкам
Расходы по папкам
1
Опрос
Коллекции `List/Set/Map`, 19 уровень, 4 лекция
Недоступен
Коллекции `List/Set/Map`
Коллекции `List/Set/Map`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ