1. Почему массивов внезапно стало мало
Если вы сейчас думаете: «Зачем ещё какие-то коллекции, у меня же есть массивы», — это нормальная реакция. Массивы действительно отлично подходят для задач, где количество элементов заранее известно и почти не меняется. Но как только вы начинаете писать программу «для жизни», а не «для контрольной», выясняется неприятное: реальная жизнь не любит фиксированные размеры.
Представьте, что вы делаете консольный мини‑учёт расходов (мы будем развивать это приложение дальше по курсу). Сколько будет записей? 5? 50? 500? Вы не знаете. И если вы выберете массив, вы либо ставите огромный размер «на всякий случай», либо начинаете постоянно пересоздавать массив. Оба варианта — так себе: один расходует память и выглядит странно, второй — превращает код в спорт по переноске коробок.
Массив как коробка с пронумерованными ячейками
Массив удобно представлять как коробку с ячейками, у каждой ячейки есть номер (индекс). Коробка создаётся сразу «на N ячеек», и увеличить её нельзя: можно только сделать новую коробку побольше и переложить туда всё содержимое. В Kotlin массивы всегда мутабельны: элемент можно заменить через arr[i] = ..., но размер остаётся прежним. Это важно помнить, потому что «мутабельный массив» — это не «растущий массив».
Вот минимальный пример, который показывает, что массив «держит размер»:
fun main() {
val arr = arrayOf(1, 2, 3)
println(arr.size) // 3
arr[0] = 10
println(arr[0]) // 10
}
Здесь мы поменяли значение, но размер — как был 3, так и остался.
Как мы «расширяли» массив раньше и почему это раздражает
Когда в массив нужно «добавить элемент», типичный путь — создать новый массив большего размера и скопировать туда старый. Даже если вы делаете это аккуратно, мозг постоянно занят не задачей программы, а бухгалтерией памяти: «сколько мест», «куда копировать», «не забыть индекс».
Пример в стиле «прибавили один элемент» (идея, а не финальный стиль приложения):
fun main() {
var names = arrayOf("Ann", "Bob")
println(names.contentToString()) // [Ann, Bob]
names = names.copyOf(names.size + 1)
names[names.lastIndex] = "Cara"
println(names.contentToString()) // [Ann, Bob, Cara]
}
Работает? Да. Удобно? Не очень. И главное: как только таких «добавлений» становится много, код начинает выглядеть так, будто вы пишете не программу учёта расходов, а менеджер по переселению массивов.
2. Коллекции: контейнеры переменного размера
Коллекции — это следующий логичный шаг: контейнеры, которые умеют хранить переменное количество элементов (хоть ноль, хоть тысячу), и при этом дают удобные операции добавления, удаления, поиска.
С коллекциями вы чаще думаете «что я хочу сделать с данными», а не «как вручную переложить данные в новую коробку». Это один из тех моментов, когда язык программирования действительно делает жизнь проще, а не просто добавляет новые слова в ваш словарь.
Три базовые формы: List, Set, Map
Когда говорят «коллекции», обычно имеют в виду три больших семейства: список, множество и карта.
Чтобы было проще выбрать «по смыслу», держите перед глазами такую таблицу:
| Тип | Что хранит | Что важно | Повторы | Как обычно используют |
|---|---|---|---|---|
|
элементы | порядок и индекс | разрешены | список расходов, список задач, история событий |
|
элементы | уникальность | не хранятся отдельно | набор команд, набор категорий, «уже видели» |
|
пары ключ → значение | доступ по ключу | ключи уникальны | настройки, словарь, «id → объект», счётчики |
Сейчас мы не углубляемся в обходы, сортировки и «красивые операции над коллекциями» — это будет дальше. Наша задача сегодня проще: понять, почему коллекции не равны массивам, чем отличаются List/Set/Map, и научиться их создавать.
Важный нюанс: массивы — это не коллекции
Это тонкость, которая неожиданно спасает от путаницы: в Kotlin массивы считаются отдельной сущностью. В документации даже есть прямое замечание, что массивы — не тип коллекции.
Практически это означает две вещи. Во‑первых, у массива свой набор возможностей и свой набор функций, а у коллекций — свой. Во‑вторых, когда вы читаете код, важно не думать «массив и список — одно и то же, просто по-разному написано». Они похожи внешне (у обоих есть элементы), но логика использования часто различается.
3. Read-only vs mutable: почему Kotlin разделяет «читать» и «менять»
В Kotlin есть важная идея: «тип говорит о том, что с ним можно делать». Поэтому коллекции делятся на read-only интерфейсы и mutable интерфейсы. И это не просто «любовь к усложнению жизни студента», а практичная защита от случайных изменений данных.
В стандартной библиотеке для каждого базового вида коллекции есть пара интерфейсов: read-only (только чтение) и mutable (чтение + изменение). Именно поэтому существуют пары List/MutableList, Set/MutableSet, Map/MutableMap. Выглядит как «два почти одинаковых слова», но смысл у них разный.
Read-only коллекция — это «через эту ссылку нельзя менять»
Read-only тип (List, Set, Map) означает: через эту переменную у вас нет операций изменения (add, remove, присваивания по индексу и т.д.). Вы можете читать размер, брать элементы, проверять наличие, но не «перестраивать контейнер».
Это очень полезно, когда вы хотите явно сказать: «вот здесь мы только читаем». Например, функция получает список расходов и должна лишь посчитать сумму (а не «случайно удалить половину расходов в процессе подсчёта», потому что кто-то перепутал методы).
Пример «только чтение» выглядит так:
fun main() {
val names: List<String> = listOf("Ann", "Bob", "Ann")
println(names.size) // 3
println(names) // [Ann, Bob, Ann]
}
Здесь names — read-only список. Он может содержать повторы, и порядок элементов сохраняется.
Mutable коллекция — это «можно менять содержимое»
Mutable тип (MutableList, MutableSet, MutableMap) означает: вы можете добавлять, удалять, обновлять элементы.
Самый базовый пример:
fun main() {
val numbers = mutableListOf(1, 2, 3)
numbers.add(4)
println(numbers) // [1, 2, 3, 4]
}
Вот тут происходит магия, ради которой мы и пришли: список вырос без пересоздания коробки.
val и «мутабельность внутри»: частый источник путаницы
Очень важное правило, которое ломает мозг ровно один раз (а потом становится очевидным): val запрещает переназначить переменную, но не запрещает менять объект, на который эта переменная ссылается.
Посмотрите на пример и прочувствуйте разницу:
fun main() {
val xs = mutableListOf("one", "two", "three")
xs.add("four") // можно
println(xs) // [one, two, three, four]
// xs = mutableListOf("X") // нельзя: val нельзя переназначить
}
Это супер‑популярный стиль: «коллекция mutable, но переменная val». Так вы говорите: «контейнер можно менять, но сам контейнер (как объект) не подменяем на другой случайно».
Read-only — это не «абсолютно неизменяемо во всей вселенной»
Ещё один тонкий момент: read-only тип не обещает, что данные вообще никогда не меняются. Он обещает только то, что вы через эту конкретную ссылку не можете вызвать методы изменения.
Если где-то есть другая ссылка на ту же самую mutable-коллекцию, изменения могут происходить «с другой стороны». На этом этапе курса нам не нужно уходить в сложные темы про «разделяемые ссылки», но полезно запомнить интуицию: read-only — это ограничение API, а не волшебный купол неизменяемости.
4. Создание коллекций: listOf, mutableListOf, setOf, mapOf
До сих пор мы создавали массивы через arrayOf(...) или IntArray(...). С коллекциями всё ещё проще: в Kotlin есть фабричные функции, которые создают нужный тип по значениям. Это читается почти как обычный текст: «создай список из этих элементов».
И да, эти функции используются постоянно: вы будете видеть их в примерах, в документации, в реальном коде. Поэтому важно привыкнуть к ним сейчас, пока мы не начали строить логику на коллекциях.
listOf(...) и mutableListOf(...)
listOf(...) создаёт read-only List. Это удобно, когда у вас есть «набор значений», который вы не планируете менять.
mutableListOf(...) создаёт MutableList, который можно расширять и сокращать.
Пример рядом, чтобы сравнить:
fun main() {
val readOnly = listOf("A", "B", "C")
println(readOnly) // [A, B, C]
val mutable = mutableListOf("A", "B", "C")
mutable.add("D")
println(mutable) // [A, B, C, D]
}
Смысловой совет на будущее (без фанатизма): если не уверены, что нужно менять — начинайте с read-only. Mutable берите тогда, когда изменения реально нужны.
setOf(...) и mutableSetOf(...)
setOf(...) создаёт множество уникальных элементов. Если вы случайно повторите элемент, множество не станет «длиннее», потому что оно про уникальность.
Базовый пример:
fun main() {
val tags = setOf("kotlin", "kotlin", "jvm")
println(tags) // например: [kotlin, jvm]
println(tags.size) // 2
}
Заметьте: порядок вывода мы не используем как часть логики. Мы воспринимаем Set как «мешочек уникальных значений».
А вот mutable-вариант:
fun main() {
val commands = mutableSetOf("add", "list")
commands.add("remove")
commands.add("add") // повтор
println(commands) // [add, list, remove] (повтор не добавился)
}
mapOf(...), mutableMapOf(...) и инфиксная функция to
Карта (Map) хранит пары «ключ → значение». Ключи уникальны.
Создавать карту удобно через to:
fun main() {
val prices = mapOf(
"tea" to 90,
"coffee" to 150
)
println(prices) // {tea=90, coffee=150}
}
С mutableMapOf можно менять и добавлять значения:
fun main() {
val stock = mutableMapOf("apple" to 3)
stock["apple"] = 4
stock["banana"] = 1
println(stock) // {apple=4, banana=1}
}
Небольшая честность заранее: чтение map[key] может вернуть null, если ключа нет. Это нормальная модель безопасности. Подробно и спокойно мы разберём это в отдельной лекции про Map, а сейчас просто держим в голове как факт, чтобы не удивляться.
5. Пустые коллекции и вывод типа: почему компилятор просит подсказку
Когда вы создаёте непустую коллекцию, Kotlin обычно выводит тип элементов автоматически. Но если коллекция пустая, компилятору «не за что зацепиться»: он не видит ни одного элемента и не может понять, какой тип там должен быть. Поэтому пустые коллекции часто создают с явным указанием типа.
Это важный практический момент: вы будете создавать пустые коллекции постоянно, особенно в приложениях, где данные накапливаются по мере работы программы.
Непустая коллекция: тип выводится сам
Пример, где Kotlin сам понимает тип:
fun main() {
val a = listOf(1, 2, 3) // List<Int>
val b = setOf("kotlin", "jvm") // Set<String>
val c = mapOf("a" to 1) // Map<String, Int>
println(a) // [1, 2, 3]
println(b) // [kotlin, jvm]
println(c) // {a=1}
}
Здесь всё просто: элементы подсказали тип.
Пустая коллекция: тип нужно задать
Теперь создадим пустые коллекции правильно:
fun main() {
val items = mutableListOf<String>()
val categories = mutableSetOf<String>()
val counts = mutableMapOf<String, Int>()
items.add("milk")
categories.add("food")
counts["milk"] = 1
println(items) // [milk]
println(categories) // [food]
println(counts) // {milk=1}
}
Обратите внимание: в каждой строке <String> или <String, Int> — это не «лишний шум», а подсказка компилятору, без которой он не обязан угадывать.
6. Мини‑набросок приложения: зачем нам коллекции в реальности
С этого дня курса мы начинаем собирать практическое консольное приложение (без классов и ООП на этом этапе). Чтобы не было ощущения «коллекции ради коллекций», давайте привяжем всё к простой жизненной задаче: хранить вводимые пользователем записи о расходах. Сегодня мы сделаем только самое начало: хранилище в памяти и пару действий. Команды, нормализацию ввода и полноценную логику мы разберём позже — сейчас важно увидеть, что коллекции решают проблему размера.
Представим запись расхода пока просто строкой (вроде "food coffee 300"). Да, это ещё не идеальная модель данных — но на текущем этапе знаний это честный и рабочий временный формат.
Версия на массиве: неудобно и шумно
Вот как выглядел бы «сбор расходов» на массиве (упрощённо):
fun main() {
var expenses = emptyArray<String>()
println("Введите расход (или пустую строку для выхода):")
while (true) {
val line = readln().trim()
if (line.isEmpty()) break
expenses = expenses.copyOf(expenses.size + 1)
expenses[expenses.lastIndex] = line
}
println("Сохранено записей: ${expenses.size}") // например: 2
}
Работает, но ощущение такое, что мы занимаемся не расходами, а переупаковкой данных.
Версия на MutableList: короче, чище, ближе к смыслу
А теперь то же самое на коллекциях:
fun main() {
val expenses = mutableListOf<String>()
println("Введите расход (или пустую строку для выхода):")
while (true) {
val line = readln().trim()
if (line.isEmpty()) break
expenses.add(line)
}
println("Сохранено записей: ${expenses.size}") // например: 2
}
Разница не только в количестве строк. Разница в том, о чём вы думаете, пока пишете код. Здесь у вас в голове «добавить запись», а не «пересоздать массив и скопировать». И именно за это коллекции любят в прикладном коде.
Как быстро выбрать контейнер: простая схема для начинающих
В начале знакомства с коллекциями легко зависнуть: «а что выбрать-то? список? множество? карту?». Нормально. Давайте зафиксируем очень простую модель выбора, без погружения в детали производительности и внутренней реализации.
Если вам важен порядок элементов и допускаются повторы, почти всегда это List. Если вам нужна уникальность значений, берите Set. Если вы хотите хранить соответствие «ключ → значение» и доставать по ключу, берите Map.
Можно даже представить это как мини‑блок‑схему:
flowchart TD
A["Нужно хранить несколько значений"] --> B{"Нужен доступ по ключу?"}
B -->|Да| M["Map⟨K, V⟩"]
B -->|Нет| C{"Нужна уникальность?"}
C -->|Да| S["Set⟨T⟩"]
C -->|Нет| L["List⟨T⟩"]
Эта схема не «высшая математика», но она помогает быстро принять решение в большинстве бытовых случаев.
7. Типичные ошибки при первом знакомстве с коллекциями
Ошибка №1: путать read-only и immutable («раз нельзя менять через List, значит никто не меняет вообще»).
Read-only типы ограничивают операции через конкретную ссылку, но не гарантируют, что где-то в другом месте нет mutable-ссылки на те же данные. На старте курса достаточно помнить простое правило: read-only — это про API и доступные методы, а не про волшебную неизменяемость мира.
Ошибка №2: держать mutable-коллекцию в var просто «на всякий случай».
Часто новички пишут var list = mutableListOf(...), потому что «ну раз там изменения, значит var». На самом деле изменения происходят методами коллекции (add, remove), а не переназначением переменной. Поэтому удобный и безопасный стиль — val list = mutableListOf(...): так вы случайно не подмените коллекцию другой.
Ошибка №3: создавать пустую коллекцию без типа и удивляться ошибке компиляции.
Когда вы пишете mutableListOf() без элементов, компилятор не обязан угадывать, MutableList чего именно вы хотите. Лекарство простое: указывать тип явно (mutableListOf<String>(), mutableMapOf<String, Int>()) или задавать ожидаемый тип переменной.
Ошибка №4: воспринимать Set как «список без индексов, но в остальном то же самое».
У множества другой смысл: уникальность. Если вы начинаете ждать от Set «первый элемент», «второй элемент», «порядок добавления», вы строите хрупкую логику. На уровне сегодняшней темы мы относимся к Set как к «набору уникальных значений», и этого достаточно.
Ошибка №5: пытаться «обновлять» listOf/setOf/mapOf, как будто они mutable.
Фабрики listOf, setOf, mapOf создают read-only коллекции. Если вы заранее знаете, что будете менять содержимое, выбирайте mutableListOf, mutableSetOf, mutableMapOf. Это не прихоть, а способ сделать намерения кода понятными без комментариев.
Ошибка №6: забывать, что массивы и коллекции — разные сущности.
Иногда новичок видит arrayOf(...), потом видит listOf(...) и думает, что это «одно и то же, просто другое слово». Но массивы — отдельный тип, со своими правилами (например, массивы всегда мутабельны по элементам). Когда вы выбираете контейнер, вы выбираете и стиль работы с данными, поэтому лучше сразу держать различие в голове.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ