$ sudo teach IT

Зачем нужен один код для разных типов

Представьте, что вам нужна функция, которая находит из трёх чисел среднее по значению. Завтра та же логика понадобится для трёх строк — сравнить их по алфавиту, а послезавтра для трёх дат. Переписывать одну и ту же логику под каждый новый тип — верный способ развести дублирование: если в логике найдётся ошибка, чинить её придётся сразу в нескольких местах, и рано или поздно одну из копий забудут поправить.

Вы уже пользовались готовым решением этой проблемы, даже не задумываясь об этом: Array, Optional, функция max устроены так, что один и тот же код работает и с Int, и со String, и с любым вашим собственным типом. Это дженерики (generics) — способ написать функцию или тип один раз, с параметром типа вместо конкретного типа, а компилятор сам подставит нужный тип на месте вызова.

Обобщённые функции: параметр типа

Параметр типа записывается в угловых скобках сразу после имени функции. Буква T — просто традиционное сокращение от Type, можно назвать иначе, но короткие заглавные буквы — принятый в Swift стиль:

func swapValues<T>(_ a: inout T, _ b: inout T) {
    let temp = a
    a = b
    b = temp
}

var x = 1
var y = 2
swapValues(&x, &y)
print(x, y)

Компилятор заранее не знает, что скрывается за T. На месте вызова swapValues(&x, &y) он смотрит на переданные аргументы, видит два Int и как будто на лету подставляет Int вместо T по всему телу функции. Вызовите ту же функцию с двумя строками — компилятор так же подставит String, без единой лишней строчки кода с вашей стороны. Такая подстановка называется специализацией: для каждого используемого набора типов появляется отдельная версия функции, но написана она была один раз.

Ограничения типов: T: Comparable

Без уточнений компилятор знает про T только то, что это какой-то один и тот же тип у всех параметров, помеченных этой буквой, — и всё. Сложить, сравнить или напечатать такие значения нельзя:

func bigger<T>(_ a: T, _ b: T) -> T {
    return a > b ? a : b // не скомпилируется: у T нет оператора >
}

Оператор > определён не для всех типов подряд: например, для двух замыканий или для двух опциональных значений сравнение «что больше» не имеет смысла в принципе, поэтому компилятор не разрешает его использовать для произвольного T. Чтобы это исправить, нужно ограничение (constraint) — явно сказать, что T обязан соответствовать протоколу, где такой оператор есть, например Comparable:

func bigger<T: Comparable>(_ a: T, _ b: T) -> T {
    return a > b ? a : b
}

print(bigger(3, 7))
print(bigger("груша", "апельсин"))

Запись T: Comparable читается как «T — любой тип, но обязательно соответствующий протоколу Comparable». Теперь функция работает и с числами, и со строками (оба типа реализуют Comparable), но откажется компилироваться, если её попытаются вызвать для типа, который сравнивать нельзя, — ошибка находится ещё до запуска программы, а не где-то в середине работы приложения.

Обобщённые типы

Параметр типа можно указать не только у функции, но и у целого struct, class или enum. Устроено это ровно так же, как у знакомого вам Array в документации Apple: Array сам по себе обобщённый тип, а [Int] — лишь короткая запись для Array<Int>.

struct Box<Content> {
    var content: Content
}

let intBox = Box(content: 42)
let stringBox = Box(content: "текст")

Content здесь играет ту же роль, что T в функции: конкретный тип подставляется при создании значения, и компилятор сам выводит его по аргументу, переданному в content. Один struct Box заменяет отдельные IntBox, StringBox и любые другие, которые пришлось бы писать без дженериков. Важно: Box<Int> и Box<String> — это разные, несовместимые типы, просто оба порождены одним и тем же обобщённым описанием; передать Box<String> туда, где ожидается Box<Int>, нельзя.

where: ограничение в конкретном месте

Ограничение можно добавить не всему типу сразу, а только отдельному расширению или методу — именно там, где оно действительно нужно. Для этого используется where:

extension Box where Content: Comparable {
    func isGreater(than other: Box<Content>) -> Bool {
        return content > other.content
    }
}

let a = Box(content: 5)
let b = Box(content: 9)
print(a.isGreater(than: b))

Метод isGreater(than:) появляется только у тех Box, чей Content соответствует Comparable. У Box<Int> он есть, потому что Int сравним. А если бы кто-то завёл Box<(Int) -> Int> — хранящий функцию, — у такого Box метода isGreater(than:) просто не существовало бы: функции не соответствуют Comparable. Сам тип Box при этом остаётся полностью универсальным — хранить в нём можно значение любого типа, а сравнение доступно только там, где оно осмысленно.

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

  • Пытаться сравнивать, складывать или перебирать значения типа T без ограничения — компилятор откажет с сообщением о том, что у типа нет нужного оператора или метода. Решение — добавить подходящее ограничение, например T: Comparable или T: Equatable.
  • Путать параметр типа обобщённой функции с типом Any. Any означает «любой тип, и он может быть разным при каждом вызове, и даже в одном массиве», а параметр типа вроде T означает «какой-то один конкретный тип, и он фиксирован для всех параметров с этой буквой в одном вызове».
  • Ожидать, что Box<Int> можно передать туда, где объявлен параметр типа Box<String>, — это два разных типа, хоть и порождены одним и тем же обобщённым struct.
  • Забывать, что метод из extension с where доступен не всегда: он появляется только тогда, когда конкретный тип, подставленный вместо параметра, удовлетворяет условию после where.

Резюме

  • Дженерики позволяют написать функцию или тип один раз, с параметром типа вместо конкретного типа, вместо копирования одинаковой логики под каждый тип отдельно.
  • Параметр типа объявляется в угловых скобках: func f<T>(...) или struct S<T> { ... }.
  • Ограничение (T: Comparable, T: Equatable) сообщает компилятору, какими операциями и свойствами обязан обладать любой тип, который подставят вместо параметра.
  • Обобщённый тип с разными параметрами (Box<Int> и Box<String>) — это разные, несовместимые между собой типы.
  • where добавляет ограничение только к одному расширению или методу, не затрагивая остальной обобщённый тип.

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

4 вопроса

Медиана трёх значений

Реализуйте обобщённую функцию middleValue(_:_:_:), которая принимает три значения одного типа и возвращает то из них, что окажется посередине, если все три расставить по возрастанию (медиану). Функция должна работать с любым типом, для которого определено сравнение, — числами, строками и любыми другими типами, соответствующими Comparable.

Например, для чисел 1, 5, 3 посередине при сортировке окажется 3. Если среди трёх значений есть равные, любое из них, стоящее в отсортированном порядке посередине, — верный ответ.

Обобщённый стек

Реализуйте обобщённую структуру Stack<Element> — стек, работающий с элементами любого типа по принципу «последним пришёл — первым вышел». У структуры уже объявлено хранимое свойство items — трогать его объявление не нужно, только использовать внутри методов. Допишите:

  • mutating func push(_ element: Element) — добавляет элемент на вершину стека.
  • mutating func pop() -> Element? — снимает и возвращает элемент с вершины стека; для пустого стека возвращает nil.
  • var isEmpty: Bool — вычисляемое свойство, true, если элементов нет.
  • var count: Int — вычисляемое свойство, количество элементов в стеке.

Кроме того, в заготовке уже есть расширение extension Stack where Element: Comparable — оно доступно только тогда, когда элементы стека можно сравнивать между собой. Допишите в нём метод func maxElement() -> Element?, который возвращает наибольший из элементов, лежащих в стеке в данный момент (не удаляя их), а для пустого стека — nil.