JavaRush /Курсы /Swift SELF /Generic‑методы с ограничениями: where T: Equatable

Generic‑методы с ограничениями: 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, чтобы метод появился только в нужных случаях.

Есть два популярных способа.

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
extension Stack where T: Equatable { ... }
Когда методов несколько, и вы хотите их «сгруппировать»
Constrained method
func contains(...) -> Bool where T: Equatable
Когда метод один, и хочется держать его рядом с базовым 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) и следовать ему последовательно.

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