JavaRush /Курси /Swift SELF /Умовна відповідність: extension X: P where ...

Умовна відповідність: extension X: P where ...

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

1. Навіщо потрібна умовна відповідність

Якби Swift був кухнею, conditional conformance працювала б як просте правило: «якщо інгредієнт їстівний, то й страва їстівна». На перший погляд це очевидно, але без такого правила довелося б вигадувати окремі страви: «СупЇстівний», «СупМайжеЇстівний» і «СупЩеПодумаю». У програмуванні все так само: ми часто створюємо контейнери й обгортки навколо значень — наприклад, «кешоване значення», «значення з метаданими» або «значення з логуванням». І хочеться, щоб обгортка успадковувала можливості значення всередині, але лише тоді, коли це справді безпечно.

Стандартна бібліотека Swift — головний прихильник цієї ідеї. Наприклад, масив має бути Equatable лише тоді, коли його елементи можна порівнювати через ==. У Swift це прямо виражається в коді стандартної бібліотеки через умовну відповідність: extension Array: Equatable where Element: Equatable.

Те саме стосується Optional: опціонал можна порівнювати на рівність лише тоді, коли порівнюваним є його внутрішнє значення, тобто Wrapped.

2. Синтаксис: як читати extension X: P where ...

Коли вперше бачиш extension Box: Equatable where T: Equatable, мозок чесно намагається вдавати, що просто втомився. Тому домовмося про простий «перекладач».

Синтаксис умовної відповідності виглядає так:

extension X: P where УМОВА { ... }

Читається це так: «тип X відповідає протоколу P, але лише якщо виконано умову».

Умова після where майже завжди пов’язана з внутрішнім типом контейнера. Найчастіше це виглядає так: якщо контейнер зберігає T, ми пишемо where T: SomeProtocol.

Важливо не плутати дві схожі форми, які виглядають як близнюки, але працюють по-різному:

Що пишемо Що це означає
extension P where ... { ... }
Ми додаємо методи й реалізацію до протоколу, точніше — до всіх типів, які вже йому відповідають. Але не робимо нові типи такими, що відповідають чомусь.
extension X: P where ... { ... }
Ми оголошуємо відповідність типу X протоколу P, і вона вмикається лише за виконання умови.

У цій лекції нас цікавить саме другий випадок: умовна відповідність типу протоколу. Цю можливість формалізували як окрему фічу мови (SE-0143) і масово застосували у стандартній бібліотеці.

Мінісловничок: що таке T

Зараз буде важливе уточнення, щоб ви не почувалися зобовʼязаними «вивчити generics за пʼять хвилин». Не потрібно.

В умовній відповідності майже завжди зʼявляється T. Читайте його як: «якийсь тип, що стане конкретним пізніше».

Наприклад:

struct Box<T> {
    let value: T
}

Це просто коробка, у якій лежить значення типу T. Під час використання T перетворюється на конкретний тип:

  • Box<Int> — коробка з Int
  • Box<String> — коробка зі String
  • Box<Book> — коробка з Book

Нам цього достатньо. Ми використовуємо T як заглушку для читання умов: where T: Equatable означає «де T можна порівнювати».

3. Приклад: контейнер стає Equatable, якщо вміст Equatable

Тепер зберемо дуже маленький фрагмент нашого навчального консольного застосунку, у дусі майбутнього LibraryCLI, але без архітектурних шарів — ми їх не чіпаємо. Припустімо, що в нас є книга, і ми хочемо час від часу загортати значення в коробку.

Базові моделі

Почнемо з міні-моделі Book:

import Foundation

struct Book: Equatable {
    let id: Int
    let title: String
}

let b1 = Book(id: 1, title: "Основи Swift")
let b2 = Book(id: 1, title: "Основи Swift")

print(b1 == b2) // true

Тепер зробімо контейнер:

import Foundation

struct Box<T> {
    let value: T
}

Питання: чи можемо ми порівнювати Box<Book> через ==?

Інтуїція каже «так», але компілятор без додаткових правил не зобовʼязаний погоджуватися. Бо Box може зберігати що завгодно: Box<Any>, Box<URLSession>, Box<НечтоНечитаемое>. І якщо T не можна порівнювати, то й коробку також не можна.

Умовна відповідність Equatable

Ось де зʼявляється conditional conformance:

import Foundation

struct Box<T> {
    let value: T
}

extension Box: Equatable where T: Equatable {
    static func == (lhs: Box<T>, rhs: Box<T>) -> Bool {
        lhs.value == rhs.value
    }
}

let a = Box(value: 10)
let b = Box(value: 10)

print(a == b) // true

Ключова думка така: ми не робимо Box завжди Equatable. Ми робимо його Equatable лише за умови, що T: Equatable.

Це рівно та сама ідея, яку використовують у стандартній бібліотеці для Array та Optional: тип-обгортка отримує здатність, якщо її внутрішній вміст теж її має.

4. Приклад: Cached<T> стає LibraryItem, якщо T — LibraryItem

Тепер зробімо приклад, який відчувається більш прикладним, ніж Box. Уявімо, що ми хочемо зберігати не просто книги, а «кешовані» книги: значення плюс момент часу, коли ми його зберегли. У реальному застосунку це може бути дата, але поки візьмемо просто число.

Протокол «те, що є в нас у бібліотеці»

Припустімо, у нашому консольному застосунку є протокол:

import Foundation

protocol LibraryItem {
    var id: Int { get }
    var title: String { get }
}

struct Book: LibraryItem {
    let id: Int
    let title: String
}

І є функція, яка друкує будь-який бібліотечний елемент:

import Foundation

func printItem(_ item: any LibraryItem) {
    print("#\\(item.id): \\(item.title)")
}

printItem(Book(id: 1, title: "Основи Swift")) // #1: Основи Swift

Обгортка Cached<T>

import Foundation

struct Cached<T> {
    let value: T
    let cachedAt: Int
}

На цьому етапі Cached<Book> ще не є LibraryItem. Хоча за змістом нам дуже хочеться, щоб це було так: всередині лежить Book, у якого є id і title.

Умовна відповідність LibraryItem

Ось як це виражається:

import Foundation

protocol LibraryItem {
    var id: Int { get }
    var title: String { get }
}

struct Book: LibraryItem {
    let id: Int
    let title: String
}

struct Cached<T> {
    let value: T
    let cachedAt: Int
}

extension Cached: LibraryItem where T: LibraryItem {
    var id: Int { value.id }
    var title: String { value.title }
}

let cachedBook = Cached(value: Book(id: 2, title: "Протоколи"), cachedAt: 100)
print("#\\(cachedBook.id): \\(cachedBook.title)") // #2: Протоколи

Тут є важлива дисципліна: ми реалізуємо вимоги LibraryItem через те, що гарантовано умовою (T: LibraryItem). Тобто ми маємо право писати value.id і value.title, бо умова каже, що такі властивості існують.

І тепер магія стає корисною: ми можемо передати кешований об’єкт туди, де очікується протокол:

import Foundation

func printItem(_ item: any LibraryItem) {
    print("#\\(item.id): \\(item.title)")
}

let cachedBook = Cached(value: Book(id: 2, title: "Протоколи"), cachedAt: 100)
printItem(cachedBook) // #2: Протоколи

Якщо ж спробувати зробити Cached<Int> бібліотечним елементом, компілятор скаже «ні», і він матиме рацію: у Int немає title.

5. Умовна відповідність і as?

Зовні conditional conformance виглядає як умова для компілятора, але в неї є й практичний ефект для перевірок під час виконання через as?. Це особливо корисно, коли ви працюєте з чимось на кшталт Any або з колекцією різних сутностей.

Уявімо, що у нас є значення Any, і ми хочемо перевірити, чи можна трактувати його як any LibraryItem.

import Foundation

protocol LibraryItem {
    var id: Int { get }
    var title: String { get }
}

struct Book: LibraryItem {
    let id: Int
    let title: String
}

struct Cached<T> {
    let value: T
    let cachedAt: Int
}

extension Cached: LibraryItem where T: LibraryItem {
    var id: Int { value.id }
    var title: String { value.title }
}

func describe(_ value: Any) {
    if let item = value as? any LibraryItem {
        print("Елемент бібліотеки: #\\(item.id) \\(item.title)")
    } else {
        print("Не елемент бібліотеки")
    }
}

describe(Cached(value: Book(id: 1, title: "Swift"), cachedAt: 10))
// Елемент бібліотеки: #1 Swift

describe(Cached(value: 42, cachedAt: 10))
// Не елемент бібліотеки

Чому так? Тому що Cached<T> стає LibraryItem не завжди, а лише за T: LibraryItem. Тому для Cached<Book> перевірка проходить, а для Cached<Int> — ні.

Цей принцип добре описано в матеріалах про conditional conformance: динамічна перевірка відповідності для обгортки може потребувати додаткової перевірки її параметрів типу — для Array це елементи, а для CachedT.

6. Обмеження і тонкі місця

З умовними відповідностями пов’язано кілька правил, які захищають Swift від хаосу. Вони можуть здаватися примхами мови, але насправді це огорожі, щоб ваш код не перетворювався на загадку навіть для компілятора.

Не можна оголосити одну й ту саму відповідність двічі

Якщо у вас є контейнер Wrapper<T>, ви не можете зробити дві різні версії Wrapper: Equatable з різними умовами, навіть якщо здається, що умови не перетинаються. Swift забороняє так робити, щоб не виникала ситуація: «а яку реалізацію вибрати?»

Поганий і некомпілювальний приклад виглядає так:

import Foundation

struct Wrapper<T> { let value: T }

protocol HasIdentity {
    static func identical(_ a: Self, _ b: Self) -> Bool
}

// Помилка: повторна відповідність Equatable (концептуальний приклад)
extension Wrapper: Equatable where T: Equatable { /* ... */ }
extension Wrapper: Equatable where T: HasIdentity { /* ... */ }

Навіть якщо ви впевнені, що «в нас ніколи не буде типу, який і Equatable, і HasIdentity», компілятор не зобовʼязаний вам вірити. Тому правило просте: одна відповідність — один раз.

На практиці це лікується тим, що ви обираєте один канонічний спосіб порівняння і дотримуєтеся його або змінюєте API так, щоб порівняння було не через Equatable, а через окремий метод, який ви викликаєте явно.

Нюанс із наслідуванням протоколів

Є ще один тонкий момент, який іноді дивує. Якщо протокол Q наслідується від P (protocol Q: P {}), то звичайна, неумовна відповідність Q зазвичай означає і відповідність P. Але з умовними відповідностями не завжди зрозуміло, яку саме умову потрібно застосувати до P, тому Swift може вимагати оголосити відповідність P окремо.

Виглядає це приблизно так, ідея тут важливіша за деталі:

import Foundation

protocol P { }
protocol Q: P { }
protocol R: P { }

struct X<T> { }

// Дві різні умовні відповідності
extension X: Q where T: Q { }
extension X: R where T: R { }

// І тут Swift може вимагати: "а P-то де?"

Чому компілятор не «здогадається сам»? Бо він не хоче вгадати неправильно й зафіксувати у вашому API випадкову, надто жорстку умову. У документах про умовні відповідності це окремо пояснюють як питання коректності та сумісності API.

Практичне правило для новачка тут просте: якщо компілятор просить явно написати ще одну відповідність, не сперечайтеся з ним. Він хоче, щоб умову було чітко й однозначно вказано.

7. Типові помилки під час використання conditional conformance

Помилка №1: плутати extension P where … і extension X: P where
Це виглядає як маленька перестановка літер, але зміст інший: у першому випадку ви додаєте методи всім, хто вже відповідає протоколу, а в другому — робите сам тип таким, що відповідає протоколу, за умови. Якщо ви очікуєте, що тип почне відповідати протоколу, але написали розширення протоколу, нічого не станеться, окрім появи методів у вже наявних конформерів.

Помилка №2: писати реалізацію вимог, не спираючись на умову.
У extension Cached: LibraryItem where T: LibraryItem ви маєте право використовувати лише те, що гарантує T: LibraryItem. Якщо всередині реалізації ви починаєте звертатися до value.author або value.pages, яких протокол не обіцяв, код або не скомпілюється, або змусить вас розширювати протокол зайвими вимогами. Ліки тут прості: умовна відповідність має спиратися на контракт.

Помилка №3: намагатися зробити дві різні conditional conformance до одного протоколу.
Swift забороняє перекривні відповідності, бо інакше легко отримати неоднозначність вибору реалізації. Якщо вам хочеться «для одних типів одне порівняння, для інших — інше», це найчастіше сигнал, що Equatable вам не підходить як форма API, і потрібне окреме, явно назване порівняння або інший дизайн.

Помилка №4: очікувати, що умовна відповідність працюватиме всюди однаково, не перевіряючи конкретний T.
Cached<Book> і Cached<Int> — це два різні конкретні типи. В одного відповідність LibraryItem вмикається, в іншого — ні. Тому, коли ви пишете функції, що приймають any LibraryItem, памʼятайте: не все, що схоже за формою, зобовʼязане туди підходити. Іноді треба ще раз перевірити, що саме ви загортаєте в контейнер.

Помилка №5: робити умову занадто складною на ранньому етапі навчання.
Conditional conformance — потужний інструмент, але «потужний» не означає «усюди потрібний». Якщо ви ловите себе на бажанні написати where T: P, T: Q, T.Associated == … — це ознака, що ви вже будуєте нетривіальну систему узагальнень. На цьому етапі краще триматися простих умов на кшталт where T: Equatable або where T: LibraryItem, щоб код залишався читабельним і не перетворювався на іспит із криптографії.

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