1. Введение
Когда вы сортируете список чисел, всё просто: число и есть значение, по которому сравниваем. Но реальная жизнь быстро подсовывает данные сложнее: «категория–сумма», «имя–баллы», «товар–цена», «покупка–стоимость–комментарий». Элементы уже не “сами просят” сравнить их напрямую, и нам нужно решить: по чему именно сортируем.
Вот это «по чему» и называют ключом сортировки: мы берём элемент, вычисляем из него сравнимое значение (ключ) и сортируем по ключу. Kotlin как раз даёт для этого очень удобные инструменты: sortedBy и sortedByDescending.
Представьте, что у вас коробка с посылками, а на каждой посылке есть наклейка “вес”. Сортировать можно коробки (элементы списка), но сравнивать удобнее по наклейке (ключу). Если наклейка — вес, сортируем по весу; если наклейка — город, сортируем по городу.
2. Сортировка по ключу через sortedBy и sortedByDescending
Начнём с самого частого сценария: у нас есть список, элементы которого не обязательно «сравнимы сами по себе», зато из каждого элемента мы можем достать что-то сравнимое (число, строку, длину строки). Kotlin-операция sortedBy { selector } как раз принимает функцию‑селектор ключа и возвращает новый список, отсортированный по возрастанию ключа.
Сортировка по возрастанию: sortedBy {...}
Сначала — мини‑пример без нашего проекта, просто чтобы увидеть механику. Мы сортируем строки по длине (ключ — it.length):
fun main() {
val words = listOf("bbb", "a", "cc")
val sorted = words.sortedBy { it.length }
println(sorted) // [a, cc, bbb]
}
Теперь перенесём это на практическое приложение. Напомню модель данных нашего текущего консольного трекера расходов (пока без классов): расход — это Triple(category, amount, note). Категория и заметка — строки, сумма — Int.
Сортировка всех расходов по сумме (по возрастанию) выглядит так:
fun main() {
val expenses = listOf(
Triple("food", 450, "coffee"),
Triple("taxi", 1200, "airport"),
Triple("food", 150, "snack")
)
val sortedByAmount = expenses.sortedBy { it.second }
println(sortedByAmount)
// [(food, 150, snack), (food, 450, coffee), (taxi, 1200, airport)]
}
Здесь важно «прочитать» код правильно: мы сортируем сами Triple, но сравниваем их по ключу it.second, то есть по сумме.
Сортировка по убыванию: sortedByDescending {...}
Чуть более «прикладной» вариант — отсортировать по убыванию: в отчётах мы почти всегда хотим видеть сначала самое большое. Для этого есть sortedByDescending { selector }. Это тот же принцип ключа сортировки, просто порядок обратный.
Например, хотим вывести самые дорогие покупки сверху:
fun main() {
val expenses = listOf(
Triple("food", 450, "coffee"),
Triple("taxi", 1200, "airport"),
Triple("books", 700, "kotlin book")
)
val sorted = expenses.sortedByDescending { it.second }
println(sorted)
// [(taxi, 1200, airport), (books, 700, kotlin book), (food, 450, coffee)]
}
И вот здесь уже просится следующий шаг: «а можно не весь список, а только первые N?». Можно — и это будет наш классический паттерн top‑N.
3. Top‑N: sortedByDescending {...}.take(n) и почему take без сортировки не топ
Когда в требованиях пишут «покажи топ‑3 категорий», почти всегда имеется в виду: отсортируй по метрике и возьми первые три. Kotlin позволяет выразить это в одну цепочку: sortedByDescending(...).take(n).
Важно не перепутать: take(n) не ищет лучшие элементы, он просто берёт первые n элементов текущего порядка. Поэтому «взять top‑N» без сортировки — это буквально «взять первые N как попало».
Сначала простой пример на числах:
fun main() {
val scores = listOf(10, 70, 50, 20, 90)
val top3 = scores.sortedDescending().take(3)
println(top3) // [90, 70, 50]
}
Теперь сделаем top‑2 самых дорогих расходов в нашем формате Triple:
fun main() {
val expenses = listOf(
Triple("food", 450, "coffee"),
Triple("taxi", 1200, "airport"),
Triple("books", 700, "kotlin book"),
Triple("food", 150, "snack")
)
val top2 = expenses
.sortedByDescending { it.second }
.take(2)
println(top2)
// [(taxi, 1200, airport), (books, 700, kotlin book)]
}
Обратите внимание на приятный момент: если элементов меньше, чем n, take(n) просто вернёт «сколько есть», без падений и драм.
4. Несколько критериев: sortedWith и compareBy
Одна из самых частых «бытовых» задач: сортировать по нескольким правилам. Например, хотим отсортировать список пар (category, total) так, чтобы сначала шли категории по имени (для красивого вывода), а если имя одинаковое, то вторым ключом была сумма или возраст.
Сортировка по нескольким критериям — это как сортировка документов в папке: сначала по “Фамилия”, а внутри одинаковых фамилий — по “Имя”, а внутри одинаковых имён — по “Дата”. Один критерий перестаёт быть достаточным.
В Kotlin для случаев «нужно задать явное правило сравнения» есть sortedWith(comparator), а чтобы компаратор не писать руками, есть фабрика компараторов compareBy(...). Стандартный путь выглядит так: sortedWith(compareBy {...}).
sortedWith(comparator): сортируем по явному правилу
sortedWith(...) принимает объект Comparator<T> и возвращает новый отсортированный список. Это полезно, когда:
- элемент не Comparable,
- у вас несколько критериев,
- у вас хитрая логика сравнения (например, «сначала VIP, потом по сумме»).
Сначала пример попроще: сортируем строки по длине через компаратор:
fun main() {
val words = listOf("aaa", "bb", "c")
val sorted = words.sortedWith(compareBy { it.length })
println(sorted) // [c, bb, aaa]
}
Технически это похоже на sortedBy { it.length }, но идея другая: мы явно говорим «вот правило сравнения». Это особенно удобно, когда критериев несколько.
compareBy(...): компаратор по одному ключу
compareBy — это способ сказать: «построй мне компаратор, который сравнивает элементы по таким-то ключам». Мы сейчас не углубляемся в устройство компаратора, а используем его как готовый инструмент.
Например, сортируем наши расходы (Triple) по категории (по алфавиту):
fun main() {
val expenses = listOf(
Triple("taxi", 1200, "airport"),
Triple("food", 450, "coffee"),
Triple("books", 700, "kotlin book")
)
val sorted = expenses.sortedWith(compareBy { it.first })
println(sorted)
// [(books, 700, kotlin book), (food, 450, coffee), (taxi, 1200, airport)]
}
compareBy({ ... }, { ... }): компаратор по нескольким ключам
compareBy умеет принимать несколько селекторов, и тогда сортировка идёт так: сначала по первому ключу, при равенстве — по второму, и так далее.
Сделаем пример на парах (category, total) и отсортируем сначала по названию категории, а затем по сумме (по возрастанию):
fun main() {
val totals = listOf(
"food" to 200,
"taxi" to 250,
"food" to 150
)
val sorted = totals.sortedWith(
compareBy<Pair<String, Int>>({ it.first }, { it.second })
)
println(sorted) // [(food, 150), (food, 200), (taxi, 250)]
}
Здесь тип Pair<String, Int> я указал явно, чтобы компилятору было проще (и читателю тоже). Если вы пока не уверены в выводе типов — не стесняйтесь писать типы: это не «слабость», это страховка от загадочных ошибок, которые в 23:00 кажутся мистикой.
5. Мини‑интеграция: отчёт top‑N категорий по сумме
Теперь соберём мини‑сценарий, который очень похож на реальную фичу в трекере расходов: у нас есть Map<String, Int> с итогами по категориям, и мы хотим вывести top‑N категорий по сумме.
Мы не используем groupBy (это будет позже), поэтому представим, что totalsByCategory уже посчитан ранее нашим кодом (например, в обработчике команды total). Дальше — чистая механика сортировки.
Схема пайплайна:
Map<String, Int>
→ entries
→ List<Pair<String, Int>>
→ sortedByDescending { сумма }
→ take(n)
→ печать
Код (разобьём на маленькие шаги, чтобы читалось человечески):
fun main() {
val totalsByCategory = mapOf(
"food" to 1200,
"taxi" to 2500,
"books" to 900
)
val topN = 2
val pairs = totalsByCategory.entries.map { it.key to it.value }
val top = pairs.sortedByDescending { it.second }.take(topN)
println(top) // [(taxi, 2500), (food, 1200)]
}
Здесь две вещи особенно важны.
Во-первых, мы сортируем не Map напрямую, а его элементы, превращённые в список пар. Это нормальная практика: сортировка — операция про последовательность (список), а Map сам по себе “про доступ по ключу”.
Во-вторых, top‑N делается строго в таком порядке: сначала сортируем, потом берём первые n. Если поменять местами — получим “первые N по внутреннему порядку Map”, что не является топом примерно никогда (кроме случайных совпадений, которые потом ломают доверие к программе).
6. Как выбирать инструмент и читать sortedBy
В какой-то момент студенты начинают спрашивать: «А что лучше — sortedBy или sortedWith(compareBy(...))?». Вопрос нормальный: оба варианта часто выглядят похоже.
Ниже — маленькая таблица, чтобы закрепить выбор:
| Задача | Что писать | Почему так |
|---|---|---|
| Один простой ключ, по возрастанию | |
Самый короткий и читаемый вариант. |
| Один простой ключ, по убыванию | |
Тот же принцип, просто “сначала большое”. |
| Несколько критериев | |
Явно видно набор критериев и порядок их применения. |
| Нестандартное правило сравнения | |
Когда нужно вручную описать логику сравнения. |
И маленькое «человеческое правило»: если у вас один ключ — берите sortedBy. Если критериев несколько — чаще всего sortedWith(compareBy(...)) читается лучше и масштабируется спокойнее.
Перед финалом давайте прямо проговорим типичную путаницу. В sortedBy {...} внутри лямбды it — это элемент списка, а результат лямбды — ключ сортировки.
Пример, где люди часто путаются (потому что Pair и так короткий):
fun main() {
val totals = listOf("food" to 1200, "taxi" to 2500)
val sorted = totals.sortedBy { it.second }
println(sorted) // [(food, 1200), (taxi, 2500)]
}
it — это пара (String, Int). it.second — это Int, ключ сортировки. Мы сортируем пары, а сравниваем числа.
Если вы поймали себя на мысли «почему я сортирую Int, если у меня список пар?» — поздравляю: вы как раз заметили ключ сортировки, значит мозг всё делает правильно.
7. Типичные ошибки
Ошибка №1: использовать take(n) без сортировки и называть это “топ‑N”.
take(n) — это не “выбери лучшие”, а “отрежь первые n”. Если исходный порядок — порядок добавления, то вы получите «первые добавленные», а не «самые большие». Исправление почти всегда одно: сначала сортировка по метрике, потом take.
Ошибка №2: сортировать по неправильному ключу и перепутать first/second/third.
Когда данные хранятся в Pair/Triple, очень легко сделать сортировку не по тому полю и несколько минут смотреть на результат с ощущением, что программа “издевается”. Спасает привычка: перед сортировкой называть смысл полей в голове (“first=категория, second=сумма”), а в коде иногда временно печатать пару элементов для проверки.
Ошибка №3: пытаться отсортировать Map “как есть”, а потом удивляться.
Map — это не список; в нём основной смысл — доступ по ключу, а не порядок. Для сортировки почти всегда нужно сделать entries.map { ... }, получить список, и уже его сортировать. Это не костыль, а нормальная трансформация контейнера под задачу.
Ошибка №4: пытаться запихнуть несколько критериев в одну “монструозную” лямбду.
Когда критериев два-три, новички иногда пишут огромный sortedWith { a, b -> ... }, где сравнения перемешаны, скобки живут своей жизнью, и всё это напоминает заклинание. В большинстве случаев читаемее и безопаснее использовать sortedWith(compareBy({ ... }, { ... })), где прямо видно, в каком порядке применяются ключи.
Ошибка №5: “потерять” результат сортировки.
Сортировки вроде sortedBy и sortedWith возвращают новый список, а исходный не меняют. Если вы написали expenses.sortedBy { it.second } и дальше печатаете expenses, то ничего “магически” не произошло. Лечится просто: сохраняйте в val или продолжайте цепочку сразу (например, sortedByDescending { ... }.take(3)), как мы делали выше.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ