1. Зачем ограничения на методы
Когда начинаешь писать обобщённый контейнер, очень хочется сразу сделать «умно»: добавить поиск, удаление по значению, проверку «есть ли такой элемент». Но тут появляется важный вопрос: можем ли мы сравнивать элементы? Ведь операция «удалить элемент X» в реальности означает «найти элементы, равные X», то есть использовать ==.
И вот здесь новичок обычно делает одно из двух:
- либо «ломает универсальность» и объявляет Stack<T: Equatable> (тогда стек работает только с equatable‑типами),
- либо начинает изворачиваться: сравнивать через String(describing:), ObjectIdentifier, «сериализацию в JSON» и прочую магию. Магия, кстати, почти всегда заканчивается тем, что у вас внезапно удаляется не то, что вы ожидали.
Нам нужен более честный и аккуратный подход: оставить Stack<T> универсальным, но добавлять методы, требующие ==, только тогда, когда T действительно умеет сравниваться.
Именно для этого в Swift есть ограничения (constraints) на уровне API: «этот метод доступен только если T: Equatable».
2. Два способа ограничить API
Давайте чуть притормозим и разложим по полочкам: где именно можно написать where T: Equatable, чтобы метод появился только в нужных случаях.
Есть два популярных способа.
Constrained extension: группируем условные методы
Идея простая: мы пишем обычный Stack<T>, а затем добавляем extension Stack where T: Equatable { ... }. Внутри этой extension можно писать сколько угодно методов, которые используют ==.
Это тот же стиль, который вы встречаете в стандартной библиотеке: многие возможности появляются «условно», когда элемент поддерживает нужный протокол (например, история с тем, что контейнеры могут быть Equatable, если их содержимое Equatable).
Constrained method: ограничение у метода
Иногда хочется держать методы вместе по смыслу: часть методов в основной структуре, а часть — рядом, но с условиями. Начиная со Swift 5.3, язык разрешает писать where-ограничения прямо у методов и других member‑объявлений внутри generic‑контекста: то есть метод может ссылаться на внешний generic‑параметр T и говорить «я доступен только когда T соответствует протоколу».
Практически это означает: вы можете выбрать стиль, который лучше читается, а не тот, который «заставили обстоятельства».
Чтобы не путаться, вот мини-таблица:
| Подход | Как выглядит | Когда удобнее |
|---|---|---|
| Constrained extension | |
Когда методов несколько, и вы хотите их «сгруппировать» |
| Constrained method | |
Когда метод один, и хочется держать его рядом с базовым API |
Как выбрать стиль
Иногда кажется, что это «просто два синтаксиса». Но на практике стиль влияет на читаемость.
Если у вас один метод, который требует Equatable, и он логически относится к основной структуре, то func ... where T: Equatable может быть очень уместен — так правило видно прямо в сигнатуре. Возможность писать такие where на member‑объявлениях в generic‑контексте закреплена в языке именно ради удобства API‑дизайна.
Если у вас несколько методов (обычно так и бывает), то constrained extension проще читать: один раз видите extension Stack where T: Equatable, и дальше мозг не спотыкается на каждом методе.
3. Базовый Stack<T> без требований
Начнём с «чистого» стека, который ничего не требует от T. Он должен работать хоть с Int, хоть со строками, хоть с вашим типом Book, хоть с чем угодно.
import Foundation
struct Stack<T> {
private var items: [T] = []
var count: Int { items.count }
var isEmpty: Bool { items.isEmpty }
var peek: T? { items.last }
mutating func push(_ value: T) { items.append(value) }
mutating func pop() -> T? { items.popLast() }
}
Здесь нет ни одного места, где нам нужен ==. Значит, мы не имеем права требовать Equatable «просто на всякий случай». Универсальность — это не абстрактная красота, а практическая вещь: вы сможете использовать этот стек в более широком диапазоне задач.
4. contains: только при T: Equatable
Теперь добавим поиск по значению. Логика простая: если items — массив, то у массива есть contains(_:), но он тоже требует, чтобы элемент умел сравниваться.
Вариант: constrained extension
import Foundation
extension Stack where T: Equatable {
func contains(_ value: T) -> Bool {
items.contains(value)
}
}
Здесь возникает маленькая техническая деталь: мы обращаемся к items, который private. Поэтому либо делайте items как fileprivate, либо держите extension в том же файле (в рамках одной лекции и учебного проекта это обычно так и будет), либо добавьте внутренний приватный helper. В учебном коде проще всего — оставить extension в том же файле, где объявлен Stack, чтобы доступ к private сохранялся.
Чуть более «учебно-явный» вариант — добавить в Stack закрытый метод, который возвращает массив, но это уже лишняя механика.
Проверим, как это ощущается в использовании:
import Foundation
var s = Stack<Int>()
s.push(10)
s.push(20)
print(s.contains(10)) // true
print(s.contains(99)) // false
И это — главная магия generics, но магия хорошая: метод contains появляется ровно тогда, когда он честен.
Вариант: constrained method
Если вы предпочитаете держать метод внутри структуры, можно написать так:
import Foundation
extension Stack {
func contains2(_ value: T) -> Bool where T: Equatable {
items.contains(value)
}
}
Да, это тот же смысл, просто where находится «на уровне метода». Такой стиль стал гораздо удобнее после того, как Swift разрешил where на member‑объявлениях в generic‑контексте.
5. count(of:): считаем совпадения
contains — это хорошо, но он возвращает Bool. А иногда хочется знать, сколько раз значение встречается. Для стека это не самая «классическая» операция (стек обычно не про поиск), но в учебных целях она идеальна: показывает, как писать читаемый метод с ==, не ломая общий тип.
import Foundation
extension Stack where T: Equatable {
func count(of value: T) -> Int {
var result = 0
for element in items {
if element == value { result += 1 }
}
return result
}
}
Проверка:
import Foundation
var s = Stack<String>()
s.push("A")
s.push("B")
s.push("A")
print(s.count(of: "A")) // 2
print(s.count(of: "B")) // 1
Обратите внимание на «честность сигнатуры»: метод существует только там, где сравнение имеет смысл.
6. removeAll(_:): мутация и where
Теперь добавим метод, который удаляет все элементы, равные заданному значению. Тут появляется мутация, а значит вспоминаем mutating.
Важно: мы не обязаны реализовывать это суперэффективно (стек всё-таки предполагает push/pop), но мы обязаны реализовать это предсказуемо и без нарушения инвариантов.
import Foundation
extension Stack where T: Equatable {
mutating func removeAll(_ value: T) {
items.removeAll { element in
element == value
}
}
}
Проверка:
import Foundation
var s = Stack<Int>()
s.push(1)
s.push(2)
s.push(1)
s.push(3)
s.removeAll(1)
print(s.pop() as Any) // Optional(3)
print(s.pop() as Any) // Optional(2)
print(s.pop() as Any) // nil
Здесь приятно то, что инвариант «верх = конец массива» продолжает жить: removeAll просто убирает элементы из массива, не меняя смысл push/pop.
7. pushUnique(_:): правило без дубликатов
Иногда хочется запретить дубликаты (например, для истории действий). Строго говоря, это уже ближе к Set, чем к стеку, но в реальной разработке часто делается именно так: «контейнер базовый, а сверху — правило».
Сделаем метод: добавляем элемент, только если такого ещё нет.
import Foundation
extension Stack where T: Equatable {
mutating func pushUnique(_ value: T) -> Bool {
if items.contains(value) { return false }
items.append(value)
return true
}
}
Проверка:
import Foundation
var history = Stack<String>()
print(history.pushUnique("add")) // true
print(history.pushUnique("add")) // false
print(history.pushUnique("list")) // true
print(history.count) // 2
Обратите внимание: мы вернули Bool, чтобы вызывающий код мог понять, «произошло ли добавление». Это маленькая UX‑деталь, но она делает API заметно приятнее.
8. Пример из CLI: история команд
Теперь давайте привяжем всё к приложению курса — консольному LibraryCLI. На этом этапе курса оно ещё может быть простым: мы читаем команду строкой, парсим в enum Command, выполняем действие.
Представим, что у нас есть команды, и мы хотим хранить историю, но без повторов (чтобы история не превращалась в повторяющийся ввод одной и той же команды).
Сделаем Command сравнимым. Если у enum простые кейсы и associated values тоже Equatable, Swift обычно умеет синтезировать Equatable автоматически (вам нужно лишь объявить conformance).
import Foundation
enum Command: Equatable {
case add(title: String)
case list
case remove(title: String)
case undo
}
Теперь используем Stack<Command> и метод pushUnique.
import Foundation
var commandHistory = Stack<Command>()
let c1 = Command.list
let c2 = Command.list
let c3 = Command.add(title: "Dune")
_ = commandHistory.pushUnique(c1)
_ = commandHistory.pushUnique(c2)
_ = commandHistory.pushUnique(c3)
print(commandHistory.count) // 2
Почему это круто именно как учебный шаг: стек остаётся универсальным, но когда вы начинаете хранить в нём тип, который можно сравнивать, у вас «включаются дополнительные возможности».
9. Что скажет компилятор, если T не Equatable
Очень полезно один раз увидеть, что будет, если T не Equatable. Допустим, есть тип без Equatable:
import Foundation
struct NotEquatable {
let id: Int
}
var s = Stack<NotEquatable>()
// print(s.contains(NotEquatable(id: 1))) // не скомпилируется
И это — не «плохая новость», а отличная. Компилятор защищает вас от ситуации «метод работает как-то странно». Он говорит честно: «сравнивать нельзя — значит, и contains нет».
Если бы язык это пропустил, вы бы получили намного худшую проблему: программа бы работала, но сравнивала «как-нибудь» (или падала). Swift предпочитает, чтобы ошибка была ранней и понятной.
Идея ограничений на API ровно в этом: не дать вам написать код, смысл которого не определён.
10. Как компилятор «включает» API
Чтобы закрепить в голове, представьте такую схему:
flowchart TD
A["Stack<T> (базовый)"] --> B["push/pop/peek/count/isEmpty"]
A --> C{"T: Equatable ?"}
C -- "нет" --> D["contains/removeAll/pushUnique НЕ доступны"]
C -- "да" --> E["extension Stack where T: Equatable"]
E --> F["contains/count(of:)/removeAll/pushUnique доступны"]
Смысл схемы в том, что ограничение — это не проверка в runtime, а правило на уровне типов: компилятор решает, какие методы вообще существуют для конкретного Stack<Int> или Stack<Command>.
11. Типичные ошибки
Ошибка №1: ограничить весь тип Stack<T> как Stack<T: Equatable> без реальной необходимости.
Такой шаг часто делают «на автомате», потому что первый добавленный метод оказался связан с ==. Но цена очень высокая: вы теряете возможность хранить в стеке несравнимые типы, хотя push/pop им прекрасно подходят. Обычно лучше ограничивать только те методы, которым действительно нужен ==.
Ошибка №2: пытаться «обойти Equatable» через String(describing:) или сравнение по памяти.
Иногда кажется, что можно сравнить любые значения, если превратить их в строку или сравнить адрес объекта. Проблема в том, что это почти никогда не совпадает с тем, что пользователь считает «равенством». Equatable — это явный контракт, и если его нет, то лучше честно не предоставлять такой API.
Ошибка №3: сделать constrained extension, но случайно потерять доступ к items из‑за private.
Часто студенты выносят extension в другой файл и удивляются, почему не видят items. Это нормальная защита инкапсуляции. Самый простой путь в учебном проекте — держать Stack и его extension рядом в одном файле. В более «взрослой» архитектуре вы бы проектировали публичный API так, чтобы extension не требовал доступа к деталям хранения.
Ошибка №4: писать методы, требующие Equatable, но забывать where T: Equatable.
Выглядит это обычно так: вы пишете items.contains(value) и получаете ошибку компилятора, потому что T не гарантированно сравним. Решение не в том, чтобы «додавить компилятор», а в том, чтобы честно добавить ограничение: либо на extension, либо на метод.
Ошибка №5: делать removeAll или contains через ! и «я уверен, что тут всё есть».
Это другая разновидность самообмана: Equatable и Optional решают разные проблемы. Equatable говорит «можно сравнивать», Optional говорит «значения может не быть». Если у вас пустой стек — это не повод для !, это повод выбрать правильный контракт (Optional/throws/Result) и следовать ему последовательно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ