1. Навіщо взагалі обирати між generics та any P
Якщо чесно, більшість новачків спочатку нічого не обирає — вони просто пишуть те, що «компілюється». Це нормальний етап. Проблема починається пізніше: код компілюється, але або стає незручним, бо всюди занадто багато T, або раптом «не дає викликати метод» через any P. Ця лекція — про практику: як заздалегідь визначити, який інструмент доречніший, щоб не перетворювати проєкт на музей компромісів.
У цій темі корисно памʼятати, що Swift дає нам два різні види абстракції: абстракцію «за типами» (generics) і абстракцію «за значеннями» (existentials, тобто any P). На вигляд вони схожі, але розвʼязують різні задачі й дають різні гарантії.
Дві моделі абстракції: «за типами» і «за значеннями»
Коли ви пишете код із generics, ви кажете компілятору: «Тип поки невідомий, але там, де я його використовую, він буде конкретним і однаковим». Це схоже на «шаблон деталі»: вирізаємо за одним трафаретом, і всі отвори збігаються.
Коли ви пишете any P, ви кажете: «У мене є коробка, всередині якої лежить щось, що задовольняє протоколу P. Я не знаю, що саме, і це знання мені не потрібне». Це схоже на «контейнер для будь-якого предмета певної форми»: коробка гарантує мінімальні властивості (протокол), але приховує конкретику. У Swift Evolution це прямо описують як абстракцію на рівні значення: екзистенціал зберігає значення невідомого конкретного типу, «запаковане» під протокол.
Давайте дуже коротко зафіксуємо різницю в невеликій таблиці:
| Інструмент | Що ви «втрачаєте» | Що ви «отримуєте» | Типове запитання, на яке відповідає |
|---|---|---|---|
| Generics (T: P) | Нічого не втрачаєте: тип лишається конкретним | Звʼязки типів і максимальну типобезпечність | «Хочу, щоб вхід і вихід були узгоджені за типом» |
| Existential (any P) | Втрачаєте конкретний базовий тип на рівні типу змінної | Можливість зберігати «різні реалізації поруч» | «Хочу зберігати різні реалізації в одному місці й викликати спільний API» |
Важливо: any P — не «спрощений generic». Це інший інструмент з іншою семантикою.
2. Золоте правило №1: якщо важливі звʼязки типів — обирайте generics
Коли ви проєктуєте API, найсильніший сигнал на користь generics — це ситуація, де вам потрібно виразити звʼязок між типами в різних місцях: параметр ↔ повертане значення, два параметри ↔ один і той самий тип, контейнер ↔ тип елемента тощо.
Звучить абстрактно — приземлимо це на простому прикладі. Найпростіший маркер «звʼязку типів» — коли один і той самий параметр типу T у сигнатурі з’являється більше одного разу.
Приклад 1: «обидва аргументи — одного типу»
func areSame<T: Equatable>(_ a: T, _ b: T) -> Bool {
return a == b
}
print(areSame(10, 10)) // true
// print(areSame(10, "10")) // ❌ не компілюється: різні типи
Тут сенс T не в тому, що «будь-який тип». Сенс у тому, що обидва значення мають бути одного конкретного типу, і цей тип має бути Equatable.
Якби ви спробували зробити це через any Equatable, ви б втратили цей звʼязок. У вас було б два «якихось Equatable», але компілятор не зобовʼязаний вважати, що в них один і той самий базовий тип.
Приклад 2: «вхід і вихід повʼязані»
func duplicate<T>(_ value: T) -> (T, T) {
return (value, value)
}
let pair = duplicate("Hi")
print(pair.0) // Hi
print(pair.1) // Hi
Знову ж таки: ми не просто «приймаємо що завгодно». Ми гарантуємо, що повертаємо рівно той самий тип T.
Приклад 3: «репозиторій і його Item повʼязані»
Уявімо наш навчальний CLI-проєкт бібліотеки — умовний LibraryCLI, у якому є книги:
import Foundation
struct Book: CustomStringConvertible {
let title: String
var description: String { "📖 \(title)" }
}
І є протокол репозиторію (як у попередніх лекціях про associatedtype):
protocol Repository {
associatedtype Item
func add(_ item: Item)
}
Якщо ми хочемо написати функцію, яка додає елемент двічі, нам потрібен звʼязок repo ↔ Item. Це буквально звʼязок типів:
func addTwice<R: Repository>(_ item: R.Item, to repo: R) {
repo.add(item)
repo.add(item)
}
Це прямий сигнал: без generics тут не обійтися, тому що R.Item має бути узгоджений із конкретним repo.
4. Золоте правило №2: обирайте any P
Тепер — навпаки. Найсильніший сигнал на користь any P — коли ви хочете зберігати різні реалізації в одному місці й звертатися до них через спільний невеликий контракт.
Найчастіші ситуації: масив обробників, список «плагінів», кілька стратегій логування, набір валідаторів, список форматувачів.
Приклад: кілька логерів в одному масиві
Логер — чудовий приклад, бо він зазвичай не потребує ні associatedtype, ні Self у параметрах: він просто приймає String і щось із ним робить.
protocol Logger {
func log(_ message: String)
}
struct ConsoleLogger: Logger {
func log(_ message: String) {
print("[console] \(message)")
}
}
struct PrefixLogger: Logger {
let prefix: String
func log(_ message: String) {
print("[\(prefix)] \(message)")
}
}
Тепер хочемо зберігати їх разом:
let loggers: [any Logger] = [
ConsoleLogger(),
PrefixLogger(prefix: "library")
]
for logger in loggers {
logger.log("Книгу додано") // однаково викликається у всіх
}
Ось тут any Logger — ідеальний варіант: нам не потрібен звʼязок типів. Ми просто хочемо динамічно пройтися по набору реалізацій і викликати один і той самий метод.
Невелика ремарка про ціну
Екзистенційні типи навмисно зробили більш явними через ключове слово any, бо вони можуть приховувати «ціну» — і смислову, і подекуди продуктивну. У Swift Evolution це прямо називають проблемою «надто легкого синтаксису», який спокушає використовувати екзистенційні типи там, де потрібні generics.
Але не варто лякатися: у прикладному коді any — нормальний інструмент. Просто він має розвʼязувати реальну задачу (гетерогенність / плагіни / композиція), а не бути «стилем за замовчуванням».
5. Додаткові правила вибору
Хто визначає конкретний тип
Дуже корисна ментальна модель — і вона справді допомагає на практиці: у generics та any P різні відповіді на запитання «хто обирає конкретний тип».
У generics конкретний тип обирає місце виклику (call site). Ви написали func f<T: P>(_: T), і коли викликаєте f(MyType()), конкретний T стає MyType.
У any P конкретний тип обирає значення, яке лежить у коробці. Ви написали let x: any P = MyType(), і тепер тип змінної — any P, хоча всередині лежить MyType. Для коду, що викликає, MyType як імʼя типу стає невидимим.
З цього випливає практичний наслідок: якщо у вас є функція, якій важливо «бачити» конкретику, наприклад, щоб повʼязати типи, як у R.Item, то їй комфортніше жити у світі generics. Якщо функція має працювати з «коробками» й їй достатньо мінімального контракту, any P буде простішим.
Місце зберігання часто важливіше за місце обчислення
Ось типова помилка в мисленні: «Я ж просто викликаю метод, отже можна any». На практиці все вирішує інше запитання: чи треба зберігати значення надовго.
Якщо значення живе недовго — всередині однієї функції — generics часто перемагають, бо дають максимум інформації компілятору й менше сюрпризів.
Якщо значення має жити у властивості, в масиві чи в словнику, де зберігаються різні реалізації, — any часто перемагає, бо дає змогу зробити один конкретний контейнер.
Приклад: «коротке життя» — generics
func printDescription<T: CustomStringConvertible>(_ value: T) {
print(value.description)
}
printDescription(123) // 123
printDescription("hello") // hello
Тут не потрібно зберігати різні реалізації разом. Ми просто приймаємо значення й друкуємо його.
Приклад: «довге життя» — any
struct AppContext {
let logger: any Logger
}
let context = AppContext(logger: ConsoleLogger())
context.logger.log("Запущено") // [console] Запущено
logger як залежність живе «довго», тому екзистенціал зручний: контексту байдуже, яка саме реалізація всередині.
6. Практична шпаргалка вибору: симптоми
Зараз буде максимально прикладна частина: не теорія, а «якщо бачите це — робіть так». Я навмисно пишу це суцільним текстом, тому що в реальному проєкті сигнали зазвичай приходять пачкою, а не поодинці.
Якщо вам важливо, щоб компілятор зберігав звʼязок між входами й виходами, щоб два аргументи гарантовано були одного типу, щоб тип елемента був повʼязаний із типом контейнера або типом сервісу, generics майже завжди будуть правильним вибором. Щойно ви бачите сигнатуру, де один і той самий тип має пройти через кілька місць, any починає заважати: він стирає конкретику й ламає такі звʼязки.
Якщо ж вам важливо, щоб різні реалізації можна було складати поруч, зберігати в одному масиві, підміняти реалізацію під час виконання програми (наприклад, один логер у продакшені, інший у тестах, третій для красивої демки перед менеджером), тоді any P зазвичай перемагає за простотою. Головна умова: контракт протоколу має бути достатньо «плоским», без залежності від прихованого типу (без associatedtype і без вимог із Self у параметрах), інакше ви ризикуєте отримати «протокол є, а викликати нічого».
І ще один корисний симптом: якщо ви обрали any, а потім постійно пишете приведення типу as? ConcreteType, щоб «дотягнутися до справжніх методів», це майже завжди означає, що any було обрано невдало. Ви самі собі стерли тип, а потім намагаєтеся відновити його вручну — і це вже не абстракція, а пригода.
7. Міні-дерево рішень для API
Щоб мозок не плавився від філософії, зручно мати просту схему. Вона не ідеальна, але рятує від 80% помилок.
flowchart TD
A["Потрібно спроєктувати API: generics чи any?"] --> B{"Потрібен звʼязок типів (один і той самий тип у кількох місцях)?"}
B -->|Так| C["Беремо generics: func f<T: P>(_: T)"]
B -->|Ні| D{"Потрібно зберігати різні реалізації в одному контейнері/властивості?"}
D -->|Так| E["Беремо екзистенціал: let x: any P / [any P]"]
D -->|Ні| F["Скоріше generics або конкретний тип. Не ускладнюємо."]
Зверніть увагу на останню гілку: іноді правильна відповідь — не generics і не any, а просто конкретний тип. Swift не видає медаль за максимальну кількість абстракцій на рядок коду.
8. Типові помилки під час вибору generics vs existentials
Помилка №1: використовувати any P «тому що коротше», а потім дивуватися, що зникли важливі гарантії.
any справді виглядає простіше: ви написали один тип і пішли далі. Але ви заплатили за це тим, що сховали конкретний тип, а разом із ним часто сховали й можливість виразити звʼязки. Якщо метод вимагає узгодженості — один і той самий тип двічі, Item повʼязаний із репозиторієм — any майже напевно заважатиме.
Помилка №2: тягнути generics через увесь проєкт про запас.
Іноді люди, навпаки, починають параметризувати все підряд: App<TLogger, TRepo, TFormatter, ...>. Код стає формально типобезпечним, але читати його неможливо, а кількість умов where починає нагадувати договір оренди на 15 сторінок. Generics вводять не заради спорту, а коли вони утримують важливі звʼязки типів. Якщо звʼязків немає — це сигнал спростити.
Помилка №3: зберігати «плагіни» через generics, коли насправді потрібен any.
Якщо у вас масив обробників або набір стратегій, і вони мають бути різних типів, generics вам не допоможуть, тому що [T] — це масив одного конкретного T. У таких місцях any P — природна форма: один масив, різні реалізації, спільний контракт.
Помилка №4: намагатися вилікувати неправильний вибір форс-кастами (as!).
Коли any заважає викликати потрібний метод, новачок інколи робить висновок «я все одно знаю, що там лежить потрібний тип» і пише as!. Це перетворює гарантію часу компіляції на міну часу виконання: помилка спливе пізніше і в іншому місці. Якщо ви дійшли до as! заради доступу до API, майже завжди треба переробити сигнатуру, зазвичай у бік generics, а не «дотискати» компілятор силою.
Помилка №5: проєктувати протокол «як зручно реалізаціям», а не «як зручно користувачеві протоколу».
Іноді протокол роблять занадто розумним, додають associatedtype і методи, завʼязані на Self, а потім хочуть зберігати все це в any. Протокол — це контракт для споживача. Якщо ви наперед знаєте, що контракт має бути зручним саме як existential, наприклад список плагінів, робіть його таким, щоб його можна було комфортно використовувати через any — без типових пасток і прихованих звʼязків.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ