JavaRush /Курси /Swift SELF /Порівнюваність обʼєктів: Comparable

Порівнюваність обʼєктів: Comparable

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

1. Після рівності постає наступне запитання: який елемент раніше

У минулій лекції ми говорили про рівність. Питання звучало так: «Це одне й те саме значення чи ні?». Але в реальному коді дуже швидко виникає інше завдання: треба зрозуміти, яке значення менше, яке більше, що має стояти раніше, а що — пізніше.

Насправді ви вже робили це багато разів. Коли писали score > 0, age >= 18, x < y, ви користувалися ідеєю порядку. Просто раніше це виглядало як звичайні оператори в умові, а тепер ми подивимося на них як на спільну властивість типів.

let a = 7
let b = 12

print(a == b) // false
print(a < b)  // true
print(a > b)  // false

Тут особливо корисно побачити різницю між рівністю та порядком. a == b запитує: «Це одне й те саме число?». a < b запитує: «Ліве значення менше за праве?». Це не те саме запитання, хоча зовні все виглядає як звичайне: «Ми ж просто щось порівняли».

Так само це працює і з рядками:

let first = "apple"
let second = "banana"

print(first == second) // false
print(first < second)  // true

Тож корисно тримати в голові два окремі шари. Рівність відповідає за однаковість. Порядок відповідає за розташування значень на лінії «менше → більше» або «раніше → пізніше».

2. У цієї здатності є імʼя: Comparable

Тепер можна назвати те, чим ви вже користувалися. У Swift здатність брати участь у порівнянні через <, >, <= і >= має офіційну назву — протокол Comparable.

У стандартній бібліотеці його оголошено так:

public protocol Comparable: Equatable {
    static func < (lhs: Self, rhs: Self) -> Bool
}

На перший погляд запис виглядає трохи технічно, але всередині прихована дуже проста ідея.

Він буквально каже таке: тип, який підтримує Comparable, уміє відповідати на запитання «ліве значення менше за праве?». Для цього Swift викликає спеціальну функцію порівняння <. До неї передаються два значення одного й того самого типу (lhs і rhs), а результатом є логічне значення Bool.

Якщо перекласти це звичайною мовою, виходить знайома картина: коли ви пишете

a < b

Swift просто викликає функцію порівняння для типу цих значень і отримує true або false.

Корисно помітити й іншу деталь в оголошенні протоколу. Comparable успадковується від Equatable. Це означає, що будь-який тип, який має природний порядок, автоматично повинен уміти й порівнюватися на рівність.

Це логічно: якщо ми вміємо сказати, що одне значення менше за інше, ми маємо вміти й відповісти на запитання, чи вони рівні.

Як і в минулій лекції, поки не потрібно занурюватися в синтаксис протоколів. До цієї теми ми ще дійдемо пізніше. Зараз достатньо простої робочої моделі: якщо тип має зрозумілий природний порядок, Swift пов’язує цю здатність із Comparable.

Саме тому числа (Int, Double) і рядки (String) спокійно беруть участь у порівняннях через < і >. У них уже є визначений порядок. Просто раніше ви користувалися цим, не замислюючись про те, як ця здатність формально описана в мові.

3. Які базові типи вже мають природний порядок

Найзрозуміліші приклади — числа. Для Int природний порядок буквально збігається зі шкільним: менше число стоїть раніше, більше — пізніше.

let x = -3
let y = 2

print(x < y) // true
print(y > x) // true

Для Double ідея та сама. Числа з плаваючою комою теж можна впорядковувати як числа.

let p = 0.5
let q = 0.75

print(p < q) // true

Для рядків порядок теж є, але він уже не «числовий», а лексикографічний. Грубо кажучи, Swift порівнює рядки зліва направо.

let a = "apple"
let b = "banana"

print(a < b) // true

Тут одразу з’являється важлива пастка. Рядок "10" — це не число десять. Це рядок із символів "1" і "0". Тому рядкове порівняння і числове порівняння — не одне й те саме.

print("10" < "2") // true

На перший погляд результат здається дивним, але для рядків він логічний. Swift дивиться на перші символи. "1" іде раніше за "2", отже й рядок "10" вважається меншим, ніж "2". Це не помилка мови. Це помилка очікувань, якщо ми забули, що працюємо з рядками, а не з числами.

Саме тому так важливо бачити звʼязок між типом даних і тим, який порядок для нього вважається природним. У Int і Double це числовий порядок. У String — рядковий порядок. У Bool стандартного порядку немає.

4. Де цей порядок уже працює у стандартній бібліотеці

Щойно тип має природний порядок, стандартна бібліотека може будувати на ньому корисні операції. Найпростіші приклади — min() і max() у масиві.

let numbers = [4, 1, 9, 2]

print(numbers.min() as Any) // Optional(1)
print(numbers.max() as Any) // Optional(9)

Ви вже інтуїтивно маєте розуміти, чому тут повертається Optional. У порожнього масиву немає мінімального і максимального елемента. Отже, чесний результат — або значення, або nil.

let empty: [Int] = []

print(empty.min() as Any) // nil
print(empty.max() as Any) // nil

Той самий природний порядок використовується і в сортуванні. У наступній лекції ми розбиратимемо сортування докладно, але вже зараз корисно побачити звʼязок. Масив рядків можна відсортувати «за замовчуванням» саме тому, що String має природний порядок.

let titles = ["Dune", "1984", "Brave New World"]
print(titles.sorted()) // ["1984", "Brave New World", "Dune"]

Отже, Comparable — це не декоративна назва. Це основа для цілого сімейства операцій: знайти мінімум, знайти максимум, відсортувати дані, підтримувати порядок в алгоритмах і, трохи пізніше, робити бінарний пошук.

5. Строгий і нестрогий порядок — це не одне й те саме

Оператори порядку виглядають схоже, але між ними є важлива логічна різниця. Оператори < і > задають строгий порядок. Оператори <= і >= дозволяють ситуацію рівності.

Це видно на простому прикладі:

let x = 10
let y = 10

print(x < y)  // false
print(x <= y) // true

Коли ви запитуєте «чи x менше за y», відповідь false, тому що значення рівні. Але коли ви запитуєте «чи менше або дорівнює», відповідь уже true.

Чому це важливо не лише теоретично? Тому що в алгоритмах і в правилах сортування зазвичай потрібна саме строга відповідь на запитання «хто має стояти раніше». Якщо замість цього дати нестроге правило, ви легко потрапите в ситуацію, де два рівні елементи ніби обидва «підходять стояти раніше» один за одного. Для звичайного if це може бути терпимо. Для сортування і пошуку — уже небезпечно.

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

6. Optional<Int> не має природного порядку за замовчуванням

Тут дуже корисно порівняти цю лекцію з попередньою. У попередній лекції Optional<Int> чудово працював у перевірках рівності. Можна було запитати, чи дорівнює він nil, або чи дорівнюють два опціонали між собою.

З порядком усе вже не так просто. Як порівнювати nil і число? nil менше за нуль? Більше за будь-яке число? Завжди на початку? Завжди в кінці? Для різних завдань відповіді можуть бути різними.

Тому в Optional немає одного універсального природного порядку, який Swift міг би навʼязати всім випадкам.

let a: Int? = 3
let b: Int? = nil

// print(a < b) // так не можна

Правильний підхід — спочатку розпакувати значення, а потім уже порівнювати звичайні Int.

let a: Int? = 3
let b: Int? = 10

if let a, let b {
    print(a < b) // true
} else {
    print("Є nil, потрібно окремо вирішити, що з ним робити")
}

Це дуже корисний контраст. Для рівності Optional можна порівнювати автоматично, тому що модель проста: nil або значення. Для порядку універсальної моделі немає. Тому порядок потребує явного рішення з вашого боку.

7. Порівняння за кількома полями

У реальних завданнях одне поле рідко буває єдиним критерієм. Зазвичай є головний ключ і додатковий. Наприклад, книжки можна порівнювати спочатку за роком, а якщо рік збігся — за назвою. Людей можна порівнювати спочатку за прізвищем, а потім за імʼям.

Поки ми ще не дійшли до struct, зручно скористатися кортежами. Нехай книжка має такий вигляд: (title: String, year: Int).

let a = (title: "Clean Code", year: 2008)
let b = (title: "The Pragmatic Programmer", year: 1999)

Якщо правило таке: раніше та книжка, у якої рік менший, а за однакового року — та, у якої назва лексикографічно менша, то вручну це можна записати так:

func isEarlierBook(
    _ left: (title: String, year: Int),
    _ right: (title: String, year: Int)
) -> Bool {
    if left.year != right.year {
        return left.year < right.year
    }
    return left.title < right.title
}

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

Перевіримо:

let b1 = (title: "A", year: 2020)
let b2 = (title: "B", year: 2020)

print(isEarlierBook(b1, b2)) // true
print(isEarlierBook(b2, b1)) // false

А тепер — приємний трюк. Оскільки Int і String уже мають природний порядок, можна порівнювати не самі книжки напряму, а кортежі ключів.

func isEarlierBook2(
    _ left: (title: String, year: Int),
    _ right: (title: String, year: Int)
) -> Bool {
    (left.year, left.title) < (right.year, right.title)
}

Це працює, тому що кортеж порівнюється поелементно зліва направо. Спочатку порівнюються роки. Якщо вони рівні, порівнюються назви. І знову видно головне: все будується на вже знайомих базових типах. Int має природний порядок. String має природний порядок. Отже, і комбінація цих ключів може використовувати той самий принцип.

Логіку такого порівняння зручно уявляти як маленьке дерево рішень:

flowchart TD
    A[Порівнюємо дві книжки] --> B{Роки однакові?}
    B -- ні --> C[Порівнюємо рік]
    B -- так --> D[Порівнюємо назву]

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

8. Мініприклад: вибрати найранішу книжку без сортування

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

Нехай у нас є список книжок у вигляді кортежів. Ми хочемо знайти найранішу книжку за правилом «менший рік, а за рівного року — менша назва».

var books: [(title: String, year: Int)] = [
    (title: "Clean Code", year: 2008),
    (title: "The Pragmatic Programmer", year: 1999),
    (title: "Refactoring", year: 1999),
]

Зробімо функцію, яка поверне або найкращу книжку, або nil, якщо список порожній:

func earliestBook(
    in books: [(title: String, year: Int)]
) -> (title: String, year: Int)? {
    guard var best = books.first else { return nil }

    for book in books.dropFirst() {
        if (book.year, book.title) < (best.year, best.title) {
            best = book
        }
    }

    return best
}

Перевіримо:

let best = earliestBook(in: books)
print(best as Any) // Optional((title: "Refactoring", year: 1999))

Тут уже видно, навіщо нам потрібен природний порядок. Ми не просто вміємо сказати, що два значення рівні або нерівні. Ми вміємо однозначно вибрати, яке з них «йде раніше». А це вже основа для сортування, пошуку мінімуму, вибору найкращого варіанта і, трохи пізніше, бінарного пошуку.

9. Типові помилки

Помилка №1: плутати == і <, ніби це одне й те саме «порівняння».
Коли ви пишете умови, важливо промовляти запитання, на яке відповідає оператор. == — це «це одне й те саме?», а < — «хто раніше/менше?». Якщо в голові одне запитання, а оператор інший, код виглядатиме правдоподібно, але робитиме не те. Особливо часто це проявляється у випадках нічиєї, коли потрібен тай-брейк.

Помилка №2: використовувати <= там, де потрібен строгий порядок.
У завданнях, де ви визначаєте «хто йде раніше», <= майже завжди погіршує ситуацію: два різні обʼєкти можуть виявитися «раніше або рівні», і ви втратите однозначність. На маленьких прикладах це може не проявлятися, але в алгоритмах призводить до дивних результатів і багів, які важко знайти.

Помилка №3: очікувати від рядків числового порядку.
Сортування й порівняння рядків — лексикографічне. Тому "10" < "2" може виявитися true, і це не помилка Swift, а помилка очікувань. Якщо ви зберігаєте числа як рядки, то для числового порядку спочатку потрібно перетворити їх у Int, а вже потім порівнювати.

Помилка №4: намагатися порівнювати Optional через < «якось».
У Optional порядок не заданий за замовчуванням, і це правильна строгість мови. Якщо ви намагаєтеся порівнювати Int?, розпакуйте значення і явно визначте, що робити з nil: пропускати, вважати «найменшим», вважати «найбільшим» або взагалі припиняти операцію. Автоматична магія тут лише зашкодить.

Помилка №5: робити порівняння за кількома ключами без тай-брейку.
Типовий баг: ви порівняли лише за головним ключем, наприклад за роком, а якщо роки рівні — повертаєте false і думаєте, що все нормально. У результаті у вас зʼявляються значення, які «не менші й не більші» одне за одне, але при цьому не рівні. Для деяких сценаріїв це терпимо, але частіше ламає очікування. Тай-брейк, тобто другий ключ, — не розкіш, а спосіб зробити порядок повноцінним і передбачуваним.

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