JavaRush /Курсы /Swift SELF /Наследование и композиция протоколов P & Q в Swift 6....

Наследование и композиция протоколов P & Q в Swift 6.2

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

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 & Q)
Не раздуваем модель лишними протоколами
Вы хотите принимать «любой тип с нужными способностями» any P или any (P & Q) Это буквальная модель экзистенциала: «какой-то тип, но с нужным API»
Вы хотите объявить, что конкретный тип поддерживает несколько контрактов
struct S: P, Q
Это про соответствие типа, не про хранение значения

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. Реализация — в типах. Если в голове держать эту границу, протоколы перестают быть мистикой и становятся просто удобным способом описывать требования.

1
Задача
Swift SELF, 40 уровень, 2 лекция
Недоступна
Ценник билета
Ценник билета
1
Задача
Swift SELF, 40 уровень, 2 лекция
Недоступна
Карточка контента
Карточка контента
1
Задача
Swift SELF, 40 уровень, 2 лекция
Недоступна
Команды терминала
Команды терминала
1
Задача
Swift SELF, 40 уровень, 2 лекция
Недоступна
Выдача в библиотеке
Выдача в библиотеке
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ