1. Зачем нужен Set, если уже есть List
Если List — это «очередь людей с номерками», то Set — это «список гостей на входе», где важно только одно: человек либо есть в списке, либо его нет. В программировании такая модель встречается постоянно: «разрешённые команды», «уникальные теги», «уже обработанные значения», «не добавлять повторно». Set помогает выражать эту идею напрямую, а не через костыли вроде «проверю список перед добавлением».
С точки зрения Kotlin, Set<T> хранит уникальные элементы: одно и то же значение не может существовать внутри множества «в двух экземплярах». Обычно ещё говорят, что порядок в множестве не является частью смысла. Документация формулирует это так: Set хранит уникальные элементы, а их порядок «обычно не определён».
Представьте, что вы делаете маленькое консольное приложение, которое хранит записи расходов (мы начали это в прошлой лекции на списках). В какой-то момент вам почти наверняка захочется:
- хранить список расходов (там повторы допустимы: кофе можно купить 200 раз);
- но при этом держать уникальный набор категорий, которые уже встречались (чтобы показывать подсказки или валидировать ввод).
Вот это и есть классическое место для Set.
2. Set и MutableSet: чтение и изменения
Когда вы впервые видите Set и MutableSet, очень хочется подумать: «О, Set — это как val, а MutableSet — как var». И вот здесь Kotlin коварно улыбается.
Set<T> — это интерфейс для чтения: размер, проверка наличия, обход.
MutableSet<T> — это интерфейс для изменения: добавление, удаление.
То есть разница не в том, можно ли изменить переменную, а в том, какие операции доступны через тип. Этот стиль такой же, как у List/MutableList.
И ещё один важный момент: mutable-множество почти всегда стоит хранить в val. Тогда вы не сможете случайно «перепривязать» переменную к другому множеству, но сможете честно менять содержимое через add/remove. Эта идея уже звучала в обзоре коллекций Kotlin: val защищает ссылку, но не запрещает write-операции у mutable-коллекции.
Создание множеств: setOf, mutableSetOf, пустые множества
Когда вы только начинаете, удобнее всего создавать множества фабричными функциями стандартной библиотеки. Они читаются как «создай мне список/множество прямо сейчас», без лишнего шума. А ещё это отлично ложится на привычный стиль из listOf(...) и mutableListOf(...), который мы уже использовали для списков.
setOf(...) и «схлопывание» дублей
fun main() {
val tags = setOf("kotlin", "kotlin", "jvm")
println(tags) // [kotlin, jvm]
println(tags.size) // 2
}
Здесь важно не то, в каком порядке напечатается множество, а то, что повтор "kotlin" не создаёт второй элемент. Set — это про уникальность, а не про «сколько раз встретилось».
Пустое множество: иногда нужен явный тип
Как и со списками, пустая коллекция часто требует подсказки компилятору: «а элементы какого типа ты тут ожидаешь?».
fun main() {
val usedCategories = mutableSetOf<String>()
usedCategories.add("food")
println(usedCategories) // [food]
}
Если вы попробуете написать просто mutableSetOf() без <String>, Kotlin может честно ответить: «Я не знаю, что тут должно лежать». И он будет прав.
3. Базовые операции множества
Уникальность как контракт: add() и возвращаемое Boolean
В списке add() почти всегда означает «элемент точно добавился (в конец)». В множестве всё интереснее: add(x) означает «попробуй добавить, но если такой элемент уже есть — ничего не меняй». Поэтому операция возвращает Boolean: получилось ли реально добавить новый элемент.
Это очень полезно, когда вы хотите сделать «один раз сообщить» или «не дублировать действие».
fun main() {
val seen = mutableSetOf<Int>()
println(seen.add(10)) // true
println(seen.add(10)) // false
println(seen) // [10]
}
Вторая попытка не добавила «вторую десятку» — она просто не нужна по смыслу множества.
Проверка принадлежности: contains() и оператор in
Самая «натуральная» операция для Set — это вопрос: «А элемент тут есть?». В Kotlin это можно делать двумя одинаково корректными способами: через contains(x) и через x in set. Второй вариант обычно читается приятнее, особенно в условиях.
И да, это одна из причин, почему множество так любят для проверок «разрешено/запрещено»: код становится почти разговорным.
fun main() {
val allowedCommands = setOf("add", "list", "remove")
val cmd = "list"
println(cmd in allowedCommands) // true
}
На этом месте очень удобно держать в голове образ: Set — это «набор допустимых значений». Он не про порядок, не про индексы, и уж точно не про «удали элемент номер 3».
Удаление: remove() и что оно возвращает
Удаление в MutableSet похоже на remove(value) у списка, но по смыслу естественнее: в множестве нет индексов, поэтому удаление всегда «по значению». Как и add, оно возвращает Boolean: удалось ли что-то удалить.
fun main() {
val usedCategories = mutableSetOf("food", "transport")
println(usedCategories.remove("food")) // true
println(usedCategories.remove("food")) // false
println(usedCategories) // [transport]
}
Второй раз удалять нечего — "food" уже отсутствует.
4. Порядок, равенство и «убрать дубли»
Можно ли полагаться на порядок в Set
Интуитивно хочется: «Ну оно же печатается как "[a, b, c]", значит порядок есть». И Kotlin действительно часто делает так, что порядок выглядит стабильным. Но здесь важно отделить «как сегодня реализовано» от «какой смысл у структуры данных».
Формально Set — это множество: уникальные элементы, и порядок «вообще не главное».
При этом в стандартной библиотеке Kotlin реализация MutableSet по умолчанию — LinkedHashSet, и она сохраняет порядок вставки, поэтому first() и last() могут быть предсказуемыми именно в этой реализации.
Практическое правило для начинающего звучит так: относитесь к Set как к контейнеру, где порядок не является контрактом. Если ваша логика разваливается от перестановки элементов — скорее всего, вам нужна другая структура (обычно список).
«Убрать дубли»: toSet() как быстрый фильтр уникальности
Один из самых частых прикладных сценариев: у вас уже есть список (или массив) с повторами, а вам нужен набор уникальных значений. Например, пользователь вводил категории как попало, и вы хотите получить «список всех уникальных категорий».
Для этого есть toSet(). Она создаёт новую коллекцию и «схлопывает» дубли. Важно, что функции копирования/конвертации вроде toSet() делают снимок на момент вызова: изменения исходной коллекции потом не «магически» отражаются в результате.
fun main() {
val words = listOf("a", "b", "a", "c")
val uniqueWords = words.toSet()
println(uniqueWords) // [a, b, c]
println(uniqueWords.size) // 3
}
То же самое работает и для массивов: массив можно превратить в Set, и вы увидите, что повторы исчезли, а при превращении в List — останутся.
Равенство множеств: важнее элементы, а не порядок
У списков равенство зависит от порядка: [1, 2] не равно [2, 1]. У множеств логика другая: если элементы одинаковые, множества равны, даже если вы создавали их «в разном порядке». Это соответствует математическому смыслу множества и полезно в реальной жизни, когда вы сравниваете наборы разрешений/тегов/фичей.
В документации Kotlin это показано на примере: множества равны, если в них одни и те же элементы.
fun main() {
val a = setOf(1, 2, 3, 4)
val b = setOf(4, 3, 2, 1)
println(a == b) // true
}
5. Пример: учёт расходов и уникальные категории
Сейчас мы продолжим наш мини-проект дня: «консольный учёт расходов», без классов и без сложной архитектуры — просто чтобы набить руку на коллекциях. В прошлой лекции мы хранили записи расходов в MutableList<Triple<String, String, Int>>, где поля — это категория, описание и сумма.
Теперь добавим ещё одну структуру: MutableSet<String> для уникальных категорий, которые уже встречались. Это даст нам две полезные вещи: быстрый ответ на вопрос «категория уже была?» и возможность в любой момент получить «список категорий без дублей», не пробегая вручную по всему списку.
Мини-схема данных
Нарисуем очень простую схему, чтобы голова не пыталась держать всё в памяти (память — штука дорогая, особенно в понедельник утром):
flowchart LR
Records["records: MutableList⟨Triple⟨Category, Title, Amount⟩⟩"]
Categories["knownCategories: MutableSet⟨Category⟩"]
Records -->|"добавили запись"| Categories
Идея: каждая новая запись может «подкинуть» новую категорию в множество. Если категория уже была — множество просто не изменится.
Код: добавление записи и обновление множества категорий
Здесь мы сознательно пишем маленькие функции и короткие примеры, чтобы код читался как учебник, а не как «взрослый прод» (он ещё успеет вас догнать).
fun addExpense(
records: MutableList<Triple<String, String, Int>>,
knownCategories: MutableSet<String>,
category: String,
title: String,
amount: Int
) {
records.add(Triple(category, title, amount))
knownCategories.add(category)
}
Обратите внимание: knownCategories.add(category) ничего не ломает, даже если категория уже была. В этом и кайф множества: оно защищает нас от дублей по контракту.
Проверка «новая категория или нет» через add()
Теперь используем тот факт, что add возвращает Boolean, чтобы вывести пользователю (или самим себе в отладке) полезное сообщение: новая категория или нет.
fun addCategoryIfNew(knownCategories: MutableSet<String>, category: String) {
val added = knownCategories.add(category)
if (added) {
println("Новая категория: $category") // например: Новая категория: food
} else {
println("Категория уже была: $category") // например: Категория уже была: food
}
}
Список так «из коробки» не умеет: в списке вам бы пришлось отдельно делать contains, а потом add, и легко ошибиться (или просто забыть).
Полный мини-пример: записи и категории
Соберём маленький main, который демонстрирует идею целиком. Он не претендует на полноценный интерфейс — это учебная «песочница», где мы руками проверяем, что структуры данных ведут себя так, как обещают.
fun main() {
val records = mutableListOf<Triple<String, String, Int>>()
val knownCategories = mutableSetOf<String>()
addExpense(records, knownCategories, "food", "coffee", 300)
addExpense(records, knownCategories, "transport", "taxi", 1200)
addExpense(records, knownCategories, "food", "lunch", 550)
println(records.size) // 3
println(knownCategories) // [food, transport] (порядок не считаем контрактом)
println("food" in knownCategories) // true
}
Здесь очень хорошо видно: записей три (две из них в категории "food"), а категорий всего две — потому что множество хранит уникальные значения.
6. Set и null: да, можно — но аккуратно
Иногда данные приходят «грязные»: категория не распарсилась, пользователь ввёл пустую строку, а вы пока не навели порядок. Раз уж мы уже проходили null-safety, полезно знать: Set может хранить null, но как уникальный элемент — максимум один null в множестве. Документация прямо говорит, что null в Set тоже уникален: второй null не появится.
Это не призыв хранить null везде, где можно (мы не коллекционируем проблемы), но полезное знание для отладки и переходных состояний.
fun main() {
val categories: MutableSet<String?> = mutableSetOf()
categories.add(null)
categories.add(null)
println(categories.size) // 1
println(categories) // [null]
}
7. Типичные ошибки при работе с Set и MutableSet
Ошибка №1: ожидать, что множество хранит повторы «как список».
Новички часто добавляют одно и то же значение в MutableSet и удивляются: «Почему размер не растёт?». Потому что Set по смыслу хранит уникальные элементы. Если вам нужно хранить «каждый ввод пользователя», даже если он повторяется, это задача списка. А если вам нужно хранить «какие значения вообще встречались» — это задача множества.
Ошибка №2: писать логику, зависящую от порядка элементов в Set.
Иногда всё выглядит так, будто порядок есть, и рука тянется сделать «возьму первый элемент и буду считать его главным». В Kotlin действительно часто используется реализация, сохраняющая порядок вставки, но смысл Set — не в этом. Как только вы начнёте опираться на порядок, код станет хрупким: поменяется реализация, поменяется источник данных, и поведение неожиданно поплывёт.
Ошибка №3: пытаться обращаться к Set по индексу.
После List очень хочется написать что-то вроде mySet[0]. Но у множества нет индексов как части модели. Это сигнал, что вы выбрали не тот контейнер под задачу. Если вам нужна позиция — берите List. Если вам нужна уникальность и проверки «есть/нет» — берите Set.
Ошибка №4: забывать, что add/remove возвращают Boolean, и терять полезную информацию.
Многие просто вызывают add(x) и не смотрят на результат, хотя это почти бесплатная подсказка о том, произошло ли изменение. Когда вы делаете валидацию ввода или ведёте список «уже виденных значений», этот Boolean помогает писать код проще и понятнее: «если добавилось впервые — сообщи/сделай действие, иначе — пропусти».
Ошибка №5: создавать пустое mutableSetOf() без типа и получать ошибку компиляции.
Пустая коллекция не даёт компилятору подсказок, поэтому иногда нужно явно указать тип: mutableSetOf<String>(). Это нормально и не делает вас «хуже программистом». Скорее наоборот: вы явно фиксируете контракт, а Kotlin перестаёт гадать, что вы имели в виду.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ