JavaRush /Курси /Swift SELF /Правила вибору: generics vs екзистенційні типи та any P

Правила вибору: generics vs екзистенційні типи та any P

Swift SELF
Рівень 46, Лекція 0
Відкрита

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)
}

Якщо ми хочемо написати функцію, яка додає елемент двічі, нам потрібен звʼязок repoItem. Це буквально звʼязок типів:

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 — без типових пасток і прихованих звʼязків.

1
Задача
Swift SELF, 46 рівень, 0 лекція
Недоступна
Два логери
Два логери
1
Задача
Swift SELF, 46 рівень, 0 лекція
Недоступна
Перевірка пари
Перевірка пари
1
Задача
Swift SELF, 46 рівень, 0 лекція
Недоступна
Подвійне додавання
Подвійне додавання
1
Задача
Swift SELF, 46 рівень, 0 лекція
Недоступна
Конвеєр тексту
Конвеєр тексту
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ