JavaRush /Курси /Swift SELF /where‑умови в generics

where‑умови в generics

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

1. Навіщо потрібен where у generics

Якщо ви вперше дивитеся на синтаксис generics у Swift, здається, ніби все вже вирішено: обмеження пишуться прямо в кутових дужках, і на цьому історія закінчується. Але щойно в сигнатурі зʼявляється більше одного типу, більше одного обмеження або, що найпідступніше, звʼязок між типами, кутові дужки починають нагадувати валізу, у яку ви намагаєтеся запхати ще одну футболку, бо «ну там же ще трохи місця є».

where у Swift — це спосіб винести вимоги до типів в окремий блок наприкінці сигнатури. Це не нова магія, а той самий контракт, тільки записаний так, щоб його було легше читати й підтримувати. У стандартній бібліотеці й у реальних проєктах ви постійно натраплятимете на функції, де значна частина сенсу живе саме в where.

Зручно мислити так: generic-параметри кажуть, які абстрактні типи беруть участь, а where — за яких умов функція взагалі має сенс.

Обмеження у <...> і обмеження у where — два еквівалентні стилі

Коли обмеження одне й воно просте, писати його в кутових дужках цілком нормально. Але важливо побачити, що це лише два рівноправні стилі, і ви можете читати будь-який із них.

Ось класичний приклад: максимум із двох значень. Прямий варіант:

func maxOf<T: Comparable>(_ a: T, _ b: T) -> T {
    a > b ? a : b
}

print(maxOf(10, 3))          // 10
print(maxOf("b", "a"))       // b

А ось той самий зміст, тільки обмеження винесене в where:

func maxOf<T>(_ a: T, _ b: T) -> T where T: Comparable {
    a > b ? a : b
}

print(maxOf(7, 9))           // 9

Чому це може бути зручніше? Тому що іноді ви хочете, щоб форма функції читалася цілісно: вхід → вихід, а вимоги були дописані окремо. Особливо це помітно на складніших сигнатурах.

Якщо ви колись ловили себе на думці «я розумію тіло функції, але сигнатуру читаю як юридичний документ», то where — один зі способів зменшити цю «юридичність».

2. Кілька вимог: чому where часто читається легше

Щойно вимог стає дві або три, запис усередині <...> починає виглядати важкувато. У Swift є композиція протоколів P & Q, але навіть із нею іноді хочеться рознести умови акуратніше.

Зробімо функцію, яка видаляє дублікати й сортує результат. Тут потрібні дві властивості: тип має вміти жити в Set (отже, бути Hashable) і вміти сортуватися (отже, бути Comparable).

Так можна написати коротко, але щільно:

func uniqueSorted<T: Hashable & Comparable>(_ items: [T]) -> [T] {
    Array(Set(items)).sorted()
}

print(uniqueSorted([3, 1, 3, 2]))    // [1, 2, 3]

А так — із where:

func uniqueSorted<T>(_ items: [T]) -> [T]
where T: Hashable, T: Comparable
{
    Array(Set(items)).sorted()
}

print(uniqueSorted(["b", "a", "b"])) // ["a", "b"]

Обидва варіанти коректні. Але другий іноді виграє в читабельності, тому що основна частина сигнатури (func-> …) виглядає простіше, а вимоги можна читати окремим блоком — як передумови.

До речі, зверніть увагу: у where умови розділяються комами, і це виглядає майже як список правил.

3. where як мова звʼязків між типами

Найсильніша сторона where — не в тому, що він уміє писати T: Comparable іншим способом. Найсильніша сторона — у тому, що він уміє виражати відношення.

І тут ми підходимо до важливої речі: same-type requirement (вимога збігу типів). Вона виглядає як ==, але це не порівняння значень, а вимога до типів на етапі компіляції.

У «справжньому» Swift-коді такі вимоги трапляються постійно. Наприклад, в обговореннях специфікації Swift ви можете побачити where-сигнатури, де «порівнюються» типи й навіть асоційовані типи, тому що інакше контракт просто неможливо виразити.

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

func concat<A, B>(_ a: [A], _ b: [B]) -> [A]
where A == B
{
    a + b
}

let x = concat([1, 2], [3, 4])
print(x)      // [1, 2, 3, 4]

З погляду початківця це виглядає як «ну… навіщо нам два типи, якщо вони мають бути однаковими?». Чудове запитання. Іноді це справді зайве, і ви б написали простіше:

func concat<T>(_ a: [T], _ b: [T]) -> [T] {
    a + b
}

Але A == B стає корисним, коли за змістом у вас є два різні джерела типів, і ви хочете звʼязати їх правилом. Найчастіше це спливає не на масивах, а на протоколах з асоційованими типами на кшталт Sequence.

T == U і a == b: однакові символи, різна реальність

На цьому місці зазвичай виникає типова плутанина: студент бачить T == U і думає, що це перевірка. Насправді це не перевірка, а заборона на некоректні виклики.

Погляньмо на приклад: функція, яка приймає два значення потенційно різних типів і намагається їх порівняти. Ми хочемо дозволити це лише тоді, коли типи збігаються і цей тип можна порівнювати (Equatable).

func isEqual<T, U>(_ a: T, _ b: U) -> Bool
where T == U, T: Equatable
{
    a == b
}

print(isEqual(10, 10))       // true
print(isEqual("a", "b"))     // false

Ключовий сенс тут у тому, що функцію взагалі не вдасться викликати, якщо типи різні. Тобто ось це не компілюється — і це добре:

// let bad = isEqual(10, "10")   // помилка компіляції (і це нас рятує)

І тепер найважливіше:

T == U — умова на рівні типів; її компілятор застосовує ще до запуску програми.
a == b — порівняння значень; воно відбувається під час виконання.

Якщо хочеться аналогію, то T == U — це як «прохід тільки за паспортом», а a == b — як «порівняти обличчя на двох фотографіях». Символ однаковий, але сенс і момент перевірки різні.

4. where + Sequence: повʼязуємо асоційовані типи

З Array усе зрозуміло: тип елемента називається Element, і ми звикли до [Int], [String]. Але коли ви працюєте з протоколом Sequence, його елементи — це S.Element, тобто асоційований тип.

І тут where стає головним способом сказати: «ось ці дві послідовності мають містити однакові елементи».

Напишімо функцію, яка перевіряє, чи можна безпечно порівняти перші елементи двох послідовностей на рівність. Нам потрібні три умови:

  • обидві вхідні штуки — Sequence,
  • елементи в них одного типу,
  • цей тип можна порівнювати через ==, тобто він Equatable.
func firstElementsEqual<S1, S2>(_ a: S1, _ b: S2) -> Bool
where S1: Sequence, S2: Sequence,
      S1.Element == S2.Element,
      S1.Element: Equatable
{
    guard let firstA = a.first, let firstB = b.first else { return false }
    return firstA == firstB
}

print(firstElementsEqual([1, 2, 3], [1, 9]))       // true
print(firstElementsEqual(["a", "b"], ["z"]))       // false

Тут S1.Element == S2.Element — це і є той самий «клей», який звʼязує типи. Без where подібна сигнатура або стала б нечитабельною, або перетворилася б на кашу з Any (а ми цього не любимо, та й компілятор теж).

Якщо вам здається, що це вже складно, ви абсолютно нормальна людина. У generics майже завжди найскладнішим є саме момент, коли зʼявляються відношення між типами, а не просто «T має бути Comparable».

5. Стиль: як писати where, щоб було читабельно

where — дуже потужний інструмент. А все потужне в програмуванні має одну особливість: ним легко переборщити. Тому тут важливі не лише правила синтаксису, а й стиль.

Імена параметрів типу — частина читабельності

Коли ви пишете T, це нормально для простих функцій. Але коли зʼявляються S1, S2, Element, Key, Value, код читається краще, бо в назвах уже видно сенс.

Порівняйте:

func join<T>(_ a: T, _ b: T) -> String where T: CustomStringConvertible {
    "\(a), \(b)"
}

і

func join<PairItem>(_ a: PairItem, _ b: PairItem) -> String
where PairItem: CustomStringConvertible
{
    "\(a), \(b)"
}

Друга версія трохи багатослівніша, але іноді вона справді рятує голову, особливо якщо всередині файла вже є інші T, U і V.

Краще кілька рядків where, ніж один камінь в один рядок

Swift дозволяє писати where в один рядок, але людина, яка це читатиме, можливо, не така вже й оптимістична.

Нормальний стиль — переносити умови, коли їх багато. Так роблять і в прикладах з обговорень мови: where-клауза часто перетворюється на акуратний блок вимог.

6. Приклад із навчального CLI-застосунку LibraryCLI

Протягом курсу ми поступово будуємо консольний застосунок (умовно назвімо його LibraryCLI), який працює зі сутностями на кшталт книг, ID, тегів, команд і результатів. У таких проєктах дуже швидко зʼявляється шар «утиліт»: форматування списків, обʼєднання колекцій, перевірка дублікатів, акуратні порівняння.

І ось тут generics із where дають приємний ефект: ви пишете одну функцію для різних типів, але не втрачаєте типобезпеку.

Форматуємо список чого завгодно, що вміє описувати себе

Нам часто потрібно красиво вивести список: список ID книжок, список авторів, список помилок у звіті. Друкувати map { "\($0)" } щоразу можна, але це як носити воду в руках, коли поруч стоїть кухоль.

Зробімо утиліту:

func joinLines<S>(_ items: S) -> String
where S: Sequence, S.Element: CustomStringConvertible
{
    items.map { $0.description }.joined(separator: "\n")
}

print(joinLines([1, 2, 3]))
// 1
// 2
// 3

Зверніть увагу, наскільки чесний контракт:

  • нам потрібна послідовність (Sequence),
  • елементи мають уміти перетворюватися на рядок (CustomStringConvertible),
  • і все.

Ми не вимагаємо Hashable, не вимагаємо Comparable, не вимагаємо нічого зайвого. Це і є мінімально достатній контракт, тільки вже у формі where.

«Зчіплюємо» два списки, але лише якщо елементи збігаються за типом

У CLI-командах іноді буває так: у нас є результати з двох джерел, і ми хочемо їх обʼєднати. Припустімо, одне джерело дало знайдені книжки, інше — рекомендовані. Ми хочемо скласти масиви, але лише якщо там один і той самий тип елементів.

func merged<A, B>(_ a: [A], _ b: [B]) -> [A]
where A == B
{
    a + b
}

let all = merged(["Swift"], ["Generics"])
print(all)    // ["Swift", "Generics"]

Це маленький приклад, але він показує ідею: where A == B — це не примха, а спосіб змусити сигнатуру говорити правду.

7. Міні-шпаргалка по where

Щоб вам було легше впізнавати патерни на око, а не страждати над кожною сигнатурою, як над ребусом, корисно мати мінітаблицю. Тут немає нічого нового за змістом — лише «як це зазвичай формулюють».

Нам потрібно… Зазвичай це виглядає так
Тип підтримує порівняння
where T: Comparable
Тип можна класти в Set / ключ Dictionary
where T: Hashable
Елементи двох Sequence збігаються
where S1.Element == S2.Element
Елемент можна порівнювати через ==
where S.Element: Equatable
Два параметри типу мають бути одним типом
where T == U

8. Типові помилки під час роботи з where

Помилка №1: сприймати where як «щось необовʼязкове для краси».
Якщо ви використовуєте generics серйозніше, ніж у кількох навчальних прикладах, where стає не прикрасою, а основним способом виразити контракт. Без нього ви або обмежитеся примітивними T: Comparable, або почнете стирати типи через Any, а це майже завжди погіршує код.

Помилка №2: плутати T == U і a == b.
T == U — це вимога компілятора щодо збігу типів, і вона унеможливлює некоректні виклики. a == b — це порівняння значень під час виконання. Коли студент намагається перевірити типи через if T == U (або чекає, що where спрацює як if), він потрапляє в концептуальну пастку: where не виконується, він обмежує застосовність.

Помилка №3: писати зайві constraints «про всяк випадок».
Дуже часта історія: «раптом знадобиться сортування» — додали Comparable, «а раптом буде Set» — додали Hashable. Через місяць ви дивитеся на сигнатуру й не розумієте, навіщо там половина вимог. Контракт має відображати рівно те, що робить функція, інакше це вже не контракт, а ворожіння на кавовій гущі.

Помилка №4: змішувати обмеження в <...> і в where без причини.
Технічно Swift дозволяє частину вимог записати в <T: ...>, а частину — в where. Але частіше це погіршує читабельність: читачеві доводиться шукати другу половину правди. Зазвичай краще вибрати один стиль для конкретної функції: просте обмеження — в <...>, складне — в where.

Помилка №5: перетворювати where на простирадло, яке неможливо прочитати.
where дозволяє багато, і це чудово… аж до моменту, поки ви не написали 8 умов в один рядок. Тоді функція стає схожою на оголошення в договорі оренди: формально все правильно, але ніхто не хоче це читати. Переноси рядків, осмислені імена параметрів типу й мінімально достатні вимоги — три речі, які рятують від цього ефекту.

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