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
Щоб вам було легше впізнавати патерни на око, а не страждати над кожною сигнатурою, як над ребусом, корисно мати мінітаблицю. Тут немає нічого нового за змістом — лише «як це зазвичай формулюють».
| Нам потрібно… | Зазвичай це виглядає так |
|---|---|
| Тип підтримує порівняння | |
| Тип можна класти в Set / ключ Dictionary | |
| Елементи двох Sequence збігаються | |
| Елемент можна порівнювати через == | |
| Два параметри типу мають бути одним типом | |
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 умов в один рядок. Тоді функція стає схожою на оголошення в договорі оренди: формально все правильно, але ніхто не хоче це читати. Переноси рядків, осмислені імена параметрів типу й мінімально достатні вимоги — три речі, які рятують від цього ефекту.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ