Почати варто з того, що таке дженерики та для чого вони потрібні. Generic'и - це різновид поліморфізму, а саме так званий параметричний поліморфізм. Вони дозволяють писати код більш перевикористовуваним, що дає гнучкість при розробці різних фіч.
Приклад generic функції:
Тут ми написали функцію, яка міняє місцями значення в двох змінних.
Але головне - нам не довелося робити окремі версії для String
, Int
та інших типів. Ми написали одну функцію, що працює з будь-яким типом. У цьому й полягає основна сила generic'ів.
Однак у generic'ів є й свої особливості. Розглянемо приклад:
Питання: Що буде виведено?
Відповідь: буде викликана друга функція, і надрукується +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 є дуже потужний інструмент при роботі з узагальненнями - обмеження. Не будемо далеко ходити, візьмемо приклад:
Як його оживити? Давайте додамо обмеження Numeric
для T
:
Вуа-ля - у нас робоча функція додавання будь-яких чисел. Правда, цей приклад досить штучний, тому що оператор +
у стандартній бібліотеці вже непогано справляється з додаванням. Візьмемо більш реальний приклад:
Припустимо, у нас є сервіс, який зберігає та отримує дані по якомусь id
:
Якщо прибрати обмеження Identifiable
, компілятор видасть помилку - T
не гарантує наявність id
, і збереження в storage[entity.id]
неможливе.
Окрім одного протоколу, generic'и можна обмежувати й кількома протоколами - за допомогою оператора &
. Приклад:
Отже, ми подивилися на 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>
. Приклад:
Важливо пам'ятати, що в Swift тільки generic'и колекцій коваріантні, але це не стосується користувацьких generic типів
Контрваріантність
Контрваріантність дозволяє використовувати більш загальний тип замість специфічного. Це часто зустрічається в функціях або замиканнях, де прийманий параметр може бути більш загальним.
Чому це працює? Уявіть, що ми зробили навпаки:
Тут буде помилка компіляції, але якби її не було і ми б викликали processAnimal
з якимось дочірнім до Animal
типом Cat
, то в такому випадку у нас був би конфлікт, оскільки processDog
приймає тільки Dog
, а ми передаємо Cat
.
Інваріантність
Інваріантність означає, що узагальнені типи не можуть бути взаємозамінними навіть якщо існує відношення успадкування між типами. Це поведінка за замовчуванням для узагальнених типів у Swift. Приклад:
Phantom types
Бонусом до розділу про generic'и я розповім не про найпопулярнішу, але досить цікаву техніку під назвою phantom types.
Фантомні типи - це такі generic'и, де хоча б один узагальнений тип ніяк не використовується в самому об'єкті. На перший погляд це може здатися дивним, але така конструкція дозволяє додати додаткову типобезпечність на етапі компіляції.
У прикладі нижче Unit - фантомний параметр, який ніяк не бере участь у зберіганні даних, але задає «одиницю вимірювання» для дистанції. Завдяки цьому Swift не дасть скласти метри та кілометри напряму - доведеться спочатку конвертувати їх в одні одиниці вимірювання.
Протоколи
Протоколи в Swift можна розглядати як реалізацію інтерфейсів (як у Java, наприклад).
Однак у Swift вони дуже потужні: окрім свого ключового призначення - створення абстракцій, протоколи також підтримують успадкування, розширення і навіть працюють як різновид generic'ів.
Але про все по порядку.
Класичний протокол у Swift виглядає так:
Його можуть реалізувати як класи, так і структури:
Так, протоколи в Swift підтримують і властивості. Їх можна оголошувати якget set
, так і простоget
, тоді їх можна буде використовувати як константи.
Розширення протоколів
Реалізації методів і властивостей протоколів у Swift обов'язкові, але що, якщо ми хочемо, щоб реалізація методу була за замовчуванням і не вимагала явної імплементації в кожному класі?
Для цього використовуються розширення протоколів:
На цьому принципі працюють багато базових протоколів Swift, наприкладCollection
іSequence
.
Підводний камінь
У розширень протоколів є нюанс, пов'язаний з диспетчеризацією методів:
Висновок:
Чому так?
Методи, оголошені в самому класі, викликаються динамічно, а методи, додані в extension
, викликаються статично, і Swift вибирає найбільш оптимальну, статичну диспетчеризацію.
Детальніше про це ми поговоримо в окремій статті про диспетчеризацію методів у Swift.
Успадкування протоколів
Якщо ми не можемо змінити існуючий протокол, але хочемо розширити його функціональність, можна створити новий протокол і успадкувати його від існуючого:
У Swift множинне успадкування доступне тільки для протоколів.
Протокол може успадковуватися відразу від кількох інших протоколів. Це дозволяє комбінувати різні набори вимог в одному класі або структурі.
Клас або структура можуть одночасно реалізовувати кілька протоколів, але при цьому у класу може бути тільки один базовий клас (множинне успадкування класів не підтримується).
А ось з протоколами множинне успадкування працює:
Але що, якщо ми хочемо зробити їх такими ж гнучкими як generic'и? Тут нам на допомогу приходять assosiated types.
Associated types
associatedtype
- це механізм, який робить протоколи в Swift схожими на generic'и (по суті, але не по реалізації).
Він дозволяє створювати перевикористовувані протоколи, які не залежать від конкретного типу даних.
Найпростіший приклад:
Тут associatedtype
виступає в ролі параметра типу, і замість T
можна підставити SomeClass
.
associatedtype
може мати обмеження. Наприклад, зробимо протокол, який описує сутності, у яких є id і по цьому id їх можна порівняти:
associatedtype
також підтримує рекурсію. Наприклад, можна описати древовидну структуру:
Тут TreeNode
використовує сам себе в children
, що дозволяє будувати ієрархію.
Бонус: Primary associated types
Primary associated types
- це синтаксичний цукор, що спрощує оголошення протоколів з асоційованими типами, який був доданий у Swift 5.7. Раніше, при роботі з associated types
компілятор сам виводив тип Box
з контексту:
Тепер же можна його вказати явно:
Окей, з 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 машинних слів. Якщо об'єкт маленький, він поміщається прямо в контейнер. Якщо більше - в контейнер кладеться вказівник, а сам об'єкт живе в купі.
Тут shapes
- це масив екзистенційних контейнерів.
У ньому лежать значення різних розмірів (Circle
і Rectangle
), але завдяки контейнеру вони можуть зберігатися разом і викликатися через інтерфейс протоколу Shape
.
Opaque types
Отже, ми поговорили про екзистенційні контейнери та про те, як вони дозволяють абстрагуватися від конкретного типу. Але у цього підходу є недолік: сам реальний тип всередині контейнера затирається.
Opaque types
вирішують цю проблему. Вони дозволяють приховати конкретний тип від зовнішнього коду, але при цьому сам компілятор знає, який саме тип лежить всередині. Це означає, що користувачі працюють тільки через інтерфейс протоколу, а Swift під капотом застосовує оптимізації, спираючись на конкретний тип.
Приклад:
У прикладі видно, що змінна pet фактично є Dog, однак доступ до її конкретних властивостей неможливий, оскільки зовнішньому коду відомий тільки інтерфейс протоколу Animal. Такий підхід дозволяє одночасно зберегти інкапсуляцію і дати компілятору працювати з конкретним типом.
Але важливо пам'ятати, що у opaque types є й обмеження: наприклад, не можна зберігати в масиві різні типи, якщо використовується ключове слово some.
Висновок
У цій статті ми розібралися, що таке generic'и та протоколи, і яку гнучкість вони дають у Swift. Generic'и забезпечують перевикористовуваність, протоколи - абстракцію та розширюваність, а opaque types та existential containers дозволяють вирішувати, приховувати чи деталі конкретного типу чи зберігати їх.
У наступній статті поговоримо про диспетчеризацію методів у Swift - чому одні викликаються статично, інші динамічно, і до чого це іноді призводить.
Коментарі