1. Введение
Кажется, что это мелочь уровня «положу куда влезет — и поехали». Но как только у вас появляется 3–4 типа, которые «почти одинаково» что-то делают, код начинает расползаться, как кот на батарее: занимает всё пространство, при этом делает вид, что так и надо. Если вы кладёте общую логику не туда, вы либо вынуждаете все типы реализовывать лишнее, либо, наоборот, прячете важное поведение так, что его нельзя нормально использовать через any Protocol.
Чтобы говорить предметно, будем развивать мини‑CLI «библиотека» (упрощённо, без полноценного парсинга команд — его мы уже проходили раньше). Идея такая: у нас есть разные элементы библиотеки (книга, журнал), и мы хотим уметь выводить их в консоль «красивыми строками».
2. Протокол как контракт: что обязаны уметь все
Когда мы пишем протокол, мы не «складываем туда код». Мы формулируем обещание: «если тип соответствует этому протоколу, значит, у него точно есть вот это». Это особенно важно, когда вы передаёте значения как any P — тогда внешний код опирается только на контракт, а не на случайные детали реализации.
Начнём с простого протокола для наших элементов библиотеки:
import Foundation
protocol LibraryItem {
var title: String { get }
func line() -> String
}
Здесь title и line() — это требования. Почему line() вообще требование? Потому что это поведение, которое нам нужно одинаково вызывать «снаружи» для любого элемента, даже если это не книга.
Теперь сделаем конкретный тип:
import Foundation
struct Book: LibraryItem {
let title: String
let author: String
func line() -> String {
"\(title) — \(author)"
}
}
И второй тип:
import Foundation
struct Magazine: LibraryItem {
let title: String
let issueNumber: Int
func line() -> String {
"\(title) (№\(issueNumber))"
}
}
Смысл: мы гарантируем внешнему коду, что любой LibraryItem умеет превращаться в строку через line().
А теперь — самое приятное: внешний код вообще не обязан знать, что внутри.
import Foundation
func printCatalog(_ items: [any LibraryItem]) {
for item in items {
print(item.line())
}
}
let items: [any LibraryItem] = [
Book(title: "Swift для людей", author: "А. Разработчиков"),
Magazine(title: "Чиним баги еженедельно", issueNumber: 42)
]
printCatalog(items)
// Swift для людей — А. Разработчиков
// Чиним баги еженедельно (№42)
Если метод важен как часть контракта и вы ожидаете, что разные типы будут иметь разную реализацию — это кандидат на требование протокола.
3. Общая логика: требования vs helper‑методы
Сейчас будет тонкий, но жизненно важный момент, который отличает аккуратный дизайн от «протокол‑помойка».
Протокол — это минимальный набор обязанностей, а не «самый удобный набор методов, который мне хотелось бы иметь». Общая логика может жить рядом с протоколом, но не обязательно в виде требований. Иногда общая логика должна быть «наружной надстройкой» — и это как раз работа extension. Но не всё подряд.
Давайте введём понятия, которые нам нужны для сегодняшней лекции:
- Требование протокола — то, что обязательно должно существовать у каждого конформера, и то, на что может рассчитывать внешний код.
- Удобный метод (helper) — приятный бонус, который можно вычислить из требований, но он не обязан быть частью контракта.
Проблема в том, что на глаз эти две вещи выглядят одинаково: «вот метод, его можно вызвать». А по смыслу — это совершенно разные сущности.
4. Контракт и реализация: где должна жить логика
Когда общую логику стоит делать требованием протокола
Иногда хочется сказать: «пусть протокол сам решит». Но протокол ничего не «решает», он «обещает». Требование протокола стоит добавлять тогда, когда метод действительно является обязательной способностью.
В нашем примере line() — это обязательная способность: без неё мы не можем вывести каталог единым способом.
Представьте, что мы сначала сделали только title, а line() решили «как-нибудь потом»:
import Foundation
protocol LibraryItem {
var title: String { get }
}
Тогда печать каталога выглядела бы так:
import Foundation
func printCatalog(_ items: [any LibraryItem]) {
for item in items {
print(item.title)
}
}
Это компилируется, но быстро становится «слишком бедно»: мы теряем нужные детали (author, issueNumber) и вынуждены либо делать as? Book, либо городить дополнительные проверки, либо… снова менять протокол.
Практическое правило: если внешний код должен вызывать поведение через any Protocol и ожидает, что поведение может отличаться по типам — добавляйте это как требование.
Когда общую логику стоит оставить в extension
Теперь более тонкая ситуация. Допустим, мы хотим уметь «искать по названию» (по запросу пользователя). Это полезно, но вопрос: это часть контракта LibraryItem или просто удобная надстройка?
По смыслу, любой LibraryItem имеет title, и мы можем выразить поиск через title. Значит, это может быть helper, который одинаков для всех.
import Foundation
protocol LibraryItem {
var title: String { get }
func line() -> String
}
extension LibraryItem {
func matchesTitle(_ query: String) -> Bool {
title.localizedCaseInsensitiveContains(query)
}
}
Обратите внимание: matchesTitle НЕ объявлен в протоколе. Это именно helper.
Использование:
import Foundation
let book = Book(title: "Swift для людей", author: "А. Разработчиков")
print(book.matchesTitle("swift")) // true
print(book.matchesTitle("kotlin")) // false
Почему это удобно? Потому что вы не заставляете каждый тип писать одинаковый код, и при этом не раздуваете контракт.
Но у этого решения есть «цена»: если вы будете хранить значение как any LibraryItem, вам будет видна прежде всего контрактная часть. Напоминание из прошлых лекций: any P — это «коробка», через которую вы видите только то, что протокол обещал.
То есть если мы напишем так:
import Foundation
let item: any LibraryItem = Book(title: "Swift для людей", author: "А. Разработчиков")
print(item.line()) // ок, это требование
// print(item.matchesTitle("Swift")) // (сюрприз для новичка) может быть недоступно в некоторых контекстах дизайна API
В учебных примерах часто «вроде работает», но в реальном проектировании API вы должны помнить простую мысль: если метод важен «снаружи», он должен быть в протоколе. Иначе вы сами себе поставите ловушку: метод есть, но использовать его в нужном месте неудобно.
Сейчас мы не углубляемся в «сюрпризы выбора реализации» и детали вызовов — это отдельная тема конца дня. Здесь нам достаточно понимания: helper‑методы хороши, но они не заменяют контракт.
Когда общую логику нужно держать в конкретном типе
Есть категория логики, которую очень хочется «вынести в протокол», но протоколу это не подходит — не потому что «нельзя», а потому что будет неправильно по смыслу.
Самая частая причина: логика зависит от внутреннего состояния, которое протокол не обещает.
Например, пусть мы захотели сделать «красивую строку» с кэшем, чтобы не пересобирать её каждый раз. В struct мы можем хранить состояние, а в протоколе — нет (протокол не хранит поля, он не «контейнер данных»).
Попробуем сделать специальный тип, который кэширует готовую строку:
import Foundation
struct CachedBookLine: LibraryItem {
let title: String
let author: String
private let cached: String
init(title: String, author: String) {
self.title = title
self.author = author
self.cached = "\(title) — \(author)"
}
func line() -> String { cached }
}
Здесь кэш — это деталь реализации. И вот такую штуку точно не нужно делать частью протокола, иначе вы заставите все типы «иметь кэш», хотя большинству он не нужен.
Ещё одна причина: логика специфична для одного типа и не имеет смысла для всех.
Например, у Magazine может быть метод «следующий выпуск» (условно), а у книги — нет. Если вы добавите это в протокол, вы либо заставите Book возвращать бессмысленное значение, либо будете писать заглушки, либо превратите контракт в «набор странных методов, которые иногда работают».
Держите в голове простую аналогию: протокол — это как «правила дорожного движения» для типов. Если вы туда добавите «каждый транспорт обязан иметь турбину», велосипедисты вас не поймут, а автобус — обидится.
5. Как выбрать место для общей логики: схема и таблица
Когда вы только начинаете, хочется «универсального правила». Полностью универсального нет, но есть очень рабочая схема рассуждений, которая спасает от хаоса. Сначала спрашиваем: «Это контракт?», потом: «Это общий код?», потом: «Это внутренняя кухня?». И только потом пишем код. Это экономит часы будущей жизни, а будущая жизнь, как известно, в дедлайнах.
Мини‑таблица решений
| Вопрос про поведение | Правильное место | Что получаем |
|---|---|---|
| Это обязаны уметь все конформеры, и внешний код должен рассчитывать на это | Требование в protocol | Чёткий контракт, работа через any P |
| Поведение одинаковое для всех и выражается через требования протокола | extension Protocol как helper | Меньше копипасты, чистый контракт |
| Поведение обязано быть у всех, но обычно одинаковое | Требование + default‑реализация в extension | И контракт есть, и типы не страдают |
| Поведение зависит от внутренних полей/инвариантов и не нужно всем | Конкретный тип | Можно использовать состояние, private, оптимизации |
Блок‑схема
flowchart TD
A[Хотим добавить общую логику] --> B{Это часть контракта?}
B -- Да --> C{Нужны разные реализации по типам?}
C -- Да --> D[Добавить как требование протокола]
C -- Нет --> E[Требование + default-реализация в extension]
B -- Нет --> F{Можно выразить через требования протокола?}
F -- Да --> G[Helper в extension протокола]
F -- Нет --> H[Оставить в конкретном типе]
Эта схема кажется «теоретической», пока вы не поймаете первый баг, когда метод лежит «не там», и его невозможно нормально вызывать в нужном месте. После этого схема внезапно становится очень практичной и даже немного терапевтической.
6. Пример: каталог и поиск без копипасты
Соберём всё в один небольшой фрагмент «нашего приложения»: у нас есть элементы библиотеки, печать каталога и фильтрация по запросу.
import Foundation
protocol LibraryItem {
var title: String { get }
func line() -> String
}
extension LibraryItem {
func matchesTitle(_ query: String) -> Bool {
title.localizedCaseInsensitiveContains(query)
}
}
struct Book: LibraryItem {
let title: String
let author: String
func line() -> String { "\(title) — \(author)" }
}
struct Magazine: LibraryItem {
let title: String
let issueNumber: Int
func line() -> String { "\(title) (№\(issueNumber))" }
}
Теперь функция поиска и печати:
import Foundation
func search(_ items: [any LibraryItem], query: String) -> [any LibraryItem] {
items.filter { $0.matchesTitle(query) }
}
func printCatalog(_ items: [any LibraryItem]) {
for item in items {
print(item.line())
}
}
И проверка:
import Foundation
let items: [any LibraryItem] = [
Book(title: "Swift для людей", author: "А. Разработчиков"),
Book(title: "Алгоритмы без паники", author: "Н. Спокойный"),
Magazine(title: "Чиним баги еженедельно", issueNumber: 42)
]
let found = search(items, query: "swift")
printCatalog(found)
// Swift для людей — А. Разработчиков
Обратите внимание на баланс:
- line() — контракт, потому что печать каталога обязана работать для всех.
- matchesTitle() — helper, потому что это удобная общая логика, выражаемая через title.
- Всё, что специфично (автор, номер выпуска), живёт в конкретных типах.
7. Нюанс про доступы и видимость
Даже в учебных примерах, где всё в одном файле, полезно сразу привыкать к мысли, что access control и расположение кода сильно влияют на то, как ваш API выглядит «снаружи».
В Swift члены, добавленные в extension, получают уровень доступа по правилам, которые зависят и от самого типа, и от модификатора extension (например, private extension делает добавленные методы приватными по умолчанию). Это важная деталь, потому что общая логика в extension может внезапно стать «не видна там, где вы ожидаете».
Практически это означает: когда вы решаете «положить helper в extension», вы одновременно решаете, будет ли это частью публичного API, внутренней утилитой или приватной деталью. В маленьких примерах это не бросается в глаза, а в реальном проекте — очень даже.
8. Типичные ошибки
Ошибка №1: делать протокол слишком «толстым».
Новички часто превращают протокол в список «всего, что мне когда-либо может пригодиться». В результате каждый новый тип, который «вроде подходит», вынужден реализовывать кучу лишнего, а реализация превращается в набор заглушек. Хороший протокол обычно маленький: 1–3 требования, которые действительно определяют способность.
Ошибка №2: прятать важное поведение только в helper‑методе, хотя оно должно быть контрактом.
Если внешнему коду нужно полагаться на метод (особенно когда значения хранятся как any P), этот метод должен быть объявлен в протоколе как требование. Иначе вы получите ситуацию «метод есть, но пользоваться им неудобно/нельзя в нужном месте», и начнётся либо as?‑кастинг, либо дублирование логики.
Ошибка №3: тащить в extension логику, которая зависит от внутренних полей конкретного типа.
extension протокола хорош, когда вы можете выразить поведение через требования протокола. Если для работы метода вам «очень хочется» залезть в какое-то поле конкретного типа, это сигнал, что логика должна жить в конкретном типе (или что протокол составлен неверно и ему не хватает требования — но добавлять требования стоит только если это действительно часть контракта).
Ошибка №4: копипастить одинаковую реализацию в нескольких типах вместо того, чтобы вынести её в extension.
Если метод одинаковый у всех, и его можно выразить через требования протокола — это прямой кандидат на helper в extension. Копипаста в таких местах особенно коварна: вы «починили» баг в одном типе, забыли во втором, и теперь пользователю кажется, что программа ведёт себя случайно (а программа просто последовательна в своих ошибках).
Ошибка №5: не думать про доступы и неожиданно сделать общий метод недоступным.
Когда логика вынесена в extension, легко забыть, что модификаторы доступа и правила видимости применяются и к extension‑членам тоже. В одном модуле всё может быть видно, а в другом — внезапно нет. Это не «магия», а закономерность: доступы — часть дизайна API, и место размещения кода на них влияет.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ