$ sudo teach IT

Зачем нужна ещё одна коллекция

Представьте список гостей на вечеринке. Порядок, в котором они пришли, никого не волнует, а вот повторять одного и того же человека в списке дважды бессмысленно — он либо в списке, либо нет. Для массива это неудобная задача: чтобы проверить, есть ли уже такой гость, придётся пройти по всем именам one by one, а чтобы не допустить повтора — проверять перед каждым добавлением. Чем длиннее список, тем дольше проверка.

Для таких случаев в Swift есть отдельная коллекция — набор уникальных значений без определённого порядка. Официально она называется Set. Внутри неё элементы хранятся не подряд, а по вычисленному «отпечатку» значения, поэтому проверка «есть ли элемент» происходит практически мгновенно, а не перебором. Обратная сторона — вы никогда не знаете заранее, в каком порядке Set отдаст элементы при обходе.

Объявление и создание

Set — универсальный (generic) тип, поэтому при объявлении переменной указывают, элементы какого типа он хранит: Set<String>, Set<Int> и так далее.

var fruits: Set<String> = ["яблоко", "банан", "груша"]
print(fruits.count)

Здесь важна одна деталь, которая ловит почти всех новичков. Литерал в квадратных скобках сам по себе не говорит компилятору, что вы хотите множество, — так же выглядит и литерал массива. Без явной аннотации типа Set<String> компилятор выберет тип по умолчанию для такого литерала, а это Array:

let numbers = [1, 2, 2, 3] // это Array<Int>, а не множество
print(numbers.count) // 4, повтор никуда не делся

Убрать повторы из уже существующего массива можно, передав его в инициализатор Set:

let numbersArray = [1, 2, 2, 3, 3, 3]
let numbersSet = Set(numbersArray)
print(numbersSet.count) // 3 — повторы схлопнулись

Здесь аннотация типа не нужна: тип виден из вызова Set(...) явно, ловушка возникает только у литерала без контекста.

Требование Hashable

Элементом множества может быть не любой тип, а только тот, что умеет вычислять свой «отпечаток» — хеш. В официальной терминологии такой тип соответствует протоколу Hashable. Не пугайтесь термина: все встроенные типы, с которыми вы уже работали — Int, String, Double, Bool, а также кортежи и массивы из таких типов, — уже умеют это делать «из коробки». Вам не нужно ничего настраивать, чтобы положить строки или числа в Set. Написать свой тип, который тоже это умеет, вы сможете позже, когда доберётесь до структур и классов.

Добавление и удаление

insert добавляет элемент. Если такого значения ещё не было, оно попадёт в множество; если было — ничего не изменится, ведь повторы не допускаются. Метод возвращает не просто факт успеха, а пару значений: было ли значение действительно вставлено, и какое значение сейчас лежит в множестве под этим «именем».

let result = fruits.insert("апельсин")
print(result.inserted) // true — добавили новый фрукт

let result2 = fruits.insert("банан")
print(result2.inserted) // false — банан уже был, множество не изменилось

remove удаляет значение, если оно есть; contains проверяет присутствие; count и isEmpty работают так же, как у массива.

fruits.remove("груша")
print(fruits.contains("груша")) // false
print(fruits.isEmpty) // false, другие фрукты остались

Операции над множествами

Настоящая сила Set — в операциях, которые в математике называются операциями над множествами. Они принимают на вход другое множество и возвращают новое.

ОперацияЧто делает
unionобъединение — все элементы из обоих множеств
intersectionпересечение — только то, что есть в обоих сразу
subtractingразность — элементы первого, которых нет во втором
symmetricDifferenceэлементы, которые есть только в одном из двух множеств, но не в обоих
let a: Set<Int> = [1, 2, 3, 4]
let b: Set<Int> = [3, 4, 5, 6]

print(a.union(b).sorted())               // [1, 2, 3, 4, 5, 6]
print(a.intersection(b).sorted())        // [3, 4]
print(a.subtracting(b).sorted())         // [1, 2]
print(b.subtracting(a).sorted())         // [5, 6] — порядок аргументов важен
print(a.symmetricDifference(b).sorted()) // [1, 2, 5, 6]

Обратите внимание на subtracting: a.subtracting(b) и b.subtracting(a) — это разные результаты, операция не симметрична, в отличие от union и intersection.

Есть ещё три метода, которые не создают новое множество, а отвечают на вопрос да/нет о взаимном расположении двух множеств:

  • isSubset(of:) — все элементы этого множества входят в другое
  • isSuperset(of:) — это множество содержит все элементы другого
  • isDisjoint(with:) — общих элементов нет вообще
print(a.isSubset(of: [1, 2, 3, 4, 5]))   // true
print(a.isSuperset(of: [1, 2]))          // true
print(a.isDisjoint(with: [10, 20]))      // true

Массив или множество

Выбор зависит от того, что вам важнее. Если нужен порядок элементов, повторы допустимы и коллекция обычно короткая — берите Array. Если важна уникальность и частая проверка «есть ли такой элемент» на большой коллекции — Set справится быстрее массива, потому что не перебирает элементы один за другим, а сразу вычисляет, где искать.

Порядок обхода

Set не хранит элементы в порядке добавления и не гарантирует вообще никакого стабильного порядка — при следующем запуске программы порядок обхода того же множества может оказаться другим. Если для вывода на экран или для проверки в тестах вам нужен предсказуемый порядок, превратите множество в отсортированный массив методом sorted() — он вернёт Array с элементами по возрастанию. Подробно про сортировку своих правил поговорим отдельно позже в этом модуле.

Частые ошибки

  • Написать литерал let set = [1, 2, 3] и ожидать множество — без аннотации типа это массив, повторы никуда не денутся.
  • Сравнивать или выводить множество в тестах напрямую, забыв, что порядок обхода не гарантирован — код может проходить проверку локально и падать на сервере просто из-за другого порядка. Спасает sorted().
  • Пытаться положить в множество тип, который не поддерживает Hashable — компилятор откажется компилировать код, а не тихо всё сломает.
  • Путать subtracting местами: a.subtracting(b) и b.subtracting(a) — разные множества.

Резюме

  • Set хранит только уникальные значения без гарантированного порядка.
  • Литералу обязательно нужна аннотация типа Set<T>, иначе получится Array.
  • Из массива множество делают через Set(массив) — повторы схлопываются.
  • insert возвращает пару: вставлено ли значение и что сейчас лежит в множестве; есть также remove, contains, count, isEmpty.
  • Операции union, intersection, subtracting, symmetricDifference строят новые множества; isSubset(of:), isSuperset(of:), isDisjoint(with:) отвечают да/нет.
  • Элементы множества обязаны соответствовать Hashable — у встроенных типов это уже есть.
  • Для предсказуемого порядка при выводе или в тестах превращайте множество в массив через sorted().

Проверьте себя

3 вопроса

Сколько разных значений

Напишите функцию uniqueCount, которая принимает массив целых чисел и возвращает количество уникальных значений в нём — то есть сколько разных чисел встречается, если не считать повторы.

Например, для [1, 2, 2, 3] ответ — 3, а для пустого массива — 0.

Общие теги

У двух постов есть списки тегов в виде массивов строк. Напишите функцию commonTags, которая принимает два массива тегов и возвращает массив тегов, встречающихся в обоих списках, без повторов и отсортированный по алфавиту.

Например, для ["swift", "ios", "backend"] и ["ios", "web", "swift"] результат — ["ios", "swift"].

Порядок тегов на входе может быть любым, а внутри одного списка теги могут повторяться — это не должно влиять на результат.