1. Вступ
Якщо ви тільки починаєте, дуже легко мислити так: «Мені потрібно порівняти два значення — отже, достатньо a > b — і готово». І справді, іноді цього досить. Але щойно у вас з’являється завдання на кшталт «вибрати найкращий елемент за правилом» або «всюди в проєкті вважати, що “краще” означає X», Boolean раптом виявляється надто «плоским».
Проблема в тому, що Boolean відповідає лише на запитання «перший кращий за другий?» — і мовчить у двох важливих випадках: що робити, якщо кращим є другий, і що робити, якщо вони рівні. А порівняння як операція порядку має вміти розрізняти три результати. Інакше ви почнете нагромаджувати каскади if/else, а код виглядатиме як квест «знайди, де тут логіка».
Уявіть, що в нас є дві витрати (ми продовжуємо наш консольний трекер витрат): одна на 500 USDT, інша — теж на 500 USDT. Запитання «перша більша за другу?» дасть false, але це не означає, що перша менша. Вона просто… така сама. І саме це «така сама» нам потрібно навчитися виражати явно.
2. Контракт порівняння: (T, T) -> Int
Коли ми говоримо «функція порівняння», найчастіше маємо на увазі функцію типу:
(T, T) -> Int
Тобто вона приймає два значення одного типу і повертає Int. І цей Int трактується не як «різниця в гривнях» і не як «якась оцінка за 100-бальною шкалою», а значно простіше: важливий знак результату.
Правило таке:
| Результат Int | Зміст |
|---|---|
|
перший менший за другого |
|
рівні |
|
перший більший за другого |
Саме так трактується результат compareTo() у порівнюваних типів, і так само трактується результат Comparator.compare(a, b).
Щоб це краще закріпилося, можна уявити «світлофор порівняння»: від’ємне — «ліворуч/нижче», нуль — «рівно», додатне — «праворуч/вище». Скільки саме там вийшло (-1 або -500) — зазвичай не важливо, якщо тільки ви не робите чогось зовсім екзотичного.
Невелика схема, як це працює, коли ви «обираєте найкращого»:
flowchart TD
A["cmp(a, b)"] --> B{"результат >= 0 ?"}
B -- так --> C["a не гірший за b → обираємо a"]
B -- ні --> D["a гірший за b → обираємо b"]
3. Лямбда порівняння та Comparator<T>
Лямбда порівняння на два параметри
Після лямбд з одним параметром (it) лямбди з двома параметрами виглядають трохи «суворіше»: it уже не пройде — доведеться чесно назвати обидва параметри.
Ось приклад: порівнюємо рядки за довжиною.
fun main() {
val compareByLength: (String, String) -> Int = { a, b ->
a.length.compareTo(b.length)
}
println(compareByLength("go", "kotlin")) // -1 (2 < 6)
println(compareByLength("java", "kotlin")) // -1 (4 < 6)
println(compareByLength("hi", "ok")) // 0 (2 == 2)
}
Зверніть увагу на важливу деталь: ми використовуємо compareTo, а не a.length - b.length. Чому так — поговоримо трохи пізніше, коли дійдемо до типових пасток.
Ще приклад: порівняння чисел «як зазвичай».
fun main() {
val compareInts: (Int, Int) -> Int = { a, b ->
a.compareTo(b)
}
println(compareInts(10, 3)) // 1
println(compareInts(3, 10)) // -1
println(compareInts(7, 7)) // 0
}
Це виглядає майже марно (ніби можна й >), але сенс з’явиться, коли ви почнете передавати це правило в інші функції: «вибери найкращого за таким-то порівнянням».
Comparator<T>: правило порівняння у вигляді об’єкта
Тепер знайомимося з Comparator<T>. Якщо говорити образно, це «коробочка, у якій лежить правило порядку». А з практичного погляду — це об’єкт із методом compare(a, b): Int, який працює за тим самим контрактом знака результату.
У Kotlin ви зазвичай створюєте компаратор так:
import kotlin.Comparator
fun main() {
val lengthComparator: Comparator<String> = Comparator { a, b ->
a.length.compareTo(b.length)
}
println(lengthComparator.compare("a", "bbb")) // -1
println(lengthComparator.compare("go", "hi")) // 0
println(lengthComparator.compare("kotlin", "js")) // 1
}
Тут важливо не заплутатися: ми вже вміємо зберігати лямбду (String, String) -> Int. То навіщо ще Comparator<String>?
Відповідь доволі практична: багато функцій стандартної бібліотеки (і ваші майбутні функції) очікують саме Comparator<T> як «офіційний контейнер правила». Навіть якщо всередині він просто викликає вашу лямбду, це стандартна форма, яку легко впізнати з першого погляду. До того ж у компаратора є зрозуміла назва методу — compare.
При цьому логіка одна й та сама: порівняння двох об’єктів → Int зі знаком.
4. Міні-інструмент: вибрати «найкращий» елемент
Спокуса зараз величезна: «О! Компаратор! Отже, сортування!» Але сортування ми залишаємо в спокої — для цього буде окремий день, і там ви познайомитеся зі стандартними функціями, які роблять усе зручно й красиво.
Сьогодні наше завдання скромніше й водночас корисніше для розуміння: навчитися застосовувати компаратор без магії, «вручну», через звичайний цикл. Тобто ми напишемо функцію: «дайте мені правило порівняння — і я виберу найкращий елемент».
Почнемо з функції для двох значень:
import kotlin.Comparator
fun <T> bestOf(a: T, b: T, cmp: Comparator<T>): T {
return if (cmp.compare(a, b) >= 0) a else b
}
fun main() {
val byLength = Comparator<String> { x, y -> x.length.compareTo(y.length) }
println(bestOf("go", "kotlin", byLength)) // kotlin
println(bestOf("java", "js", byLength)) // java
}
Зміст >= 0 такий: якщо a більше за b або дорівнює йому, ми обираємо a (тобто «a не гірший за b»). Це зручне правило: за рівності результат стабільний і передбачуваний.
Тепер розширимо до списку. Ми продовжуємо наш навчальний CLI-проєкт (умовно «облік витрат»), де витрати лежать у списку. Нехай витрата — це Triple(title, category, amount).
import kotlin.Comparator
fun findBest(
items: List<Triple<String, String, Int>>,
cmp: Comparator<Triple<String, String, Int>>
): Triple<String, String, Int>? {
var best: Triple<String, String, Int>? = null
for (item in items) {
best = if (best == null) item else bestOf(best, item, cmp)
}
return best
}
Зверніть увагу: результат Triple<...>?, тому що список може бути порожнім. Ми чесно відображаємо це в типі (ви вже проходили null-safety, і це якраз той випадок, де null — нормальна частина контракту).
5. Компаратор витрат: порівнюємо за сумою
Тепер найцікавіше: робимо компаратор для витрат. Порівнювати будемо за amount (третім компонентом Triple).
Спочатку — прямолінійний варіант без деконструкції:
import kotlin.Comparator
fun main() {
val byAmount = Comparator<Triple<String, String, Int>> { a, b ->
val amountA = a.third
val amountB = b.third
amountA.compareTo(amountB)
}
val e1 = Triple("coffee", "food", 250)
val e2 = Triple("book", "education", 900)
println(byAmount.compare(e1, e2)) // -1
}
Можна зробити трохи читабельніше, якщо ви звикли до деконструкції Triple:
import kotlin.Comparator
fun main() {
val byAmount = Comparator<Triple<String, String, Int>> { x, y ->
val (_, _, ax) = x
val (_, _, ay) = y
ax.compareTo(ay)
}
println(
byAmount.compare(
Triple("coffee", "food", 250),
Triple("book", "education", 900)
)
) // -1
}
Так, виглядає трохи «багатослівно», зате добре видно, що ми порівнюємо саме суму, а не «щось там усередині».
Тепер застосуємо це до списку й знайдемо найдорожчу витрату:
import kotlin.Comparator
fun main() {
val expenses = listOf(
Triple("coffee", "food", 250),
Triple("book", "education", 900),
Triple("taxi", "transport", 600),
)
val byAmount = Comparator<Triple<String, String, Int>> { a, b ->
a.third.compareTo(b.third)
}
val best = findBest(expenses, byAmount)
println(best) // (book, education, 900)
}
І ось тут має «клацнути»: компаратор — це не «про сортування». Він про єдине правило «що краще», яке можна передавати куди завгодно: у пошук максимуму, у вибір переможця, у порівняння двох кандидатів.
6. Складені правила: тай-брейкер за рівності
Щойно ви навчилися повертати від’ємне/нуль/додатне, з’являється наступне логічне запитання: «А якщо за першим критерієм рівність — що тоді?»
Відповідь проста: вводимо другий критерій, але лише якщо перший дав 0.
Ця ідея дуже поширена: «спочатку порівняй за головним, а якщо рівні — за запасним». У документації Kotlin подібний підхід показують на рядках: спочатку довжина, потім алфавітний порядок.
Зробімо це на наших витратах: спочатку за сумою (більше — краще), а якщо суми однакові — порівняємо за назвою (title) в алфавітному порядку, щоб правило було стабільним.
import kotlin.Comparator
fun main() {
val cmp = Comparator<Triple<String, String, Int>> { a, b ->
val amountCompare = a.third.compareTo(b.third)
if (amountCompare != 0) return@Comparator amountCompare
// тай-брейкер: за назвою
a.first.compareTo(b.first)
}
val e1 = Triple("apple", "food", 500)
val e2 = Triple("banana", "food", 500)
println(cmp.compare(e1, e2)) // < 0, тому що "apple" < "banana"
}
Тут важливо помітити два моменти.
По-перше, ми не «склеюємо все в одну формулу». Ми порівнюємо покроково: спочатку одне, потім інше.
По-друге, у нас з’явилася нова синтаксична конструкція: return@Comparator. Це «локальний return» із лямбди (тобто ми повертаємо значення з самої лямбди, а не з main). Якщо цей синтаксис поки що виглядає незвично — нічого страшного: можна переписати без раннього повернення, просто через if.
import kotlin.Comparator
fun main() {
val cmp = Comparator<Triple<String, String, Int>> { a, b ->
val amountCompare = a.third.compareTo(b.third)
if (amountCompare != 0) {
amountCompare
} else {
a.first.compareTo(b.first)
}
}
println(
cmp.compare(
Triple("apple", "food", 500),
Triple("banana", "food", 500)
)
) // < 0
}
Зміст той самий: за рівності суми ми вмикаємо «тай-брейкер» (правило розв’язання рівності).
7. Чому не варто писати a - b
Коли ви пишете компаратор для Int, рука часто тягнеться до класики:
a - b
Це справді дає від’ємне/нуль/додатне… майже завжди. Але є причина, чому в робочому коді все-таки частіше пишуть a.compareTo(b).
Річ у переповненні Int. Int у Kotlin — 32-бітний. Якщо відняти «дуже велике» від «дуже малого», результат може переповнитися й отримати несподіваний знак. У нашому навчальному проєкті суми витрат не будуть мільярдами (сподіваюся), але звичку краще виробляти правильну: compareTo безпечніше й читабельніше.
Ще один тонкий момент: порівняння має бути стабільним. Це означає, що для однакових входів ви завжди повертаєте 0, а для різних — знак результату не повинен «танцювати» від настрою компілятора й фаз Місяця.
Якщо компаратор сьогодні каже, що a > b, а завтра за тих самих значень каже, що a < b, будь-яка логіка «вибери найкращого» почне поводитися як генератор випадкових чисел — тільки без веселощів і з баг-репортами.
8. Типові помилки під час роботи з (T, T) -> Int та Comparator
Помилка №1: повертають Boolean замість Int.
Дуже поширена звичка: «порівняння = a > b». Але компаратору потрібно розрізняти три випадки, а не два. Якщо ви повертаєте Boolean, ви втрачаєте інформацію про рівність — і будь-яка функція, яка очікує контракт «менше/дорівнює/більше», або стає неможливою, або перетворюється на кашу з додаткових умов.
Помилка №2: плутають зміст знака й намагаються повернути «красиві числа».
Іноді новачок починає думати, що потрібно повернути «різницю» або «оцінку»: 100, 50, -999. Формально це не заборонено, але майже ніколи не потрібно. Хороший компаратор повертає значення, де важливий знак, а не величина. Найчитабельніший варіант — a.compareTo(b) або зрозуміла комбінація кількох compareTo через перевірку на 0.
Помилка №3: пишуть a.third - b.third і не думають про переповнення.
На малих числах це працює, а потім одного разу трапляється дивний випадок, де все «чомусь навпаки». Набагато надійніше писати a.third.compareTo(b.third). Так і безпечніше, і одразу читається як «порівняти».
Помилка №4: забувають обробити рівність і отримують «випадкове правило».
Якщо ви порівнюєте за сумою, а суми рівні, і при цьому повертаєте завжди 1 або завжди -1 «аби хоч щось було», ви створюєте нестабільний порядок. У результаті вибір «найкращого» стає непередбачуваним: за однакових сум правило має повертати 0 або вмикати другий критерій (тай-брейкер), інакше логіка починає «тремтіти».
Помилка №5: роблять порівняння надто складним прямо в місці використання.
Якщо в одному місці коду у вас написана лямбда порівняння на 12 рядків із купою if, читання перетворюється на біль. Зазвичай краще винести компаратор у val з нормальною назвою (наприклад, byAmountThenTitle) або в окрему функцію, щоб місце використання виглядало як «промовиста формула», а не як міні-роман.
Помилка №6: плутають «порівняння» і «вибір переможця».
Компаратор відповідає на запитання «хто менший/більший/рівний», а вибір переможця — це вже рішення: кого повертати, якщо результат >= 0, а кого — якщо < 0. Якщо ви змішуєте ці дві речі в одному місці, компаратор починає містити бізнес-логіку, і потім ви дивуєтеся, чому правило неможливо повторно використати в іншому контексті.
Помилка №7: не помічають, що компаратор — це спільний контракт, і ламають передбачуваність проєкту.
Якщо в одному місці «краще» означає «більша сума», а в іншому — «менша сума» (бо там ви випадково поміняли a і b місцями), проєкт починає поводитися так, ніби його писали дві різні людини, які не розмовляли одна з одною. Тому компаратори варто називати так, щоб із назви було видно напрям: byAmountAscending / byAmountDescending (або хоча б додати коментар поруч).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ