1. Зачем нужен Map и как читать Map<K, V>
Когда вы только привыкаете к коллекциям, очень легко мыслить так: «Мне нужен список, значит List». И это правда… пока вы не сталкиваетесь с задачей, где ключ к данным — это не позиция (0, 1, 2…), а какая-то осмысленная метка: название товара, команда, логин, категория, ID.
Map<K, V> (карта, словарь) хранит пары «ключ → значение». Ключи уникальны: два одинаковых ключа в одной карте жить не могут. А вот значения могут повторяться сколько угодно: например, товары могут иметь одинаковую цену, но ключи (названия товаров) всё равно разные.
Ключевой момент: Map в Kotlin — это отдельный «мир», он не наследуется от Collection, хотя относится к коллекциям как тип данных стандартной библиотеки.
Как читать тип Map<K, V> и не пугаться угловых скобок
Когда вы видите Map<String, Int>, мозг сначала делает вид, что занят: «давай потом, я не готов». Это нормально. Здесь важно научиться читать тип вслух, как предложение. Map<K, V> читается как «карта от ключей типа K к значениям типа V». То есть Map<String, Int> — это «ключи — строки, значения — целые числа».
В реальной жизни это может быть, например, «название товара → цена», «категория → сумма расходов», «слово → количество раз, сколько оно встретилось». Никакой магии: угловые скобки просто фиксируют, что именно мы кладём и достаём.
Давайте посмотрим на «карманный прайс-лист»:
fun main() {
val prices: Map<String, Int> = mapOf(
"tea" to 90,
"coffee" to 150
)
println(prices) // {tea=90, coffee=150}
}
Обратите внимание на to. Это удобный способ создать пару «ключ-значение», чтобы mapOf(...) читался как «перечень соответствий».
2. Чтение из Map: почему получается V? и как с этим жить
Если вы привыкли к List, то ожидаете логику «дай элемент по индексу — и получишь элемент». Но в Map ключ может отсутствовать. И Kotlin делает очень здравую вещь: вместо того чтобы «взорваться» ошибкой, чтение по ключу возвращает null, если ключ не найден.
Именно поэтому выражение map[key] имеет тип V?, даже если V сам по себе не nullable. Это одна из самых важных деталей, и она будет всплывать постоянно, пока вы пишете любые программы с Map.
fun main() {
val prices = mapOf("tea" to 90, "coffee" to 150)
val a: Int? = prices["tea"]
val b: Int? = prices["milk"]
println(a) // 90
println(b) // null
}
Тут нет «ошибки». milk просто отсутствует — и это нормальная ситуация, а не катастрофа.
Elvis ?: при чтении: «если ключа нет — бери значение по умолчанию»
После того как вы приняли реальность V?, возникает следующий вопрос: «Окей, а что делать, если null мне не подходит?» В простых сценариях лучше всего работает Elvis-оператор ?:: «если слева null, то возьми справа».
Это особенно удобно для чисел, когда у вас есть «естественный ноль». Например, если нет цены — считать ценой 0 (или “неизвестно”, но это уже другой дизайн).
fun main() {
val prices = mapOf("tea" to 90, "coffee" to 150)
val milkPrice = prices["milk"] ?: 0
val teaPrice = prices["tea"] ?: 0
println(milkPrice) // 0
println(teaPrice) // 90
}
Здесь мы явно выбираем политику: «нет ключа → 0». Важно, что Kotlin заставляет вас принять решение, а не притвориться, что ключ «точно есть».
Проверка наличия ключа: key in map как читаемый стиль
Иногда значение по умолчанию не подходит. Например, если команда не существует, вы хотите вывести сообщение об ошибке, а не «тихо» продолжить работу. Тогда сначала проверяем, есть ли ключ в карте.
Kotlin позволяет писать это очень читабельно: if (key in map). По смыслу это именно «если ключ входит в набор ключей карты».
fun main() {
val prices = mapOf("tea" to 90, "coffee" to 150)
val item = "tea"
if (item in prices) {
println("Price: ${prices[item]}") // Price: 90
} else {
println("No such item") // (не выполнится)
}
}
Здесь prices[item] всё ещё возвращает Int?, но в такой форме кода вы обычно уже уверены, что ключ есть. Чуть позже в курсе вы научитесь делать это ещё аккуратнее, но на текущем этапе этого достаточно: проверка + понятная ветка поведения.
3. MutableMap: запись, обновление и удаление
До этого мы использовали mapOf(...), то есть read-only Map. Её задача — «хранить и давать читать». Но если вам нужно изменять данные во время работы программы, берём MutableMap.
В Kotlin это разделение такое же, как у List: read-only тип (Map) и mutable тип (MutableMap). При этом MutableMap поддерживает операции записи: можно добавлять новые пары и обновлять существующие значения.
Одна форма для «добавить» и «обновить»
Самое приятное — запись выглядит одинаково и для «добавить», и для «обновить»:
fun main() {
val stock = mutableMapOf<String, Int>()
stock["apple"] = 3
stock["apple"] = 4
stock["banana"] = 1
println(stock) // {apple=4, banana=1}
}
То есть оператор [] для MutableMap работает в две стороны: чтение map[key] и запись map[key] = value. Это одна из причин, почему карты в Kotlin ощущаются как «честный словарь», а не как что-то громоздкое.
Небольшая практическая деталь: стандартная реализация MutableMap по умолчанию — LinkedHashMap, и при обходе она сохраняет порядок добавления. Мы сегодня не будем обходить карту (это тема следующего дня), но полезно понимать, почему при println(map) вы часто видите «пары в ожидаемом порядке».
Удаление по ключу: remove(key) и «что именно вернулось»
Удаление в MutableMap обычно делается так: remove(key). И тут Kotlin снова ведёт себя «взросло»: метод возвращает значение, которое было удалено, или null, если ключа не было.
Это удобно, потому что вы одним выражением можете узнать, было ли что удалять.
fun main() {
val stock = mutableMapOf("apple" to 4, "banana" to 1)
val removed: Int? = stock.remove("banana")
val removedAgain: Int? = stock.remove("banana")
println(removed) // 1
println(removedAgain) // null
println(stock) // {apple=4}
}
Снова та же философия: «отсутствие ключа» — нормальное состояние мира, а не исключение.
Мини-таблица: что возвращают базовые операции Map
Иногда легче один раз увидеть «контракт» операций, чем десять раз читать объяснение. В этом разделе мы аккуратно зафиксируем, какие типы возвращаются, чтобы вы перестали удивляться ? в неожиданных местах (это удивление — один из самых частых источников раздражения у новичков).
| Операция | Пример | Что возвращается | Почему |
|---|---|---|---|
| Чтение по ключу | |
|
ключ может отсутствовать |
| Проверка ключа | |
|
просто факт наличия |
| Запись/обновление | |
|
это действие, не «значение» |
| Удаление | |
|
можно узнать, что удалили |
Эта таблица — не «надо заучить», а «надо привыкнуть». Через пару упражнений мозг начнёт автоматически ожидать nullable при чтении из карты.
4. Паттерн «счётчик»: увеличиваем значение по ключу
Сейчас будет один из самых практичных приёмов. Он встречается везде: статистика, подсчёты, частоты, «сколько раз пользователь сделал X», «сколько расходов в категории Y». И делается он буквально в одну строчку.
Проблема звучит так: «Я хочу увеличить счётчик по ключу. Но если ключа нет, счётчик должен считаться равным 0».
Решение: (map[key] ?: 0) + 1, после чего записываем обратно.
fun main() {
val counts = mutableMapOf<String, Int>()
val word = "kotlin"
counts[word] = (counts[word] ?: 0) + 1
counts[word] = (counts[word] ?: 0) + 1
println(counts) // {kotlin=2}
}
Выглядит почти слишком просто — но это именно та простота, ради которой люди любят Kotlin: всё честно, и null обработан явно.
Пример «счётчик слов» без функциональных цепочек
Очень хочется уже писать красиво через map/filter/groupBy, но мы договорились: сегодня без «операций над коллекциями», только базовые инструменты. Поэтому сделаем максимально понятный вариант: читаем строку, разбиваем на слова, нормализуем регистр и считаем частоты.
Мы будем считать слова, разделённые пробелами. Это не идеальный парсер текста (знаки препинания останутся в словах), но для отработки Map это отлично.
fun main() {
val line = readln().trim().lowercase()
val parts = line.split(" ")
val counts = mutableMapOf<String, Int>()
for (p in parts) {
if (p != "") counts[p] = (counts[p] ?: 0) + 1
}
println(counts)
}
Если ввести:
Kotlin kotlin is fun
то вывод будет примерно таким (порядок может совпасть с добавлением):
// {kotlin=2, is=1, fun=1}
Здесь мы одновременно увидели три вещи: нормализация строки (trim().lowercase()), базовый цикл for, и тот самый паттерн счётчика.
5. Пример: «категория → сумма» и нормализация ключей
Теперь аккуратно привяжем Map к нашему практическому контексту: мы постепенно движемся к приложению, которое хранит записи (например, расходы) в памяти. Пока без классов, поэтому запись представим как Triple(category, title, amount). Сегодня наша цель не «идеальная архитектура», а практика карты: построим отчёт «по категориям».
Сделаем функцию, которая по списку записей строит MutableMap<String, Int>, где ключ — категория, значение — сумма расходов по категории.
fun buildTotalsByCategory(
records: List<Triple<String, String, Int>>
): Map<String, Int> {
val totals = mutableMapOf<String, Int>()
for (r in records) {
val category = r.first
val amount = r.third
totals[category] = (totals[category] ?: 0) + amount
}
return totals
}
Обратите внимание на хороший стиль для новичка: внутри мы используем MutableMap, потому что «строим» результат, а наружу возвращаем Map, потому что «отдаём читать». Это простая дисциплина: мутируем там, где создаём, и не обещаем мутабельность там, где она не нужна.
Проверим на маленьком примере:
fun main() {
val records = mutableListOf<Triple<String, String, Int>>()
records.add(Triple("food", "coffee", 300))
records.add(Triple("transport", "taxi", 1200))
records.add(Triple("food", "sandwich", 250))
val totals = buildTotalsByCategory(records)
println(totals) // {food=550, transport=1200}
}
Такой отчёт — базовая причина, почему Map появляется в программах чаще, чем «чистые множества» и иногда даже чаще, чем списки: очень многие задачи — это «по ключу хранить агрегированное значение».
Нюанс про ключи: нормализация строки как часть контракта
Карты честно считают ключи по точному равенству. Для строки это значит, что "Food", "food" и " food " — три разных ключа. И это не «проблема Kotlin», это ваша будущая ответственность как автора программы.
Поэтому, если ключ приходит от пользователя (например, он вводит категорию), хорошее правило — нормализовать: trim() и часто lowercase(). Тогда ваша Map будет отражать смысл данных, а не случайные пробелы и настроение Caps Lock.
Мини-пример:
fun main() {
val totals = mutableMapOf<String, Int>()
val raw = " FOOD "
val category = raw.trim().lowercase()
totals[category] = (totals[category] ?: 0) + 100
println(totals) // {food=100}
}
Если вы не сделаете такую нормализацию, то в отчёте внезапно появятся категории Food, FOOD, food — и вам будет казаться, что программа «сломалась», хотя она просто слишком буквально воспринимает ввод.
6. Типичные ошибки при работе с Map/MutableMap
Ошибка №1: ожидать, что map[key] вернёт V, а не V?.
Новички часто пишут val x: Int = prices["tea"] и удивляются ошибке компиляции. Компилятор здесь ваш союзник: он предупреждает, что ключ может отсутствовать. Исправление обычно простое: либо Elvis ?:, либо проверка if (key in map), либо другой контракт функции (например, вернуть Int? наружу).
Ошибка №2: лечить V? через !!, потому что «ну я же знаю, что ключ есть».
!! иногда кажется коротким путём, но это как «я точно не упаду» перед катанием на самокате без рук. Сегодня вы уверены, завтра входные данные поменялись — и программа падает. Гораздо спокойнее либо проверять ключ, либо использовать ?: с понятной политикой по умолчанию.
Ошибка №3: пытаться обновлять Map, созданную через mapOf.
mapOf(...) возвращает read-only Map. Она для чтения, а не для изменений. Если нужно добавлять/удалять/обновлять, используйте mutableMapOf(...). Это не придирка: разделение read-only и mutable — важная часть дизайна Kotlin-коллекций.
Ошибка №4: забыть про нормализацию ключа и получить «размножение сущностей».
Когда ключ — строка от пользователя, пробелы и регистр превращаются в разные ключи. В отчёте появляются дубли «по смыслу», и вы начинаете подозревать паранормальное. Обычно достаточно договориться: ключи храним в trim().lowercase(), а «красивое отображение» сделаем позже.
Ошибка №5: хранить в Map то, что по смыслу должно быть List.
Иногда хочется всё превратить в карту, потому что «там удобно по ключу». Но если у вас данные естественно упорядочены, если важны повторы или порядок, если вам нужен доступ по позиции — это задачи List. Map хорош, когда ключ действительно уникален и по нему логично обращаться как к «адресу».
Ошибка №6: путать «ключ» и «индекс» и пытаться мыслить картой как списком.
У Map нет «нулевого элемента» и «последнего индекса» в том смысле, как у списка. Есть только ключи. Если вы ловите себя на желании «пробежаться по карте по индексам», значит вы либо решаете задачу обхода (мы это разберём позже), либо вам на самом деле нужен List.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ