Стирання типів у Swift: як спростити роботу з протоколами з асоційованими типами за допомогою any і some та власної реалізації

Стирання типів у Swift: як спростити роботу з протоколами з асоційованими типами за допомогою any і some та власної реалізації

Проблема: навіщо вам знати про стирання типів?

Уявіть, що ви будуєте гнучку систему у Swift і вирішуєте використати протокол з асоційованим типом (PAT). Зрештою, опора на абстракції, а не на конкретні реалізації, – одна з основ якісного проєктування ПЗ. Тут варто згадати принципи SOLID і найкращі практики @Uncle Bob! Наприклад, ви визначаєте такий протокол:

protocol InterfaceWithAssociatedType {
    associatedtype OurType
    func doSomething(with value: OurType)
}

Такий підхід дає змогу побудувати модульну систему, яку легко тестувати і яка відповідає Dependency Inversion Principle (літера «D» у SOLID): залежати від абстракцій, а не від конкретних деталей. Але протоколи з асоційованими типами мають свої особливості. Зокрема, зберігаючи значення за протокольним інтерфейсом, можна приховати інформацію про асоційований тип, потрібну для виклику його методів. Як же поєднати абстракцію з реалізацією? Розберімося!

Наприклад, ви хочете зберегти у змінній екземпляр типу, що відповідає цьому протоколу, або передати його як параметр функції. І одразу натрапляєте на проблему:

struct StringHandler: InterfaceWithAssociatedType {
    func doSomething(with value: String) {
        print("Handling string: \(value)")
    }
}

let handler: any InterfaceWithAssociatedType = StringHandler()
handler.doSomething(with: "Hello, World!") // Member 'doSomething' cannot be used on value of type 'any InterfaceWithAssociatedType'; consider using a generic constraint instead

Саме оголошення змінної коректне у Swift 5.7 і новіших версіях, але виклик методу не компілюється: цей інтерфейс не повідомляє коду, який його використовує, що асоційований тип (OurType) є String. Передавати довільний String було б небезпечно для деяких можливих обробників.

Як це обійти? Стирання типів приховує конкретний тип реалізації за інтерфейсом; при цьому корисні обмеження на асоційовані типи можна зберегти. У цій статті порівняємо any і manual (custom) стирання типів зі спорідненою альтернативою some, яка приховує конкретний тип, зберігаючи його статичну ідентичність.

Підхід 1: використання any і some

Swift надає два потужні інструменти для роботи з протоколами з асоційованими типами: any і some. Обидва варіанти типобезпечні, але розкривають різну інформацію про базовий тип.

Що таке any і some?

let stringHandler: any InterfaceWithAssociatedType = StringHandler()

func process(handler: any InterfaceWithAssociatedType) {
    // Note: You cannot call `doSomething` directly because `OurType` is unknown.
    // You would need to cast or use a wrapper to resolve the type.

    /* e.g.:  casting 
    (handler as? StringHandler)?.doSomething(with: "Hello world")
    */
}

Тут any дає змогу зберігати або передавати значення, але інтерфейс не розкриває тип вхідного аргументу, потрібний методу. Зберігання екзистенційного значення й диспетчеризація можуть додавати витрати під час виконання, зокрема опосередкований доступ і, за потреби, виділення пам’яті в купі. Фактичні витрати залежать від значення, оптимізації та навантаження.

Докладніше про any у пропозиції для Swift 5.6

  • some (непрозорі типи результату з’явилися у Swift 5.1; оголошення непрозорих параметрів – у Swift 5.7) У позиції результату some дає реалізації змогу вибрати конкретний базовий тип, приховуючи його від коду, який викликає функцію, і зберігаючи ідентичність цього типу. У позиції параметра це скорочений запис узагальненого параметра: тип, що відповідає протоколу, вибирає код, який викликає функцію. Якщо ви працювали з протоколом View у SwiftUI, непрозорі результати вам, мабуть, уже знайомі.

Погляньмо на ці фрагменти коду:

func createHandler() -> some InterfaceWithAssociatedType {
     StringHandler()
}

let handler = createHandler()
handler.doSomething(with: "Hello World") // ERROR: Cannot convert value of type 'String' to expected argument type '(some InterfaceWithAssociatedType).OurType'

Проблема в тому, що це оголошення результату не розкриває обмеження OurType == String. Асоційований тип і далі має сталу ідентичність, але код, який викликає функцію, не може вважати його типом String. Цей зв’язок можна зберегти за допомогою обмежень узагальнених типів, власної обгортки або первинного асоційованого типу. Первинні асоційовані типи та спрощений синтаксис обмежень рівності типів з’явилися у Swift 5.7. Ось оновлений протокол і параметр з обмеженням, яке дозволяє передавати рядки:

protocol InterfaceWithAssociatedTypeNew<OurType> { // <- `<OurType>`
    associatedtype OurType
    
    func doSomething(with value: OurType)
}

// The primary associated type constraint is repeated here: `...<String>`
func process(handler: some InterfaceWithAssociatedTypeNew<String>) {
    handler.doSomething(with: "Hello world")
}

struct StringHandlerNew<TypeNew>: InterfaceWithAssociatedTypeNew {
    func doSomething(with value: TypeNew) {
        print("Handling new string: \(value)")
    }
}

let handler = StringHandlerNew<String>()
process(handler: handler)
// or, simply:
handler.doSomething(with: "Direct call")

до речі, наша функція

func process(handler: some InterfaceWithAssociatedType) {
   ...
}

еквівалентна такому узагальненому оголошенню:

func process<T: InterfaceWithAssociatedType>(handler: T) {
  ...
}

Цей скорочений запис для параметрів з’явився у Swift 5.7 разом з оголошеннями непрозорих параметрів. Це окрема можливість, відмінна від непрозорих типів результату, які з’явилися раніше.

З оновленим протоколом результат може розкрити таке саме обмеження через some InterfaceWithAssociatedTypeNew<String>. А екзистенційні типи з обмеженнями у Swift 5.7 також дозволяють запис any InterfaceWithAssociatedTypeNew<String>, тож власна обгортка не обов’язкова, якщо потрібно лише зберегти цей тип вхідного аргументу.

Докладніше про some у пропозиції для Swift 5.1

Підхід 2 (те, заради чого ми всі тут зібралися): Manual (Custom) стирання типів

Якщо вам потрібна іменована обгортка з API, який ви контролюєте, можна використати manual стирання типів. Ця обгортка відповідає протоколу й приховує конкретний тип обробника H, зберігаючи його асоційований тип вхідного аргументу як узагальнений параметр T.

struct AnyInterfaceWithAssociatedType<T>: InterfaceWithAssociatedType {
    private let _doSomething: (T) -> Void

    init<H: InterfaceWithAssociatedType>(_ handler: H) where H.OurType == T {
        _doSomething = handler.doSomething
    }

    func doSomething(with value: T) {
        _doSomething(value)
    }
}

Тепер за допомогою обгортки зі стиранням типу можна зберігати або передавати обробники:


let stringHandlerManualEraser = AnyInterfaceWithAssociatedType(StringHandler())

func process<T>(handler: AnyInterfaceWithAssociatedType<T>, 
                value: T) {
    handler.doSomething(with: value)
}

process(handler: stringHandlerManualEraser, 
        value: "Hello World, from manual eraser!")

Так тип вхідного аргументу перевіряється під час компіляції, а обгортку можна використовувати повторно, проте це потребує додаткового шаблонного коду. Обгортка зберігає замикання, яке передає виклик далі; це не гарантує відсутності накладних витрат під час виконання. Замикання захоплюють і зберігають контекст, а витрати на виділення пам’яті чи непрямі виклики залежать від оптимізації та використання.

Якщо ви раптом помітили, що підхід із manual стиранням типів дуже нагадує патерн Decorator, то маєте рацію!

По суті, ми обгортаємо початковий тип структурою або класом, який «декорує» його: відповідає протоколу й делегує всю роботу базовій реалізації. Ніби надягаємо на конкретний тип вишукане пальто, щоб приховати його складність, але залишити йому змогу виконувати свою роботу. Саме в цьому й полягає суть патерна Decorator: додати рівень абстракції, не змінюючи основної функціональності.

Тож так, власна реалізація стирання типів – це не просто трюк у Swift, а ще й відсилання до класичних патернів проєктування. Круто, правда? 🎩✨

Порівняння підходів

Підхід Переваги Недоліки
any - Мінімум шаблонного коду
- Зберігає різні типи, що відповідають протоколу, за одним інтерфейсом
- Дозволяє обмежувати первинні асоційовані типи
- Можливі витрати на зберігання й диспетчеризацію
- Доступ до членів залежить від розкритих обмежень
some - Зберігає статичну ідентичність типу
- Приховує тип реалізації результату
- Скорочує оголошення узагальнених параметрів
- Результат має один базовий тип для кожного набору узагальнених аргументів типу
- У позиціях параметра й результату має різну семантику
- Потрібні обмеження на асоційовані типи слід розкрити
Власна реалізація (Manual / Custom) - Іменована обгортка з API, який ви контролюєте
- Зберігає вибрані обмеження на асоційовані типи
- Працює зі змінними, параметрами й типами повернення
- Потребує додаткового шаблонного коду й підтримки
- Передавання викликів через замикання може додавати витрати під час виконання
- Не настільки готове до використання «з коробки», як any або some

Висновок

Протоколи з асоційованими типами – потужна можливість Swift, але працювати з ними буває непросто. Type erasure може приховати конкретну реалізацію, зберігаючи потрібні вашому API обмеження на асоційовані типи.

Використовуйте any, коли значення має містити різні типи, що відповідають протоколу. Використовуйте some для непрозорих результатів або стислого оголошення узагальненого параметра. Використовуйте manual стирання типів, коли власна обгортка підходить вашому API. Розуміння цих технік допоможе вибрати підхід для вашого конкретного випадку й уникнути поширених пасток у роботі з PAT.

P.S. Перш ніж обирати any, перевірте, чи можна виразити потрібний зв’язок між типами через some або параметр generic. Збереження статичної ідентичності типу може дати оптимізатору більше можливостей, але жоден із цих записів не гарантує певної продуктивності. any корисний, коли потрібне зберігання екзистенційного значення або зміна конкретного типу під час виконання. Обирайте абстракцію відповідно до задачі й вимірюйте продуктивність, якщо вона важлива.

Востаннє оновлено

На цій сторінці