JavaRush /Курсы /Swift SELF /Идея type erasure: когда реально понадобится

Идея type erasure: когда реально понадобится

Swift SELF
46 уровень , 2 лекция
Открыта

1. Зачем нужен type erasure

Когда читаешь про type erasure впервые, легко подумать: «Ого, какая-то продвинутая техника. Наверное, её используют только люди в чёрных водолазках, которые пишут стандартную библиотеку». На самом деле type erasure появляется не из любви к сложности, а из очень приземлённой боли: мы хотим хранить разные реализации одного протокола в одном месте и при этом продолжать вызывать нужные методы типобезопасно.

Типичная ситуация звучит так: «У меня есть протокол, но он с associatedtype. Через any я не могу нормально вызвать методы, а через generics мне приходится тащить параметр типа через весь проект, и код начинает выглядеть как очень грустный учебник по высшей математике». В Swift‑мире эта проблема настолько частая, что даже в обсуждениях развития языка её описывают как «existential trap»: легко перейти к any, но потом сложно вернуться обратно к удобным generics, и иногда приходится писать type eraser.

Type erasure — это приём, который создаёт один конкретный тип‑обёртку (обычно AnyX), внутри которого лежит конкретная реализация, а наружу торчит удобный API. То есть мы как бы говорим: «Я не хочу знать, какой конкретно там тип внутри, но хочу знать (и зафиксировать), какие типы и методы важны для моего сценария».

Что «стирается» и что «фиксируется»

Перед тем как смотреть код, полезно прямо словами проговорить идею. Потому что если сразу нырнуть в обёртки с замыканиями, можно почувствовать себя как человек, который пришёл за хлебом, а попал на лекцию по квантовой физике (вроде интересно, но непонятно, где батон).

Type erasure делает две вещи одновременно:

  1. Стирает конкретный тип реализации (например, какой именно класс/структура обслуживает контракт).
  2. Фиксирует важные типовые оси (например, что репозиторий работает именно с 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("Saved book: \(item.title)") // Saved book: ...
    }
}
import Foundation

struct PrintBookRepository: Repository {
    func add(_ item: Book) {
        print("Pretend-saving book: \(item.title)") // Pretend-saving book: ...
    }
}

И вот здесь возникает классический «хочу хранить репозиторий в переменной, но не хочу делать весь код 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")) // Pretend-saving book: 1984

А теперь самое вкусное: можно сложить разные реализации в одну коллекцию, потому что тип один и тот же:

import Foundation

let repos: [AnyRepository<Book>] = [
    AnyRepository(PrintBookRepository()),
    AnyRepository(PrintBookRepository())
]

for r in repos {
    r.add(Book(id: 2, title: "Dune"))
    // Pretend-saving book: 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

Почему «existential trap» вообще существует

В Swift‑спецификации отдельно обсуждают, что существование any-значений может стать «ловушкой»: вы легко получаете any P, но потом внезапно не можете вызвать generic‑функцию или использовать связанные типы, и приходится либо переписывать сигнатуры, либо писать type eraser.

Swift 6 делает жизнь лучше за счёт более явных existentials (any) и улучшений на границе anygenerics (вроде implicit opening existentials). Но даже если компилятор научился «открывать коробку» для вызова generic‑функции, это не отменяет главный сценарий type erasure: хранение и композиция в виде одного конкретного типа.

То есть грубо:

  • Implicit opening помогает «вызвать функцию и тут же забыть».
  • Type erasure помогает «положить в карман и носить с собой».

Почему это не то же самое, что generics

Здесь полезно сделать короткое сравнение, потому что путаница очень типичная.

Подход Что это по смыслу Когда удобно
Generics (
func f<T: P>(_: T)
)
«Я работаю с конкретным типом, но не называю его» Когда важны типовые связи и всё происходит «здесь и сейчас»
Existential (
any P
)
«У меня значение неизвестного типа в коробке» Когда протокол простой (без
associatedtype
/
Self
в параметрах) и нужно хранить разные реализации
Type erasure (
AnyX
)
«Я сделал один конкретный тип, который внутри прячет разные реализации, но важные типы зафиксированы» Когда протокол с
associatedtype
, и вам нужно хранить/возвращать/компоновать реализации

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 на все случаи жизни».
В учебных целях и в реальном прикладном коде лучше держать обёртку минимальной: ровно под текущий протокол и ровно под нужные методы. Как только вы начинаете оборачивать «всё подряд» (особенно если протокол большой), обёртка разрастается и становится отдельным мини‑проектом. На этом курсе нам достаточно уметь распознать идею и понимать, зачем она существует.

1
Задача
Swift SELF, 46 уровень, 2 лекция
Недоступна
Ловушка existential
Ловушка existential
1
Задача
Swift SELF, 46 уровень, 2 лекция
Недоступна
Обёртка AnySaver
Обёртка AnySaver
1
Задача
Swift SELF, 46 уровень, 2 лекция
Недоступна
Фабрика sender
Фабрика sender
1
Задача
Swift SELF, 46 уровень, 2 лекция
Недоступна
Плагины строки
Плагины строки
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ