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.
Важно не путать две похожие формы, которые выглядят как близнецы, но делают разное:
| Что пишем | Что это означает |
|---|---|
|
Мы добавляем методы/реализацию в протокол (точнее: всем типам, которые ему соответствуют), но не делаем новые типы соответствующими чему-то. |
|
Мы объявляем соответствие типа 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 — элементов, у Cached — T).
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, чтобы код оставался читаемым и не превращался в экзамен по криптографии.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ