1. Навіщо потрібен type erasure
Коли вперше читаєш про type erasure, легко подумати: «Ого, якась надто складна техніка. Мабуть, її використовують лише люди в чорних водолазках, які пишуть стандартну бібліотеку». Насправді type erasure зʼявляється не з любові до складності, а з дуже приземленої потреби: ми хочемо зберігати різні реалізації одного протоколу в одному місці й при цьому й далі типобезпечно викликати потрібні методи.
Типова ситуація звучить так: «У мене є протокол, але в ньому є associatedtype. Через any я не можу нормально викликати методи, а через generics мені доводиться тягнути параметр типу через увесь проєкт, і код починає скидатися на дуже сумний підручник із вищої математики». У світі Swift ця проблема настільки поширена, що навіть в обговореннях розвитку мови її описують як «екзистенційну пастку»: легко перейти до any, але потім важко повернутися до зручних generics, і часом доводиться писати type eraser.
Type erasure — це прийом, який створює один конкретний тип-обгортку (зазвичай AnyX), усередині якого захована конкретна реалізація, а назовні ми отримуємо зручний API. Тобто ми ніби кажемо: «Мені не потрібно знати, який саме тип усередині, але важливо зафіксувати, які типи й методи потрібні в моєму сценарії».
Що «стирається» і що «фіксується»
Перш ніж дивитися код, корисно проговорити ідею простими словами. Бо якщо одразу пірнути в обгортки із замиканнями, можна відчути себе як людина, яка прийшла за хлібом, а потрапила на лекцію з квантової фізики: ніби цікаво, але незрозуміло, де батон.
Type erasure робить дві речі одночасно:
- Стирає конкретний тип реалізації (наприклад, яка саме структура або клас обслуговує контракт).
- Фіксує важливі типові звʼязки (наприклад, що репозиторій працює саме з Book, а не з «чимось»).
Можна уявити це як пакування посилки.
- «Стирання» — це як прибрати назву виробника з коробки: усередині може бути що завгодно, але зовні коробки однакові.
- «Фіксація» — це як наклеїти наліпку «КРИХКЕ: СКЛО» (тобто важлива властивість, яку не можна втратити).
Для візуалізації — невелика схема:
flowchart LR
A["Конкретний тип: InMemoryRepo"] --> W["Обгортка: AnyRepository<Book>"]
B["Конкретний тип: LoggingRepo"] --> W
W --> U["Зовні код бачить один тип і знає, що Item == Book"]
Зовні ми працюємо з одним типом AnyRepository<Book>, і це зручно для властивостей, масивів, фабрик та інших місць, де generics незручно тягнути крізь увесь код.
2. Проблема: associatedtype і незручний any
Зараз зробимо приклад у стилі нашого навчального консольного застосунку — умовно «мінібібліотека». У нас є книга:
import Foundation
struct Book {
let id: Int
let title: String
}
І ми хочемо репозиторій — об’єкт, що вміє зберігати книги:
import Foundation
protocol Repository {
associatedtype Item
func add(_ item: Item)
}
Дві прості реалізації:
import Foundation
struct InMemoryBookRepository: Repository {
private var items: [Book] = []
mutating func add(_ item: Book) {
items.append(item)
print("Збережено книгу: \(item.title)") // Збережено книгу: ...
}
}
import Foundation
struct PrintBookRepository: Repository {
func add(_ item: Book) {
print("Ніби зберігаємо книгу: \(item.title)") // Ніби зберігаємо книгу: ...
}
}
І ось тут виникає класична ситуація: «хочу зберігати репозиторій у змінній, але не хочу робити весь код generic».
Інтуїтивна спроба:
import Foundation
let repo: any Repository = PrintBookRepository()
// repo.add(Book(id: 1, title: "1984")) // ❌ Не компілюється: Item невідомий
Чому це ламається? Бо в any Repository не виражено конкретний Item. В одній реалізації Item == Book, в іншій — теж Book (у нашому прикладі), але компілятор не зобов’язаний здогадуватися і не зобов’язаний вірити нам на слово. Для типу any Repository Item може бути чим завгодно, тож викликати add(_:) безпечно не можна.
Це і є центральна причина, чому люди взагалі згадують про type erasure.
3. Рішення: обгортка AnyRepository<Item>
Тепер ключовий трюк: ми створюємо обгортку, яка сама має параметр типу Item і гарантує, що всередині лежить репозиторій саме з таким Item. Далі ця обгортка просто переадресовує виклики всередину.
Мінімальна навчальна форма (без «промислової» універсальності) виглядає так:
import Foundation
struct AnyRepository<Item>: Repository {
private let _add: (Item) -> Void
init<R: Repository>(_ repo: R) where R.Item == Item {
self._add = repo.add
}
func add(_ item: Item) {
_add(item)
}
}
Розберімо людською мовою, що тут відбувається.
Поле _add — це «пульт керування»: збережене замикання, яке знає, як додати Item до реальної реалізації. Ми не зберігаємо сам репозиторій як any Repository, бо тоді знову втратимо Item. Натомість ми зберігаємо дію (функцію), і ця дія вже типізована як (Item) -> Void.
Ініціалізатор каже: «Я можу обгорнути будь-який R: Repository, але лише якщо R.Item збігається з Item моєї обгортки». Ось ця умова where R.Item == Item — серце всієї ідеї.
Перевірмо, як це виглядає у використанні:
import Foundation
let r1 = AnyRepository(PrintBookRepository())
r1.add(Book(id: 1, title: "1984")) // Ніби зберігаємо книгу: 1984
А тепер найцікавіше: можна скласти різні реалізації в одну колекцію, бо тип саме один і той самий:
import Foundation
let repos: [AnyRepository<Book>] = [
AnyRepository(PrintBookRepository()),
AnyRepository(PrintBookRepository())
]
for r in repos {
r.add(Book(id: 2, title: "Dune"))
// Ніби зберігаємо книгу: Dune
}
Зверніть увагу на важливу річ: ми не можемо змішати AnyRepository<Book> і AnyRepository<String> в одному масиві — це різні типи, і це правильно. Type erasure не робить «все з усім», він робить «різне всередині, але однакове назовні за фіксованих важливих типів».
4. Де type erasure справді потрібен
Зараз може виникнути чесне запитання: «Гаразд, я зрозумів ідею. Але коли мені це справді знадобиться, якщо я не пишу стандартну бібліотеку?»
Нижче — три сценарії, де type erasure зʼявляється природно. Я опишу їх без занурення в архітектуру на багато місяців уперед: нам важлива логіка, а не красиві папки в проєкті.
Зберігати реалізацію у властивості без generics
Уявімо сервіс, який додає книги:
import Foundation
struct LibraryService {
private let repo: AnyRepository<Book>
init(repo: AnyRepository<Book>) {
self.repo = repo
}
func addSample() {
repo.add(Book(id: 100, title: "Sample"))
}
}
Якби ми не використовували type erasure, нам довелося б робити так:
import Foundation
struct GenericLibraryService<R: Repository> where R.Item == Book {
private var repo: R
init(repo: R) {
self.repo = repo
}
}
Це іноді нормально, але іноді починає розростатися: один generic-тип тягне за собою інший, і ось уже половина вашого коду живе у світі GenericSomething<T, U, V>.
Type erasure дає змогу зробити сервіс не generic, але при цьому зберегти важливу гарантію: «сервіс працює з Book».
Повернути єдиний тип із фабрики
Припустімо, у вас є функція, яка вибирає реалізацію — за прапорцем, за змінною середовища, за налаштуванням користувача; неважливо:
import Foundation
func makeBookRepository(usePrinting: Bool) -> AnyRepository<Book> {
if usePrinting {
return AnyRepository(PrintBookRepository())
} else {
return AnyRepository(PrintBookRepository()) // припустімо, тут була б інша реалізація
}
}
Якби ви намагалися повернути some Repository або any Repository, ви швидко впираєтеся в обмеження: в одному випадку гілки мають повертати один конкретний тип, а в іншому — ви знову втрачаєте Item. Type eraser дає простий вихід: завжди повертаємо AnyRepository<Book>.
Зробити масив стратегій або плагінів з однаковим контрактом
Це дуже поширена історія: у вас є «речі, які роблять одне й те саме», і ви хочете зберігати набір цих «речей» у масиві та пропускати через нього дані.
Проблема в тому, що протокол із associatedtype сам по собі не є зручним «готовим типом для масиву». Type erasure якраз і робить такий готовий тип.
5. Ціна: компроміси type erasure
Важливо чесно проговорити мінуси, щоб не використовувати type erasure без потреби.
По-перше, зʼявляється додатковий шар. Налагодження стає трохи менш прямолінійним: замість прямого виклику методу ви бачите виклик замикання, яке викликає метод. Це не трагедія, але додає опосередкованості.
По-друге, ви частково втрачаєте інформацію про конкретний тип реалізації. Це, власне, і є мета, але іноді саме конкретний тип вам і потрібен — наприклад, для спеціальних методів, яких немає в протоколі. Якщо ви постійно намагаєтеся вийти за межі абстракції та робити as? ConcreteType, це сигнал, що ви вибрали неправильну абстракцію.
По-третє, type eraser треба підтримувати. Чим більше вимог у протоколі, тим більше переадресування потрібно зробити. Тому в реальному коді часто роблять type erasure не для «гігантських» протоколів, а для акуратно спроєктованих контрактів.
І нарешті, є невелика вартість виконання — виклик через замикання або динамічну прокладку. Зазвичай це не проблема для прикладного коду, але памʼятати про це корисно.
6. Як це повʼязано з any і generics
Чому «екзистенціальна пастка» взагалі існує
У специфікації Swift окремо обговорюють, що існування any-значень може стати «пасткою»: ви легко отримуєте any P, але потім раптово не можете викликати generic-функцію або використати повʼязані типи, і доводиться або переписувати сигнатури, або писати type eraser.
Swift 6 робить життя кращим завдяки більш явним екзистенціалам (any) і поліпшенням на межі any ↔ generics — наприклад, implicit opening existentials. Але навіть якщо компілятор навчився «відкривати коробку» для виклику generic-функції, це не скасовує головний сценарій type erasure: зберігання і композиція у вигляді одного конкретного типу.
Тобто, якщо спростити:
- Implicit opening допомагає «викликати функцію і тут же забути».
- Type erasure допомагає «покласти до кишені й носити із собою».
Чому це не те саме, що generics
Тут корисно зробити коротке порівняння, бо плутанина дуже типова.
| Підхід | Що це за змістом | Коли зручно |
|---|---|---|
Generics () |
«Я працюю з конкретним типом, але не називаю його» | Коли важливі типові звʼязки й усе відбувається тут і зараз |
Existential () |
«У мене в коробці значення невідомого типу» | Коли протокол простий (без / у параметрах) і потрібно зберігати різні реалізації |
Type erasure () |
«Я створив один конкретний тип, який ховає різні реалізації, але важливі типи зафіксовані» | Коли протокол із , і вам потрібно зберігати, повертати або компонувати реалізації |
Type erasure у певному сенсі будує міст між світами: зовні у вас конкретний тип — зручно зберігати, а всередині — будь-яка реалізація, яку можна гнучко підміняти.
Де ви це вже бачили в стандартній бібліотеці
Щоб переконатися, що це нормальна практика, корисно знати, що Swift (і екосистема Apple) використовує type erasure постійно.
Наприклад, ідея типів на кшталт Any... трапляється дуже часто. Навіть якщо ви поки не готові читати їхню реалізацію, сам патерн корисно впізнавати на око: AnySequence, AnyIterator, AnyHashable і так далі. Це майже завжди сигнал: «усередині схований конкретний тип, а зовні — один зручний контейнер».
До речі, у світі Objective-C generics історично теж повʼязані з type erasure: параметризовані класи мають спільну метакласову природу, а частину перевірок типових аргументів просто неможливо надійно виконати в середовищі виконання — тому накладаються обмеження на касти та використання type arguments. Це інший шар мови, але сама ідея «у середовищі виконання параметри типу можуть бути стерті» добре підсвічує, чому взагалі існує термін «type erasure».
7. Типові помилки
Помилка № 1: очікувати, що type erasure дасть змогу змішати різні Item в одній колекції.
Якщо у вас є AnyRepository<Int> і AnyRepository<String>, це два різні типи. Обгортка спеціально фіксує Item, щоб зовні API був типобезпечним. Якщо намагатися «змішати все з усім», ви або прийдете до Any і втратите користь типізації, або до складніших обгорток, які зараз нам не потрібні.
Помилка № 2: зробити обгортку без параметра типу й тим самим повернути проблему назад.
Якщо спробувати написати «просто AnyRepository без <Item>», ви знову опинитеся в ситуації: а який Item очікується? Type eraser працює саме тому, що фіксує важливий тип на рівні самої обгортки: AnyRepository<Book>.
Помилка № 3: почати активно витягувати конкретний тип через as? і використовувати методи не з протоколу.
Якщо після впровадження type erasure ваш код усюди робить if let concrete = repo as? InMemoryBookRepository { ... }, ви по суті скасували абстракцію й перенесли логіку в середовище виконання. Це майже завжди знак, що протокол сформульований занадто слабко (бракує вимог) або що конкретний тип має бути видимим явно і не треба було його «стирати».
Помилка № 4: сприймати type erasure як обов’язкову техніку «для краси API».
Type erasure варто застосовувати, коли без нього справді незручно: потрібно зберігати реалізацію у властивості без generics, потрібно повернути єдиний тип із фабрики, потрібно мати масив «плагінів». Якщо задача розв’язується простими generics, найчастіше так і треба робити: простіше читати, простіше налагоджувати, менше шарів.
Помилка № 5: намагатися одразу написати «універсальний промисловий AnyX на всі випадки життя».
У навчальних цілях і в реальному прикладному коді краще тримати обгортку мінімальною: рівно під поточний протокол і рівно під потрібні методи. Щойно ви починаєте обгортати «все підряд» (особливо якщо протокол великий), обгортка розростається і стає окремим мініпроєктом. На цьому курсі нам достатньо вміти впізнати ідею та розуміти, навіщо вона існує.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ