1. Иногда полезно «склеивать» протоколы
Когда вы только начинаете писать код, хочется сделать один протокол DoEverything и положить туда вообще всё: имя, печать, запуск, валидацию, настроение и прогноз погоды. Это нормальная стадия — как желание хранить все вещи в одном пакете «чтобы не потерять». Но у этого подхода есть побочный эффект: чем «толще» контракт, тем меньше типов смогут ему соответствовать, и тем чаще вы будете тащить ненужные требования просто потому, что «так исторически сложилось».
Гораздо удобнее мыслить мелкими контрактами: один протокол отвечает за «есть имя», другой — за «может выполниться», третий — за «может показать help». А дальше вы решаете: вам нужно постоянное имя для комбинации требований (тогда протокольное наследование), или комбинация нужна только в одном месте (тогда композиция P & Q).
2. Наследование протоколов
Наследование протоколов часто путают с наследованием классов — и это прямо классическая ловушка. У классов наследование обычно ассоциируется с «получили поведение и данные от предка». У протоколов наследование означает другое: мы наследуем требования. То есть новый протокол «включает в себя» требования родительского и добавляет новые. Никаких stored properties, никаких готовых реализаций — просто расширение контракта.
Синтаксис выглядит так:
import Foundation
protocol Parent {
var id: Int { get }
}
protocol Child: Parent {
var title: String { get }
}
Здесь Child означает: «тип обязан иметь всё, что требует Parent, и ещё title».
Мини‑пример: сущности библиотеки как набор контрактов
Давайте продолжим нашу учебную историю с консольным приложением (в перспективе это будет наш LibraryCLI, а пока — просто один файл). Представим, что у нас есть разные «объекты библиотеки»: книги, журналы, возможно, что-то ещё. Мы начнём с маленьких протоколов и соберём из них большой.
import Foundation
protocol HasID {
var id: Int { get }
}
protocol HasTitle {
var title: String { get }
}
protocol LibraryItem: HasID, HasTitle {
var kind: String { get }
}
Обратите внимание: LibraryItem наследуется сразу от двух протоколов (HasID, HasTitle). Это всё ещё «наследование протоколов», просто родителей может быть несколько.
Теперь любой тип, который объявит : LibraryItem, автоматически обязан реализовать id, title и kind.
import Foundation
struct Book: LibraryItem {
let id: Int
let title: String
let author: String
var kind: String { "book" }
}
С точки зрения компилятора это очень прямолинейно: «если сказал Book: LibraryItem, покажи мне id, title, kind».
Как читать наследование «по‑человечески»
На этом месте мозг иногда пытается усложнить. Кажется, что где-то скрыта магия: «раз LibraryItem наследуется от HasID, значит там есть какая-то реализация…» — нет. У протоколов наследование — это просто способ не копировать требования руками и дать комбинации требований понятное имя.
Представьте, что протокол — это список галочек в анкете, а наследование — это «в анкете B автоматически включены вопросы анкеты A». Никто за вас не отвечает на вопросы, но список вопросов стал длиннее.
Ещё один уровень: «аудио‑объекты» как расширенный контракт
Если вам хочется ещё один уровень: допустим, у нас есть «читаемые» элементы (книга, журнал), а есть «медиа» (аудиокнига). Мы можем сделать новый протокол‑наследник:
import Foundation
protocol HasDuration {
var minutes: Int { get }
}
protocol AudioItem: LibraryItem, HasDuration {
var narrator: String { get }
}
AudioItem теперь означает «это библиотечный объект + у него есть длительность + есть диктор». И это всё — снова про требования, а не про реализацию.
3. Композиция протоколов P & Q и слово any
Иногда вам не нужно вводить новый протокол с именем. Комбинация требований нужна «вот прямо здесь», в одной функции. Например: «дай мне что угодно, лишь бы у него был id и title». Для этого есть композиция протоколов: P & Q.
В современном Swift экзистенциальные типы (то есть значения «некоторого неизвестного конкретного типа, но соответствующего протоколу») принято явно помечать словом any. Это сделано, чтобы код читался честнее: «я действительно храню любой тип, подходящий под протокол».
Поэтому в реальном коде вы чаще увидите:
- any P — «значение любого типа, который соответствует P»
- any (P & Q) — «значение любого типа, который соответствует и P, и Q»
Мини‑пример: функции нужны только id и title
import Foundation
protocol HasID { var id: Int { get } }
protocol HasTitle { var title: String { get } }
func printShortCard(_ value: any (HasID & HasTitle)) {
print("#\(value.id): \(value.title)")
}
struct Book: HasID, HasTitle {
let id: Int
let title: String
}
printShortCard(Book(id: 1, title: "Swift для людей")) // #1: Swift для людей
Почему здесь any (HasID & HasTitle), а не any HasID & HasTitle? Со скобками обычно читается проще (как «any от (P & Q)»), и в этом виде легче увидеть, что any применяется ко всей композиции сразу.
4. Наследование vs композиция: в чём разница по смыслу
Снаружи эти механики похожи. Но смысл разный, и этот смысл становится важным, когда проект разрастается (а он разрастётся, даже если вы будете сопротивляться и говорить «у меня маленькая утилита»).
Наследование протоколов — это когда вы говорите: «Эта комбинация требований встречается часто, она заслуживает имени». Например, LibraryItem как «в библиотеке у каждого объекта есть id и title».
Композиция протоколов — это когда вы говорите: «В этой функции мне нужно вот это и вот это, но отдельное имя для комбинации мне не нужно». Например, функция печати карточки может работать с любым HasID & HasTitle, и ей всё равно, является ли это «официальным» LibraryItem.
Чтобы закрепить, давайте на одной схеме:
flowchart TD
A["HasID"] --> C["LibraryItem"]
B["HasTitle"] --> C["LibraryItem"]
C --> D["Book conforms to LibraryItem"]
X["printShortCard(value: any (HasID & HasTitle))"] --> Y["Можно передать Book, Magazine, что угодно"]
Верхняя часть (стрелки к LibraryItem) — про наследование контрактов. Нижняя строка — про композицию «на месте использования».
5. Запятая и амперсанд: разные смыслы в синтаксисе
В этом месте у новичков обычно случается «синтаксический туман»: запятые, амперсанды, any… выглядит как пароль от Wi‑Fi, который вы не просили.
Разложим по полочкам.
struct S: P, Q — это про тип. Мы объявляем: «тип S соответствует P и Q».
let x: any (P & Q) — это про значение. Мы объявляем: «переменная x хранит значение некоторого неизвестного конкретного типа, но оно соответствует и P, и Q».
Мини‑пример рядом:
import Foundation
protocol P { func p() }
protocol Q { func q() }
struct S: P, Q {
func p() { print("p") }
func q() { print("q") }
}
let value: any (P & Q) = S()
value.p() // p
value.q() // q
Идея простая: запятая в S: P, Q — список соответствий типа. Амперсанд в (P & Q) — «пересечение возможностей», набор требований, которые должны выполняться одновременно.
6. Пример из CLI: команды как комбинации контрактов
Теперь давайте сделаем не «абстрактный пример про P и Q», а что-то максимально приземлённое: кусочек инфраструктуры для CLI‑команд. В консольных программах постоянно встречается одна и та же связка: у команды есть имя, у неё есть help, и её можно выполнить.
Сделаем три протокола: NamedCommand, RunnableCommand, HelpPrintableCommand. А потом соберём их в один «командный» протокол через наследование.
import Foundation
protocol NamedCommand {
var name: String { get }
}
protocol RunnableCommand {
func run()
}
protocol HelpPrintableCommand {
var help: String { get }
}
protocol CLICommand: NamedCommand, RunnableCommand, HelpPrintableCommand { }
Обратите внимание на важную мысль: CLICommand не добавляет ничего нового. Он просто говорит: «команда = имя + запуск + help». Это идеальный кандидат для наследования протоколов: комбинация повторяется и заслуживает отдельного имени.
Реализуем пару команд
import Foundation
struct PingCommand: CLICommand {
let name: String = "ping"
let help: String = "ping — проверка, что приложение живо"
func run() {
print("pong") // pong
}
}
И команда help:
import Foundation
struct HelpCommand: CLICommand {
let name: String = "help"
let help: String = "help — показать список команд"
func run() {
print("Доступные команды: ping, help") // Доступные команды: ping, help
}
}
Да, пока help «захардкожен» — это нормально для учебного примера. Мы не делаем архитектуру уровня космического корабля, мы учимся комбинировать контракты.
Композиция «на месте»: печатаем help без требования run()
А теперь представьте, что у нас есть функция, которая печатает справку по чему-то, что «имеет имя» и «имеет help». Ей не обязательно, чтобы объект был полноценной командой и умел run(). Значит, мы не хотим требовать CLICommand. Мы хотим потребовать ровно две способности.
Вот тут и появляется композиция протоколов:
import Foundation
protocol NamedCommand { var name: String { get } }
protocol HelpPrintableCommand { var help: String { get } }
func printHelp(for value: any (NamedCommand & HelpPrintableCommand)) {
print("\(value.name): \(value.help)")
}
И проверка:
import Foundation
struct AboutScreen: NamedCommand, HelpPrintableCommand {
let name: String = "about"
let help: String = "about — информация о приложении"
}
printHelp(for: AboutScreen()) // about: about — информация о приложении
printHelp(for: PingCommand()) // ping: ping — проверка, что приложение живо
Здесь красота в том, что AboutScreen не обязан быть командой. Он просто удовлетворяет ровно тем требованиям, которые нужны функции.
7. Экзистенциалы в коллекциях и приведение типов
После того как вы научились писать any P, возникает естественное желание: «а можно ли массив таких значений?» Можно. И это один из самых практичных сценариев: хранить рядом разные типы, объединённые контрактом.
Например, мы можем хранить список команд как список «любой команды»:
import Foundation
let commands: [any CLICommand] = [
PingCommand(),
HelpCommand()
]
for cmd in commands {
print(cmd.name) // ping затем help
}
Или, если нам нужны только name и help, можно хранить [any (NamedCommand & HelpPrintableCommand)] и класть туда вообще что угодно подходящее.
Когда вспоминаем про as?
Бывает и обратная ситуация: вы храните значения по узкому контракту, а потом внезапно хотите сделать что-то «дополнительное». Например, у вас массив [any NamedCommand], а вы хотите печатать help — но help не часть NamedCommand.
Тогда есть два честных пути. Первый — понять, что help действительно важен для всех, и расширить контракт (например, перейти на CLICommand). Второй — сказать: «help есть не у всех, поэтому я попробую аккуратно привести тип».
import Foundation
let named: [any NamedCommand] = [PingCommand(), HelpCommand()]
for item in named {
if let helpable = item as? any HelpPrintableCommand {
print("\(item.name) -> \(helpable.help)")
} else {
print("\(item.name) -> (no help)")
}
}
Здесь важно, что as? — не «костыль», а нормальный инструмент для случая «я хранил по одному контракту, а теперь хочу проверить дополнительные возможности». Главное — не скатываться в as!, если вы не пишете код для соревнования «сломай приложение одним символом».
Шпаргалка: что выбрать
| Ситуация | Лучше подходит | Почему |
|---|---|---|
| Комбинация требований повторяется по проекту и должна иметь имя | protocol Child: Parent (и несколько родителей тоже можно) | Дешевле для чтения кода: один термин вместо «мешка требований» |
| Комбинация нужна в одной функции/переменной | |
Не раздуваем модель лишними протоколами |
| Вы хотите принимать «любой тип с нужными способностями» | any P или any (P & Q) | Это буквальная модель экзистенциала: «какой-то тип, но с нужным API» |
| Вы хотите объявить, что конкретный тип поддерживает несколько контрактов | |
Это про соответствие типа, не про хранение значения |
8. Типичные ошибки
Ошибка №1: путать запятую и амперсанд.
Запятая используется, когда вы объявляете соответствия типа: struct Book: HasID, HasTitle. Амперсанд используется, когда вы описываете «пересечение возможностей» в типе значения: any (HasID & HasTitle). Если перепутать, вы обычно получаете ошибку компилятора, но хуже другое: вы начинаете думать, что это «всё одно и то же», и теряете смысловую разницу между «тип обязуется» и «значение хранится по контракту».
Ошибка №2: писать огромный протокол вместо маленьких.
Очень соблазнительно создать один протокол LibraryThing и добавить туда 15 требований «на будущее». Потом оказывается, что половина типов не может ему соответствовать, а другая половина вынуждена реализовывать пустышки. Наследование протоколов — это способ держать протоколы маленькими и собирать из них большие только там, где это действительно нужно.
Ошибка №3: забывать про any и получать странные сообщения компилятора.
В современных версиях Swift идея в том, что экзистенциальный тип должен быть явно помечен как экзистенциальный: any P, а для композиции — any (P & Q). Это сделано, чтобы вы не путали экзистенциалы с ограничениями дженериков и чтобы тип «любое значение, подходящее под протокол» читался честно.
Ошибка №4: делать протокольную иерархию слишком глубокой.
Иногда люди строят «лесенку» из 6–7 протоколов: A, B: A, C: B, D: C… и через неделю никто не может понять, какие требования в итоге нужны. Наследование протоколов полезно, пока оно помогает читать код. Когда оно превращается в квест «найди все требования, переходя по ссылкам», лучше остановиться и переосмыслить имена и слои контрактов.
Ошибка №5: ожидать, что протокольное наследование даст реализацию.
Если вы определили protocol CLICommand: NamedCommand, RunnableCommand, HelpPrintableCommand, это не значит, что у вас «где-то появится код» для run(). Протоколы описывают API. Реализация — в типах. Если в голове держать эту границу, протоколы перестают быть мистикой и становятся просто удобным способом описывать требования.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ