Меню

Узагальнення та протоколи в Swift

Узагальнення та протоколи в Swift

Почати варто з того, що таке дженерики та для чого вони потрібні. Generic'и - це різновид поліморфізму, а саме так званий параметричний поліморфізм. Вони дозволяють писати код більш перевикористовуваним, що дає гнучкість при розробці різних фіч.

Приклад 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'и, але в прикладі вище вони виглядають якось безпорадно, оскільки ми не можемо з чистими 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). Це таблиця низькорівневих операцій з типом, яка описує:

  1. як виділяти пам'ять під об'єкт (allocate),
  2. як його копіювати (copy),
  3. як знищувати (destroy)

Коли ж ми додаємо до generic'у обмеження, компілятор підключає ще й Protocol Witness Table (PWT). Ця таблиця містить конкретні реалізації вимог протоколу для даного типу. Причому при кожному новому обмеженні додається окрема PWT.

Наприклад, якщо у нас T: Protocol1 & Protocol2, то під час виконання у типу T будуть:

  1. одна VWT
  2. PWT для Protocol1
  3. 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), компілятору потрібно зберігати його так, щоб:

  1. всі значення мали однаковий розмір у пам'яті,
  2. можно було викликати методи протоколу, навіть якщо конкретний тип невідомий.

Проблема в тому, що різні типи можуть мати різний розмір. Якби Swift намагався зберігати їх напряму, масив з протоколних значень був би неможливий.

Для вирішення цієї задачі використовується екзистенційний контейнер, який у 64-бітній системі займає фіксовані 5 машинних слів (5 × 64 = 320 біт).

Екзистенційний контейнер складається з:

  1. value buffer - область для зберігання об'єкта;
  2. VWT (Value Witness Table) - таблиця базових операцій (копіювання, знищення, виділення пам'яті);
  3. 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 - чому одні викликаються статично, інші динамічно, і до чого це іноді призводить.

Посилання

  1. Swift ABI
  2. Existentials
  3. Доповідь про дженерики від розробника Apple

Коментарі