JavaRush /Курсы /Swift SELF /Conditional conformance: extension X: P where ...

Conditional conformance: extension X: P where ...

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

1. Зачем нужен conditional conformance

Если бы 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 Basics")
let b2 = Book(id: 1, title: "Swift Basics")

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 Basics")) // #1: Swift Basics

Обёртка 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: "Protocols"), cachedAt: 100)
print("#\\(cachedBook.id): \\(cachedBook.title)") // #2: Protocols

Здесь есть важная дисциплина: мы реализуем требования 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: "Protocols"), cachedAt: 100)
printItem(cachedBook) // #2: Protocols

Если же попытаться сделать Cached<Int> библиотечным элементом — компилятор скажет «нет», и он прав: у Int нет title.

5. Условное соответствие и as?

Снаружи conditional conformance выглядит как «условие для компилятора», но у него есть и практический эффект для runtime-проверок через 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("LibraryItem: #\\(item.id) \\(item.title)")
    } else {
        print("Не LibraryItem")
    }
}

describe(Cached(value: Book(id: 1, title: "Swift"), cachedAt: 10))
// LibraryItem: #1 Swift

describe(Cached(value: 42, cachedAt: 10))
// Не LibraryItem

Почему так? Потому что 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 лекция
Недоступна
Кэш каталога
Кэш каталога
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ