Введение: «расскажите про коллекции»

«Расскажите, какие коллекции есть в Kotlin» — вопрос, который задают почти на каждом собеседовании, и почти всегда поверхностно. Кандидат перечисляет List, Set, Map — и застревает на первом же уточнении: «а чем List отличается от ArrayList?», «а почему в val список всё-таки меняется?»

Тема коллекций коварная: код с ними пишет каждый, а системного понимания не хватает. В Kotlin у каждой коллекции два интерфейса — read-only и mutable, а на JVM под ними лежат конкретные реализации Java. Разберём по порядку, опираясь на официальную документацию.

List, Set, Map: три базовых интерфейса

Определения из документации простые, но именно их путают:

  • List — упорядоченная коллекция с доступом по индексу. Элементы могут повторяться, индексы — от 0 до size - 1.
  • Set — коллекция уникальных элементов. Дубликаты схлопываются, порядок в общем случае не важен, null может быть только один.
  • Map — коллекция пар «ключ — значение». Ключи уникальны, значения могут повторяться. Map не наследует Collection, но считается коллекцией Kotlin.
CollectionsBasics.kt
fun main() {
    val list = listOf("one", "two", "one")
    println(list[0])   // one
    println(list.size) // 3 — дубликаты допустимы

    val set = setOf(1, 2, 3, 2, 1)
    println(set)       // [1, 2, 3] — дубликаты схлопнулись

    val map = mapOf("key1" to 1, "key2" to 2)
    println(map["key1"]) // 1 — доступ по ключу
}

Как не перепутать

Порядок и дубликаты — главные различия. List: порядок важен, дубликаты есть. Set: дубликатов нет, порядок не гарантирован. Map: ключи уникальны, значение одно на ключ.

Обратите внимание на to в mapOf("key1" to 1) — это infix-функция, создающая пару Pair. Для небольших карт это нормально, но в документации честно предупреждают: to создаёт короткоживущий объект Pair, и при строгих требованиях к производительности карту лучше заполнять напрямую — mutableMapOf плюс запись по ключу.

Read-only и mutable: как устроена изменяемость

Ключевая особенность Kotlin, которая отличает его от Java: у каждого типа коллекции два интерфейса. List — read-only (только чтение), MutableList — расширяет его операциями записи. То же для Set/MutableSet и Map/MutableMap.

Важный нюанс, который валит кандидатов: val не делает коллекцию неизменяемой. val защищает ссылку, а не содержимое:

Mutability.kt
fun main() {
    val numbers = mutableListOf(1, 2, 3)
    numbers.add(4)    // ок: содержимое меняется
    numbers[0] = 100  // ок
    println(numbers)  // [100, 2, 3, 4]

    val readOnly: List<Int> = numbers
    // readOnly.add(5) — ошибка компиляции: тип List не имеет add
}

Тип List — это «обещание не менять». Реализация может быть изменяемой, но через read-only ссылку вы её не троньте — компилятор не даст. Именно поэтому в сигнатурах API принято отдавать List, а принимать MutableList только там, где действительно нужно что-то добавить.

Из этого же следует ковариантность: read-only коллекции ковариантны — List<Rectangle> можно использовать там, где ожидается List<Shape>. С mutable-коллекциями так нельзя: иначе в MutableList<Shape> можно было бы положить Circle, нарушив типы.

Типичная ошибка на собеседовании: «у меня val — значит, коллекция не меняется». Не так: val защищает ссылку, а изменяемость определяет тип — List или MutableList. Смешение этих двух осей («изменяемость ссылки» и «изменяемость содержимого») — главный источник путаницы.

Что лежит под капотом на JVM

Kotlin-коллекции на JVM — это интерфейсы поверх Java-коллекций, а «дефолтные» реализации выбирает стандартная библиотека:

  • MutableList — ArrayList: изменяемый массив, быстрый доступ по индексу.
  • MutableSet — LinkedHashSet: сохраняет порядок вставки при обходе.
  • MutableMap — LinkedHashMap: тоже сохраняет порядок вставки.

Проверяется это одной строкой:

JvmImplementations.kt
fun main() {
    val list = mutableListOf("a", "b", "c") // на JVM это ArrayList
    val set = mutableSetOf("a", "b", "c")   // LinkedHashSet
    val map = mutableMapOf("a" to 1)        // LinkedHashMap

    println(list::class) // class java.util.ArrayList
    println(set::class)  // class java.util.LinkedHashSet
    println(map::class)  // class java.util.LinkedHashMap
}

Альтернативы существуют и нужны осознанно:

  • HashSet / HashMap — не дают никаких гарантий порядка, но, как отмечает документация, требуют меньше памяти на то же количество элементов.
  • LinkedList — двусвязный список: вставка и удаление по позиции быстрые, а вот доступ по индексу обходит список от ближайшего конца. В Kotlin-коде встречается редко: дефолтный ArrayList почти всегда лучше.

Создавать коллекции можно не только через listOf/mutableListOf. Полезно знать конструктор с инициализатором и строители, которые возвращают read-only результат:

Builders.kt
fun main() {
    val doubled = List(3) { it * 2 } // [0, 2, 4]
    println(doubled)

    val result: List<Int> = buildList {
        add(1)
        add(2)
        add(3)
    }
    println(result) // [1, 2, 3]

    val empty = emptyList<String>() // пустая коллекция с явным типом
}

Подводный камень: listOf() без типа не скомпилируется — «пустую» коллекцию нужно объявлять с явным типом: val empty = emptyList<String>(). А buildList, buildSet, buildMap — удобный способ собрать коллекцию и вернуть её read-only: внутри строителя работаете с mutable-версией, наружу отдаёте неизменяемый интерфейс.

Равенство и копирование

Коллекции сравниваются структурно: две коллекции равны, если у них одинаковые элементы и одинаковое поведение — в List важна позиция, в Set и Map порядок не имеет значения.

Equality.kt
fun main() {
    val listA = listOf(1, 2, 3)
    val listB = listOf(1, 2, 3)
    println(listA == listB) // true

    val setA = setOf(1, 2, 3)
    val setB = setOf(3, 2, 1)
    println(setA == setB) // true — порядок не важен

    val mapA = mapOf("a" to 1, "b" to 2)
    val mapB = mapOf("b" to 2, "a" to 1)
    println(mapA == mapB) // true — пары те же
}

Для сравнения используется обычный == — внутри он вызывает equals. Это удобно в тестах и в data-классах.

Ещё один нюанс — копирование. toList(), toSet(), toMutableList() создают новую коллекцию — снимок на момент вызова. Изменения исходной коллекции на снимок не влияют (это поверхностная копия: сами элементы — те же объекты):

Snapshot.kt
fun main() {
    val source = mutableListOf(1, 2, 3)
    val copy = source.toList()

    source.add(4)
    println(source) // [1, 2, 3, 4]
    println(copy)   // [1, 2, 3] — снимок не изменился
}

Это часто всплывает в коде: передали куда-то «копию», а потом удивляются, почему список вырос. Если нужен именно снимок — toList(), если нужно, чтобы изменения были видны — передавайте ссылку как есть.

Ошибки на собеседовании

Соберу промахи, которые я вижу на собеседованиях чаще всего.

  • List — порядок и дубликаты; Set — уникальность; Map — пары «ключ — значение».
  • val не делает коллекцию неизменяемой — изменяемость определяет тип (List vs MutableList).
  • На JVM дефолты: ArrayList, LinkedHashSet, LinkedHashMap.
  • HashMap/HashSet — без гарантий порядка, но экономнее по памяти.
  • Равенство коллекций — структурное: listA == listB по элементам.
  • toList() — снимок; buildList — read-only результат из mutable-строителя.

Ошибка-классика: «Set хранит элементы в порядке добавления, поэтому first() вернёт первый добавленный». Это правда только для дефолтного LinkedHashSet. Как только кто-то передаёт HashSet — порядок исчезает, и код на first() становится непредсказуемым. Рассчитывать на порядок в Set — антипаттерн.

Две оси изменяемости

val/var — изменяемость ссылки, List/MutableList — изменяемость содержимого. Кандидат, который разделяет эти оси, отвечает на тему коллекций без запинок — и заодно показывает понимание дизайна языка.

На мок-собеседованиях я часто прошу написать функцию, которая принимает List, и спрашиваю: «что будет, если сюда передадут mutableListOf?». Ответ — «ничего страшного: тип параметра read-only, через него менять не дадут» — показывает понимание, а не заученный текст.

Готовитесь к переходу на middle?

Бесплатная диагностика: разберу ваши пробелы и составлю план роста за 30–40 минут. Без обязательств — если менторство вам не подойдёт, честно скажу.

Итоги

Коллекции — одна из тех тем, где «знаю, потому что пользуюсь» не работает: собеседование требует системного ответа.

  • Интерфейсы: List (порядок, дубликаты), Set (уникальность), Map (ключи уникальны) — у каждого read-only и mutable версия.
  • Изменяемость: val защищает ссылку; содержимое меняется через Mutable*-интерфейсы, а read-only тип — это контракт.
  • JVM: дефолтные реализации — ArrayList, LinkedHashSet, LinkedHashMap; HashMap/HashSet — альтернатива без гарантий порядка.

Главное

Двухслойная структура — интерфейсы Kotlin поверх реализаций JVM — это то, что отличает уверенный ответ от «ну, List — это список». Назовите оба слоя, и вопрос закрыт.

Если коллекции, дженерики и другие базовые темы языка всплывают на каждом собеседовании зияющими пробелами — не нужно готовиться в одиночку. Напишите в Telegram, разберём вашу ситуацию и составим план, что закрыть в первую очередь.