1. Навіщо потрібні обмеження для методів
Коли ви починаєте писати узагальнений контейнер, дуже хочеться одразу зробити все «розумно»: додати пошук, видалення за значенням і перевірку «чи є такий елемент». Але тут постає важливе питання: чи можемо ми порівнювати елементи? Адже операція «видалити елемент X» насправді означає «знайти елементи, рівні X», тобто використати ==.
І тут новачки зазвичай ідуть одним із двох шляхів:
- або ж «ламають» універсальність і оголошують Stack<T: Equatable> (тоді стек працює лише з типами, що підтримують Equatable),
- або ж починають винаходити обхідні шляхи: порівнювати через String(describing:), ObjectIdentifier, «серіалізацію в JSON» та іншу магію. А магія, до речі, майже завжди завершується тим, що ви раптом видаляєте не те, що очікували.
Нам потрібен чесніший і акуратніший підхід: залишити Stack<T> універсальним, але додавати методи, які вимагають ==, лише тоді, коли T справді вміє порівнюватися.
Саме для цього в Swift є обмеження (constraints) на рівні API: «цей метод доступний лише тоді, коли T: Equatable».
2. Два способи обмежити API
Давайте трохи пригальмуємо й розкладемо все по поличках: де саме можна написати where T: Equatable, щоб метод зʼявлявся лише в потрібних випадках.
Є два популярні способи.
Обмежене розширення: групуємо умовні методи
Ідея проста: ми пишемо звичайний Stack<T>, а потім додаємо extension Stack where T: Equatable { ... }. Всередині цього розширення можна писати скільки завгодно методів, які використовують ==.
Це той самий стиль, який ви бачите у стандартній бібліотеці: багато можливостей зʼявляються «умовно», коли елемент підтримує потрібний протокол. Наприклад, контейнери можуть бути Equatable, якщо їхній вміст теж Equatable.
Метод з обмеженням: правило в самому методі
Іноді хочеться тримати методи разом за змістом: частину — в основній структурі, а частину — поруч, але з умовами. Починаючи зі Swift 5.3, мова дозволяє писати where-обмеження прямо в методах та інших оголошеннях членів в узагальненому контексті. Тобто метод може посилатися на зовнішній generic-параметр T і казати: «я доступний лише тоді, коли T відповідає протоколу».
Практично це означає: ви можете обрати стиль, який краще читається, а не той, до якого вас змушують обставини.
Щоб не плутатися, ось коротка мінітаблиця:
| Підхід | Як виглядає | Коли зручніше |
|---|---|---|
| Обмежене розширення | |
Коли методів кілька і ви хочете їх згрупувати |
| Метод з обмеженням | |
Коли метод один і хочеться тримати його поруч із базовим API |
Як обрати стиль
Іноді здається, що це «просто два синтаксичні варіанти». Але на практиці стиль впливає на читабельність.
Якщо у вас один метод, який вимагає Equatable, і він логічно належить до основної структури, то func ... where T: Equatable може бути дуже доречним — так правило видно прямо в сигнатурі. Можливість писати такі where в оголошеннях членів в узагальненому контексті закріплена в мові саме для зручного проєктування API.
Якщо у вас кілька методів, а так буває найчастіше, то розширення з обмеженням читати легше: один раз бачите 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(_:), але йому потрібен тип, який уміє порівнюватися.
Варіант: обмежене розширення
import Foundation
extension Stack where T: Equatable {
func contains(_ value: T) -> Bool {
items.contains(value)
}
}
Тут є одна технічна деталь: items оголошено як private. Тому або зробіть items як fileprivate, або тримайте розширення в тому самому файлі. У межах однієї лекції й навчального проєкту це зазвичай саме так і буде. Або ж додайте внутрішній приватний допоміжний метод. У навчальному коді найпростіше — залишити розширення в тому самому файлі, де оголошено 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 зʼявляється рівно тоді, коли це коректно.
Варіант: метод з обмеженням
Якщо ви віддаєте перевагу тому, щоб тримати метод усередині структури, можна написати так:
import Foundation
extension Stack {
func contains2(_ value: T) -> Bool where T: Equatable {
items.contains(value)
}
}
Так, це той самий зміст, просто where знаходиться «на рівні методу». Такий стиль став набагато зручнішим після того, як Swift дозволив where на оголошеннях членів в узагальненому контексті.
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["розширення Stack where T: Equatable"]
E --> F["contains/count(of:)/removeAll/pushUnique доступні"]
Сенс схеми в тому, що обмеження — це не перевірка в середовищі виконання, а правило на рівні типів. Компілятор вирішує, які методи взагалі існують для конкретного Stack<Int> або Stack<Command>.
11. Типові помилки
Помилка №1: без потреби обмежувати весь тип Stack<T> як Stack<T: Equatable>.
Так часто роблять механічно, тому що перший доданий метод виявився повʼязаним із ==. Але ціна дуже висока: ви втрачаєте можливість зберігати в стеку типи, які не можна порівняти, хоча push/pop їм чудово підходять. Зазвичай краще обмежувати лише ті методи, яким справді потрібен ==.
Помилка №2: намагатися «обійти» Equatable через String(describing:) або порівняння адреси обʼєкта.
Іноді здається, що можна порівняти будь-які значення, якщо перетворити їх на рядок або порівняти адресу обʼєкта. Проблема в тому, що це майже ніколи не збігається з тим, що користувач вважає «рівністю». Equatable — це явний контракт, і якщо його немає, то краще чесно не надавати такий API.
Помилка №3: зробити обмежене розширення, але випадково втратити доступ до items через private.
Часто студенти виносять розширення в інший файл і дивуються, чому не бачать items. Це нормальний захист інкапсуляції. Найпростіший шлях у навчальному проєкті — тримати Stack та його розширення поруч в одному файлі. У більш дорослій архітектурі ви б спроєктували публічний API так, щоб розширення не залежало від деталей зберігання.
Помилка №4: писати методи, які вимагають Equatable, але забувати where T: Equatable.
Зазвичай це виглядає так: ви пишете items.contains(value) і отримуєте помилку компілятора, тому що T не гарантовано порівнюваний. Рішення не в тому, щоб «дотиснути» компілятор, а в тому, щоб чесно додати обмеження: або на розширення, або на метод.
Помилка №5: робити removeAll або contains через ! і з позицією «я впевнений, що тут усе є».
Це інший різновид самообману: Equatable і Optional вирішують різні проблеми. Equatable каже «можна порівнювати», Optional каже «значення може не бути». Якщо у вас порожній стек, це не привід для !. Це привід обрати правильний контракт (Optional/throws/Result) і послідовно його дотримуватися.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ