Зачем нужен один код для разных типов
Представьте, что вам нужна функция, которая находит из трёх чисел среднее по значению. Завтра та же логика понадобится для трёх строк — сравнить их по алфавиту, а послезавтра для трёх дат. Переписывать одну и ту же логику под каждый новый тип — верный способ развести дублирование: если в логике найдётся ошибка, чинить её придётся сразу в нескольких местах, и рано или поздно одну из копий забудут поправить.
Вы уже пользовались готовым решением этой проблемы, даже не задумываясь об этом: 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.