JavaRush /Курсы /Kotlin SELF /Функция сравнения и Comparator: лямбда (T, T) -> Int

Функция сравнения и Comparator: лямбда (T, T) -> Int

Kotlin SELF
21 уровень , 3 лекция
Открыта

1. Введение

Если вы только начинаете, очень легко думать так: «Мне нужно сравнить два значения — значит, достаточно a > b и готово». И правда, иногда достаточно. Но как только у вас появляется задача вроде «выбрать лучший элемент по правилу» или «везде в проекте считать, что “лучше” означает X», Boolean вдруг становится слишком «плоским».

Проблема в том, что Boolean отвечает только на вопрос «первый лучше второго?» и молчит в двух важных случаях: что делать, если второй лучше первого, и что делать, если они равны. А сравнение как операция порядка должно уметь различать три исхода, иначе вы начнёте городить каскады if/else, и код будет выглядеть как квест «найди, где тут логика».

Представьте, что у нас есть две траты (мы продолжаем наш консольный трекер расходов): одна на 500 USDT, другая на 500 USDT тоже. Вопрос «первая больше второй?» даст false, но это не означает, что первая меньше. Она просто… такая же. И вот это «такая же» нам нужно уметь выражать явно.

2. Контракт сравнения: (T, T) -> Int

Когда мы говорим «функция сравнения», чаще всего имеется в виду функция типа:

(T, T) -> Int

То есть: принимает два значения одного типа и возвращает Int. И этот Int трактуется не как «разница в рублях» и не как «какая-то оценка по 100-балльной шкале», а намного проще: важен знак результата.

Правило такое:

Результат Int Смысл
< 0
первый меньше второго
0
равны
> 0
первый больше второго

Именно так трактуется результат 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 (или хотя бы комментарий рядом).

1
Задача
Kotlin SELF, 21 уровень, 3 лекция
Недоступна
Честное сравнение
Честное сравнение
1
Задача
Kotlin SELF, 21 уровень, 3 лекция
Недоступна
Судья по длине
Судья по длине
1
Задача
Kotlin SELF, 21 уровень, 3 лекция
Недоступна
Выбор лидера
Выбор лидера
1
Задача
Kotlin SELF, 21 уровень, 3 лекция
Недоступна
Лучшая трата
Лучшая трата
Комментарии (1)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
kasnil Уровень 54
28 марта 2026
В данном уроке Comparator под капотом является псевдонимом java.util.Comparator. Сам java.util.Comparator является функциональным интерфейсом. А сама конструкция в котлине судя по документации называется: functional interface constructor или в русском переводе книги Kotlin in Action: SAM конструктор.