Вступление
Привет, Хабр! В прошлых статьях мы говорили про ARC и управление памятью в Swift, но обошли стороной ещё одну сильную сторону языка. Речь идёт о generic'ах и протоколах - именно они делают Swift таким чистым и мощным. В этой статье мы разберёмся, как они работают и почему без них невозможно представить современный Swift.
Поехали!
Generics
Начать стоит с того, что такое дженерики и для чего они нужны. Generic'и - это разновидность полиморфизма, а именно так называемый parametric polymorphism. Они позволяют писать код более переиспользуемым, что дает гибкость при разработке различных фичей.
Пример generic функции:
func swapValues<T>(_ a: inout T, _ b: inout T) {
let temp = a
a = b
b = temp
}
var x = 10
var y = 20
swapValues(&x, &y)
print(x, y) // 20 10
var s1 = "Hello"
var s2 = "World"
swapValues(&s1, &s2)
print(s1, s2) // World Hello
Здесь мы написали функцию, которая меняет местами значения в двух переменных.
Но главное - нам не пришлось делать отдельные версии для String
, Int
и других типов. Мы написали одну функцию, работающую с любым типом. В этом и заключается основная сила generic'ов.
Однако у generic'ов есть и свои особенности. Рассмотрим пример:
// 1
func printValue<T>(_ value: T) {
print(String(describing: value))
}
// 2
func printValue(_ value: Int) {
print("+\(value)+")
}
printValue(3)
Вопрос: Что будет выведено?
Ответ: будет вызвана вторая функция, и напечатается +3+
А теперь другой пример:
// 1
func printValue<T>(_ value: T) {
print(String(describing: value))
}
// 2
func printValue(_ value: Int) {
print("+\(value)+")
}
func process<A>(_ value: A) {
printValue(value)
}
process(3)
Здесь вызовется первая реализация, и будет напечатано 3.
Почему так происходит? Давайте разберёмся.
Когда мы используем generic функции, Swift во время компиляции генерирует конкретные реализации для конкретных типов. То есть, если мы вызываем generic функцию с Int
, компилятор создаёт специализированную версию под Int
.
При перегрузке методов Swift всегда старается выбрать наиболее специфичную реализацию. В первом примере тип аргумента известен на этапе компиляции как Int, поэтому компилятор может однозначно выбрать перегрузку printValue(_ value: Int)
- она более конкретная, чем универсальная версия.
Во втором примере вызов printValue
происходит внутри обобщённого контекста функции process<A>
. На этом уровне компилятор оперирует только параметром типа A
, без конкретного знания, что именно будет подставлено. Из-за этого перегрузка printValue(_ value: Int)
не рассматривается как подходящая, и Swift выбирает универсальную реализацию printValue<T>
. Таким образом, даже если во время выполнения туда передаётся Int
, на этапе компиляции доступна только информация об абстрактном типе A
, и вызывается версия, сгенерированная из дженерика.
Окей, мы посмотрели на то, как выглядят generic'и, но в примере выше они выглядят как-то беcпомощно, так как мы не можем с чистыми generic'ами проводить какие либо полезные операции, кроме копирования и перекладывания их с места на место.
Тут в игру вступают ограничения.
Ограничения generic'ов
В Swift есть очень мощный инструмент при работе с обобщениями - ограничения. Не будем далеко ходить, возьмем пример:
func sum<T>(_ op1: T, _ op2: T) -> T {
return op1 + op2 // Ошибка: Binary operator '+' cannot be applied to two 'T' operands
}
Как его оживить? Давайте добавим ограничение Numeric
для T
:
func sum<T: Numeric>(_ op1: T, _ op2: T) -> T {
return op1 + op2
}
print(sum(1, 2)) // 3
print(sum(1.2, 2.1)) // 3.3
Вуа-ля - у нас рабочая функция сложения любых чисел. Правда, этот пример довольно искусственный, потому что оператор +
в стандартной библиотеке уже неплохо справляется со сложением. Возьмем более реальный пример:
Предположим, у нас есть сервис, который сохраняет и получает данные по некоторому id
:
protocol Identifiable {
var id: Int { get }
}
struct User: Identifiable {
let id: Int
let name: String
}
final class DataService<T: Identifiable> {
private var storage: [Int: T] = [:]
func save(_ entity: T) {
storage[entity.id] = entity
}
func get(by id: Int) -> T? {
storage[id]
}
func getAll() -> [T] {
Array(storage.values)
}
}
let userService = DataService<User>()
userService.save(User(id: 1, name: "Sasha"))
userService.save(User(id: 2, name: "Masha"))
print(userService.getAll().map(\.id)) // [1, 2]
Если убрать ограничение Identifiable
, компилятор выдаст ошибку - T
не гарантирует наличие id
, и сохранение в storage[entity.id]
невозможно.
Помимо одного протокола, generic'и можно ограничивать и несколькими протоколами - с помощью оператора &
. Пример:
protocol Printable {
var description: String { get }
}
protocol Identifiable {
var id: Int { get }
}
struct User: Identifiable, Printable {
let id: Int
let name: String
let description: String
}
final class DataService<T: Identifiable & Printable> {
private var storage: [Int: T] = [:]
func save(_ entity: T) {
storage[entity.id] = entity
}
func get(by id: Int) -> T? {
storage[id]
}
func printStorage() -> [String] {
return storage.values.map(\.description)
}
}
let userService = DataService<User>()
userService.save(User(id: 1, name: "Sasha", description: "Boy"))
userService.save(User(id: 2, name: "Masha", description: "Girl"))
print(userService.printStorage()) // ["Boy", "Girl"]
Итак, мы посмотрели на generic'и и их ограничения. Теперь углубимся и разберёмся, как Swift реализует их под капотом. Для этого важно понять два ключевых механизма: Value Witness Table (VWT) и Protocol Witness Table (PWT).
Value Witness Table и Protocol Witness Table
У обобщённого типа всегда есть Value Witness Table (VWT). Это таблица низкоуровневых операций с типом, которая описывает:
как выделять память под объект (
allocate
),как его копировать (
copy
),как уничтожать (
destroy
)
Когда же мы добавляем к generic'у ограничение, компилятор подключает ещё и Protocol Witness Table (PWT). Эта таблица содержит конкретные реализации требований протокола для данного типа. Причём при каждом новом ограничении добавляется отдельная PWT.
Например, если у нас T: Protocol1 & Protocol2
, то во время выполнения у типа T
будут:
одна VWT
PWT для
Protocol1
PWT для
Protocol2
Итак, мы разобрали практическую часть работы с generic'ами, но есть еще и более концептуальная часть, о которой мы поговорим далее.
50 оттенков generic'ов
Я думаю те, кто учился в институте на технической специальности или имеют опыт в разработке, хотя бы раз слышали такие понятия как: ковариантность, контрвариантность и инвариантность. Звучит страшно, но на самом деле тут ничего трудного нет. Давайте разбираться.
Ковариантность
Ковариантность позволяет обобщённому типу сохранять иерархию наследования между типами. Это означает, что если B
является подклассом A
, то обобщённый тип Container<B>
будет считаться подклассом Container<A>
. Пример:
class Animal {}
class Dog: Animal {}
let animals: [Animal] = [Dog()] // Это ковариантность,
// так как Array<Dog> — считается подклассом Array<Animal>
Важно помнить, что в Swift только generic'и коллекций ковариантны, но это не относится к пользовательским generic типам
Контрвариантность
Контрвариантность позволяет использовать более общий тип вместо специфического. Это часто встречается в функциях или замыканиях, где принимаемый параметр может быть более общим.
class Animal {}
class Dog: Animal {}
func processAnimal(_ animal: Animal) {
print("Processing an animal")
}
let processDog: (Dog) -> Void = processAnimal
Почему это работает? Представьте, что мы сделали наоборот:
class Animal {}
class Dog: Animal {}
func processDog(_ animal: Dog) {
print("Processing an animal")
}
let processAnimal: (Animal) -> Void = processAnimal
Здесь будет ошибка компиляции, но если бы ее не было и мы бы вызвали processAnimal
с каким-нибудь дочерним к Animal
типом Cat
, то в таком случае у нас был бы конфликт, так как processDog
принимает только Dog
, а мы передаем Cat
.
Инвариантность
Инвариантность означает, что обобщённые типы не могут быть взаимозаменяемемыми даже если существует отношение наследования между типами. Это поведение по умолчанию для обобщённых типов в Swift. Пример:
class Animal {}
class Dog: Animal {}
struct Container<T> {}
let dogContainer = Container<Dog>()
// Ошибка: Cannot assign value of type 'Container<Dog>' to type 'Container<Animal>'
// let animalContainer: Container<Animal> = dogContainer
Phantom types
Бонусом к разделу о generic'ах я расскажу не о самой популярной, но довольно интересной технике под названием phantom types.
Фантомные типы - это такие generic'и, где хотя бы один обобщенный тип никак не используется в самом объекте. На первый взгляд это может показаться странным, но такая конструкция позволяет добавить дополнительную типобезопасность на этапе компиляции.
В примере ниже Unit - фантомный параметр, который никак не участвует в хранении данных, но задаёт «единицу измерения» для дистанции. Благодаря этому Swift не даст сложить метры и километры напрямую - придётся вначале конвертировать их в одни единицы измерения.
enum Meter {}
enum Kilometer {}
struct Distance<Unit> {
let value: Double
}
extension Distance {
static func + (lhs: Distance<Unit>, rhs: Distance<Unit>) -> Distance<Unit> {
return Distance<Unit>(value: lhs.value + rhs.value)
}
}
extension Distance where Unit == Meter {
func toKilometers() -> Distance<Kilometer> {
return Distance<Kilometer>(value: value / 1000.0)
}
}
extension Distance where Unit == Kilometer {
func toMeters() -> Distance<Meter> {
return Distance<Meter>(value: value * 1000.0)
}
}
let d1 = Distance<Meter>(value: 500)
let d2 = Distance<Kilometer>(value: 2)
// Ошибка: Cannot convert value of type 'Distance<Kilometer>' to expected argument type 'Distance<Meter>'
// let wrongSum = d1 + d2
let converted = d1.toKilometers()
print(converted.value) // 0.5
let sum = converted + d2
print(sum) // Distance<Kilometer>(value: 2.5)
Протоколы
Протоколы в Swift можно рассматривать как реализацию интерфейсов (как в Java, например).
Однако в Swift они очень мощные: помимо своего ключевого назначения - создания абстракций, протоколы также поддерживают наследование, расширения и даже работают как разновидность generic'ов.
Но обо всём по порядку.
Классический протокол в Swift выглядит так:
protocol SomeProtocol {
func foo()
func bar()
var prop: Int { get set }
}
Его могут реализовать как классы, так и структуры:
protocol SomeProtocol {
func foo()
func bar()
var prop: Int { get set }
}
class SomeClass: SomeProtocol {
var prop: Int = 0
func foo() {
// do smth
}
func bar() {
// do smth else
}
}
Да, протоколы в Swift поддерживают и свойства. Их можно объявлять как
get set
, так и простоget
, тогда их можно будет использовать как константы.
Расширения протоколов
Реализации методов и свойств протоколов в Swift обязательные, но что, если мы хотим, чтобы реализация метода была по умолчанию и не требовала явной имплементации в каждом классе?
Для этого используются расширения протоколов:
protocol SomeProtocol {
func foo()
func bar()
}
extension SomeProtocol {
func bar() {
print("bar")
}
}
class SomeClass: SomeProtocol {
func foo() {
print("foo")
}
}
let a = SomeClass()
a.foo() // foo
a.bar() // bar
На этом принципе работают многие базовые протоколы Swift, например
Collection
иSequence
.
Подводный камень
У расширений протоколов есть нюанс, связанный с диспетчеризацией методов:
protocol SomeProtocol {
func foo()
}
extension SomeProtocol {
func bar() {
print("a")
}
}
class SomeClass: SomeProtocol {
func foo() {
print("c")
}
func bar() {
print("b")
}
}
let a: SomeProtocol = SomeClass()
a.foo()
a.bar()
Вывод:
c
a
Почему так?
Методы, объявленные в самом классе, вызываются динамически, а методы, добавленные в extension
, вызываются статически, и Swift выбирает наиболее оптимальную, статическую диспетчеризацию.
Подробнее об этом мы поговорим в отдельной статье про диспетчеризацию методов в Swift.
Наследование протоколов
Если мы не можем изменить существующий протокол, но хотим расширить его функциональность, можно создать новый протокол и унаследовать его от существующего:
protocol SomeProtocol {
func foo()
}
protocol OtherProtocol: SomeProtocol {
func bar()
}
class SomeClass: OtherProtocol {
func foo() {
print("foo")
}
func bar() {
print("bar")
}
}
let a = SomeClass()
a.foo() // foo
a.bar() // bar
В Swift множественное наследование доступно только для протоколов.
Протокол может наследоваться сразу от нескольких других протоколов. Это позволяет комбинировать разные наборы требований в одном классе или структуре.
Класс или структура могут одновременно реализовывать несколько протоколов, но при этом у класса может быть только один базовый класс (множественное наследование классов не поддерживается).
class A { }
class B { }
class C: A, B { } // Ошибка: Multiple inheritance from classes 'A' and 'B'
А вот с протоколами множественное наследование работает:
protocol A { }
protocol B { }
protocol D: A, B { } // все ок
class C: A, B { } // всё ок
Но что, если мы хотим сделать их такими же гибкими как generic'и? Тут нам на помощь приходят assosiated types.
Associated types
associatedtype
- это механизм, который делает протоколы в Swift похожими на generic'и (по сути, но не по реализации).
Он позволяет создавать переиспользуемые протоколы, которые не зависят от конкретного типа данных.
Простейший пример:
protocol Copyble {
associatedtype T
func copy() -> T
}
class SomeClass: Copyble {
init() { }
func copy() -> SomeClass {
return SomeClass()
}
}
let a = SomeClass()
let b = a.copy()
Здесь associatedtype
выступает в роли параметра типа, и вместо T
можно подставить SomeClass
.
associatedtype
может иметь ограничения. Например, сделаем протокол, который описывает сущности, у которых есть id и по этому id их можно сравнить:
protocol Identifiable {
associatedtype Identifier: Equatable
var id: Identifier { get }
}
struct User: Identifiable {
let id: Int
let name: String
}
struct Book: Identifiable {
let id: String
let title: String
}
final class Comparator {
static func compareIDs<T: Identifiable>(_ lhs: T, _ rhs: T) -> Bool {
return lhs.id == rhs.id
}
}
let user1 = User(id: 1, name: "Sasha")
let user2 = User(id: 2, name: "Masha")
let book1 = Book(id: "Book1", title: "Swift")
let book2 = Book(id: "Book1", title: "Generics")
print(Comparator.compareIDs(user1, user2)) // false
print(Comparator.compareIDs(book1, book2)) // true
associatedtype
также поддерживает рекурсию. Например, можно описать древовидную структуру:
protocol TreeNode {
associatedtype Child: TreeNode
var value: String { get }
var children: [Child] { get set }
}
class Node: TreeNode {
var value: String
var children: [Node] = []
init(value: String) {
self.value = value
}
}
let root = Node(value: "root")
let child1 = Node(value: "child1")
let child2 = Node(value: "child2")
root.children = [child1, child2]
print(root.value) // "root"
print(root.children.map(\.value)) // ["child1", "child2"]
Здесь TreeNode
использует сам себя в children
, что позволяет строить иерархию.
Бонус: Primary associated types
Primary associated types
- это синтаксический сахар, упрощающий объявление протоколов с ассоциированными типами, который был добавлен в Swift 5.7. Раньше, при работе с associated types
компилятор сам выводил тип Box
из контекста:
protocol Box {
associatedtype Item
var value: Item { get set }
}
struct IntBox: Box {
var value: Int
}
func makeBoxOld() -> some Box {
IntBox(value: 10)
}
Теперь же можно его указать явно:
protocol Box<Item> {
associatedtype Item
var value: Item { get set }
}
struct IntBox: Box {
var value: Int
}
func makeBoxNew() -> some Box<Int> {
IntBox(value: 10)
}
Окей, с associated type мы разобрались, но что если нам хочется в массиве держать несколько разных типов? В Swift при работе с чистыми структурами и классами такое невозможно, но с протоколами это становится возможным при помощи existential containers, о них далее.
Existential containers
Когда объект накрывается протоколом (any Protocol
), компилятору нужно хранить его так, чтобы:
все значения имели одинаковый размер в памяти,
можно было вызывать методы протокола, даже если конкретный тип неизвестен.
Проблема в том, что разные типы могут иметь разный размер. Если бы Swift пытался хранить их напрямую, массив из протокольных значений был бы невозможен.
Для решения этой задачи используется экзистенциальный контейнер, который в 64-битной системе занимает фиксированные 5 машинных слов (5 × 64 = 320 бит).
Экзистенциальный контейнер состоит из:
value buffer - область для хранения объекта;
VWT (Value Witness Table) - таблица базовых операций (копирование, уничтожение, выделение памяти);
PWT (Protocol Witness Table) - таблица реализаций методов протокола.
value buffer занимает 3 машинных слова. Если объект маленький, он помещается прямо в контейнер. Если больше - в контейнер кладётся указатель, а сам объект живёт в куче.
protocol Shape {
func area() -> Double
}
struct Circle: Shape {
let radius: Double
func area() -> Double { .pi * radius * radius }
}
struct Rectangle: Shape {
let width: Double
let height: Double
func area() -> Double { width * height }
}
let shapes: [any Shape] = [
Circle(radius: 2),
Rectangle(width: 3, height: 4)
]
for shape in shapes {
print(shape.area())
}
// 12.566370614359172
// 12.0
Здесь shapes
- это массив экзистенциальных контейнеров.
В нём лежат значения разных размеров (Circle
и Rectangle
), но благодаря контейнеру они могут храниться вместе и вызываться через интерфейс протокола Shape
.
Opaque types
Итак, мы поговорили об экзистенциальных контейнерах и о том, как они позволяют абстрагироваться от конкретного типа. Но у этого подхода есть недостаток: сам реальный тип внутри контейнера затирается.
Opaque types
решают эту проблему. Они позволяют скрыть конкретный тип от внешнего кода, но при этом сам компилятор знает, какой именно тип лежит внутри. Это значит, что пользователи работают только через интерфейс протокола, а Swift под капотом применяет оптимизации, опираясь на конкретный тип.
Пример:
protocol Animal {
func makeSound()
}
struct Dog: Animal {
let name = "Rex"
func makeSound() {
print("Woof!")
}
}
struct Cat: Animal {
func makeSound() {
print("Meow!")
}
}
func getFavoriteDog() -> Dog {
Dog()
}
func favoriteAnimal() -> some Animal {
getFavoriteDog()
}
let pet = favoriteAnimal() // some Animal
print(type(of: pet)) // Dog
pet.makeSound() // "Woof!"
// Ошибка: Value of type 'some Animal' has no member 'name'
// pet.name
В примере видно, что переменная pet фактически является Dog, однако доступ к её конкретным свойствам невозможен, так как внешнему коду известен только интерфейс протокола Animal. Такой подход позволяет одновременно сохранить инкапсуляцию и дать компилятору работать с конкретным типом.
Но важно помнить, что у opaque types есть и ограничения: например, нельзя хранить в массиве разные типы, если используется ключевое слово some.
// Ошибка: Conflicting arguments to generic parameter 'τ_0_0' ('Dog' vs. 'Cat')
let animals: [some Animal] = [
Dog(),
Cat()
]
Заключение
В этой статье мы разобрались, что такое generic'и и протоколы, и какую гибкость они дают в Swift. Generic'и обеспечивают переиспользуемость, протоколы - абстракцию и расширяемость, а opaque types и existential containers позволяют решать, скрывать ли детали конкретного типа или сохранять их.
В следующей статье поговорим о диспетчеризации методов в Swift - почему одни вызываются статически, другие динамически, и к чему это иногда приводит.