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. Составные правила: tie-breaker при равенстве
Как только вы научились возвращать отрицательное/нулевое/положительное, появляется следующий логичный вопрос: «А если по первому критерию равны, что делать?»
Ответ: вводим второй критерий, но только если первый дал 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
// tie-breaker: по названию
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 (или хотя бы комментарий рядом).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ