JavaRush /Курси /Swift SELF /Узагальнені методи з обмеженнями: where T: Equatable

Узагальнені методи з обмеженнями: where T: Equatable

Swift SELF
Рівень 44 , Лекція 2
Відкрита

1. Навіщо потрібні обмеження для методів

Коли ви починаєте писати узагальнений контейнер, дуже хочеться одразу зробити все «розумно»: додати пошук, видалення за значенням і перевірку «чи є такий елемент». Але тут постає важливе питання: чи можемо ми порівнювати елементи? Адже операція «видалити елемент X» насправді означає «знайти елементи, рівні X», тобто використати ==.

І тут новачки зазвичай ідуть одним із двох шляхів:

  1. або ж «ламають» універсальність і оголошують Stack<T: Equatable> (тоді стек працює лише з типами, що підтримують Equatable),
  2. або ж починають винаходити обхідні шляхи: порівнювати через 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 відповідає протоколу».

Практично це означає: ви можете обрати стиль, який краще читається, а не той, до якого вас змушують обставини.

Щоб не плутатися, ось коротка мінітаблиця:

Підхід Як виглядає Коли зручніше
Обмежене розширення
extension Stack where T: Equatable { ... }
Коли методів кілька і ви хочете їх згрупувати
Метод з обмеженням
func contains(...) -> Bool where T: Equatable
Коли метод один і хочеться тримати його поруч із базовим 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) і послідовно його дотримуватися.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ