JavaRush /Курсы /Kotlin SELF /Nullable в сортировке: дефолты, разбиение и понятное прав...

Nullable в сортировке: дефолты, разбиение и понятное правило для null

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

1. Введение

Когда вы впервые сталкиваетесь с null в данных, кажется: «Ну null — и null, я же умею писать ?:». Но сортировка — это не просто «обработать каждый элемент по отдельности». Сортировка требует сравнивать элементы попарно, а значит, нужен ответ на вопрос: если один элемент null, а другой не null, кто из них «больше» и кто «меньше»? У null по смыслу нет естественного места в порядке, поэтому это решение всегда немного «политическое»: вы сами договариваетесь, где null должен оказаться — в начале или в конце.

В Kotlin сортировки строятся вокруг идеи сравнимости и компараторов: либо естественный порядок (Comparable), либо пользовательский порядок через Comparator (в том числе удобный compareBy). Если ваш ключ сортировки может быть null, то вы обязаны сделать правило явным. Да, иногда стандартная библиотека «как-то» умеет сравнивать nullable-ключи, но проблема не только в компиляторе — проблема в читаемости. Через неделю вы сами не вспомните, почему null оказался в начале списка, а не в конце.

Давайте привяжем это к нашему практическому консольному приложению (условно назовём его BudgetBuddy): мы храним расходы как пары "категория" to сумма. И представим, что иногда сумма неизвестна (null) — например, вы решили разрешить «черновики» расходов: категорию уже ввели, а сумму — позже.

2. Модель данных: расходы с неизвестной суммой

Когда мы обсуждаем null, важно не превращать его в абстрактную философию. Пусть null означает что-то конкретное. В нашем приложении будем считать, что null — это неизвестная сумма. Это не «ноль», а именно «данных нет». И это принципиально: ноль — число, а null — отсутствие числа.

Вот маленький пример данных, на которых удобно тренироваться:

fun main() {
    val expenses: List<Pair<String, Int?>> = listOf(
        "food" to 250,
        "taxi" to null,
        "books" to 90,
        "food" to null
    )

    println(expenses)
    // [(food, 250), (taxi, null), (books, 90), (food, null)]
}

Здесь видно, что null встречается не как экзотика, а как обычный участник коллекции. По определению nullable-типов Kotlin заставляет нас явно признать, что значение может отсутствовать, и это хорошо: язык буквально не даёт «забыть» про дырку в данных.

Теперь представим, что мы хотим сделать отчёт: вывести категории, отсортированные по сумме (например, чтобы потом взять top‑N). Но суммы местами null. Куда их девать?

3. Стратегия 1: дефолт через ?:

Самый популярный подход у новичков — это «давайте подставим что-нибудь вместо null и поедем дальше». Это действительно рабочая стратегия, если вы аккуратно выбрали замену. Elvis-оператор ?: как раз для этого и создан: «если слева null, возьми справа».

null в конце при сортировке по возрастанию

Если мы сортируем по возрастанию и хотим, чтобы неизвестные значения ушли в конец, логика простая: при null подставляем очень большое число, чтобы оно оказалось «самым последним».

fun main() {
    val totals: List<Pair<String, Int?>> = listOf(
        "food" to 250,
        "taxi" to null,
        "books" to 90
    )

    val sorted = totals.sortedBy { (_, total) ->
        total ?: Int.MAX_VALUE
    }

    println(sorted)
    // [(books, 90), (food, 250), (taxi, null)]
}

Здесь идея читается даже человеком, который не знает Kotlin: «если суммы нет, считаем её бесконечно большой, чтобы улетела в конец».

Приём простой, но у него есть тонкость: вы не должны выбирать дефолт «наугад». Если вы поставите 0, null окажется в начале, и это будет выглядеть как «такси = 0», хотя мы хотели «такси = неизвестно».

null в начале при сортировке по возрастанию

Иногда по смыслу наоборот хочется увидеть все «дырки» сверху: чтобы отчёт начинался с проблемных данных. Тогда для null подставляем очень маленькое число.

fun main() {
    val totals: List<Pair<String, Int?>> = listOf(
        "food" to 250,
        "taxi" to null,
        "books" to 90
    )

    val sorted = totals.sortedBy { (_, total) ->
        total ?: Int.MIN_VALUE
    }

    println(sorted)
    // [(taxi, null), (books, 90), (food, 250)]
}

Это уже другой договор: «null — самое маленькое». С точки зрения алгоритма всё честно. С точки зрения бизнеса — тоже честно, если вы так решили.

sortedByDescending и «перевёрнутый» дефолт

Вот здесь новички часто спотыкаются: в sortedByDescending всё наоборот. Если вы хотите null в конце при сортировке по убыванию, то null должен стать очень маленьким, иначе он внезапно окажется в начале как «самый большой».

fun main() {
    val totals: List<Pair<String, Int?>> = listOf(
        "food" to 250,
        "taxi" to null,
        "books" to 90
    )

    val sorted = totals.sortedByDescending { (_, total) ->
        total ?: Int.MIN_VALUE
    }

    println(sorted)
    // [(food, 250), (books, 90), (taxi, null)]
}

Если ваши суммы по смыслу не бывают отрицательными, можно выбрать ещё более «говорящий» дефолт, например -1, чтобы прям подчёркивать: «невалидное/неизвестное».

4. Когда ?: становится опасным

Стратегия «подставим число» выглядит как универсальный ключ, но у неё есть скрытая цена. Если вы подставляете дефолт, вы фактически встраиваете бизнес-решение в арифметику: вы превращаете «нет данных» в «какое-то число». Да, вы делаете это только ради сортировки, но следующий читатель кода может не понять, что это «технический костыль», а не реальная логика.

Представьте, что вы подставили Int.MAX_VALUE, а потом кто-то решил посчитать «среднее» по этим ключам (или сделать ещё один шаг пайплайна) и случайно использовал уже «замещённые» числа. В отчёте внезапно появится категория с суммой 2147483647, и это будет выглядеть как серьёзная финансовая проблема, хотя на самом деле это проблема программиста.

Поэтому правило такое: стратегия ?: хороша, когда вы используете её только как ключ сортировки (внутри sortedBy({})), а исходные данные (null) оставляете как есть. То есть мы не переписываем данные, мы только выбираем, как их сравнивать.

5. Стратегия 2: разделяем null и non-null

Вторая стратегия обычно получается длиннее, зато она выигрывает по читаемости. Вместо того чтобы «прикидываться, что null — число», мы говорим честно: «сначала сортируем известные значения, потом добавляем неизвестные туда, куда договорились».

Этот подход особенно хорош, когда null имеет смысл «неизвестно», и вы не хотите, чтобы кто-то даже теоретически принял его за «0» или «бесконечность».

Начнём с простого случая: список чисел List<Int?>.

fun main() {
    val xs: List<Int?> = listOf(3, null, 1, null, 2)

    val sortedNotNull = xs.filterNotNull().sorted()
    val nullCount = xs.count { it == null }

    val result = mutableListOf<Int?>()
    for (v in sortedNotNull) result.add(v)
    repeat(nullCount) { result.add(null) }

    println(result)
    // [1, 2, 3, null, null]
}

Здесь код многословнее, но он максимально прямолинейный. Мы использовали filterNotNull() и count {}, которые уже встречались в операциях над коллекциями: сначала «очистили» данные, потом посчитали, сколько выкинули, и в конце вернули null обратно, но уже в конце списка. И да, этот приём прямо подчёркивает, что null не потерялся, а был временно вынесен в «отдельный карман».

6. Выбор стратегии и читаемое правило для null

Сортируем пары, сохраняя категорию

Реальные отчёты редко сортируют «просто числа». Обычно у нас есть связка «имя–значение», и важно не потерять имя. Поэтому применим стратегию разбиения к List<Pair<String, Int?>>.

Здесь удобна идея «разделить на две коллекции»: одну с известными суммами, другую с неизвестными. В Kotlin есть partition, но даже без него можно сделать прозрачный цикл (а цикл новичкам обычно понятнее, чем «волшебные» функции).

fun main() {
    val totals: List<Pair<String, Int?>> = listOf(
        "food" to 250,
        "taxi" to null,
        "books" to 90
    )

    val known = mutableListOf<Pair<String, Int>>()
    val unknown = mutableListOf<Pair<String, Int?>>()

    for ((cat, total) in totals) {
        if (total == null) unknown.add(cat to null) else known.add(cat to total)
    }

    val knownSorted = known.sortedBy { it.second }
    val result = knownSorted.map { it.first to it.second } + unknown

    println(result)
    // [(books, 90), (food, 250), (taxi, null)]
}

Обратите внимание на хитрость: в known мы храним уже не-null суммы (Int), поэтому дальше сортировка по it.second становится «обычной», без ?:. Это снижает риск случайно принять null за число и делает дальнейшую логику проще.

Мини-ориентир: когда ?:, а когда разбиение

Когда у вас появляется выбор из двух подходов, легко впасть в крайности. Одни начинают везде писать ?: Int.MAX_VALUE и чувствуют себя волшебниками. Другие, наоборот, везде разделяют данные на пять списков и чувствуют себя бухгалтерией. Чтобы не перегнуть, полезно иметь простой критерий выбора: насколько опасно «подменять смысл» и насколько важна читаемость.

Ниже — небольшой ориентир. Он не закон, но помогает принять решение без долгих медитаций.

Ситуация ?: (дефолт) Разделение (split + merge)
Данные маленькие, правило простое («null в конец») Обычно отлично Тоже ок, но может быть многословно
Дефолт легко выбрать так, чтобы он точно вне домена Отлично Не обязательно
Есть риск, что дефолт пересечётся с реальными значениями Опасно и неочевидно Надёжнее
Вы боитесь, что кто-то «переиспользует» дефолт как реальное число Лучше не надо Надёжнее
Нужно явно сохранить/показать null как проблему данных Может скрыть смысл Подчёркивает смысл

И если хочется очень короткого правила: если вы можете одним взглядом объяснить, почему дефолт безопасен, используйте ?:. Если начинаются фразы «ну вообще-то суммы у нас почти всегда положительные, кроме…», лучше разделяйте.

«Готовые правила»: null first/last как идея

Иногда вы увидите код, где null ставят «в начало/в конец» через готовые компараторы или специальные функции сравнения. Сегодня мы не углубляемся в «зоопарк» компараторов (чтобы не распухла тема дня), но важно не пугаться: это всё та же идея, просто упакованная в библиотечный инструмент.

В любом случае, базовая мысль неизменна: сортировка — это контракт, и для null надо выбрать явное место. Kotlin вообще любит, когда намерение читается из кода: как с nullable-типами, где T? прямо говорит «значение может отсутствовать». А для сортировок Kotlin даёт инструменты вроде sortedWith(compareBy{}), чтобы правило сравнения было явным, а не спрятанным в загадочном if.

7. Встраиваем в приложение: отчёт по категориям

Сейчас сделаем маленький кусочек «как это выглядит в живом коде приложения», но без усложнений архитектуры. Пусть у нас есть функция, которая строит «итог по категориям», но если хотя бы один расход в категории неизвестен (null), то итог тоже неизвестен (потому что мы не можем честно посчитать сумму).

fun totalsByCategory(expenses: List<Pair<String, Int?>>): List<Pair<String, Int?>> {
    val categories = expenses.map { it.first }.distinct()
    val result = mutableListOf<Pair<String, Int?>>()

    for (cat in categories) {
        var total = 0
        var hasUnknown = false

        for ((c, amount) in expenses) {
            if (c != cat) continue
            if (amount == null) hasUnknown = true else total += amount
        }

        result.add(cat to if (hasUnknown) null else total)
    }

    return result
}

Теперь применим сортировку так, чтобы null был в конце, а известные суммы шли по убыванию (типичный «топ категорий», только без take(n) — его вы делали в предыдущей лекции).

fun main() {
    val expenses = listOf(
        "food" to 250,
        "taxi" to null,
        "books" to 90,
        "food" to null
    )

    val totals = totalsByCategory(expenses)

    val sorted = totals.sortedByDescending { (_, total) ->
        total ?: Int.MIN_VALUE
    }

    println(sorted)
    // [(books, 90), (food, null), (taxi, null)]
}

Обратите внимание: food и taxi оба null, поэтому их относительный порядок нам уже не так важен (он может зависеть от исходного порядка). Главное — они оба честно в конце. А books с известным итогом впереди.

И да, если вас смущает Int.MIN_VALUE как «волшебное число», это нормальное чувство. В таком случае как раз стоит предпочесть стратегию разбиения: она длиннее, но меньше магии.

8. Типичные ошибки

Ошибка №1: подставлять дефолт «для удобства», не думая о смысле данных.
Самый частый вариант — total ?: 0. Это почти всегда неправда: 0 — это «точно ноль», а null — это «неизвестно». После такой подстановки отчёт становится логически неверным: категории с неизвестными суммами внезапно выглядят как «самые маленькие», и вы сами себе подсовываете неправильные выводы.

Ошибка №2: перепутать дефолты для sortedBy и sortedByDescending.
В сортировке по убыванию многие автоматически делают ?: Int.MAX_VALUE «чтобы было в конце», но получают null в начале, потому что «максимум» в убывании — это как раз лидер. Хороший способ не ошибаться — проговаривать вслух: «я хочу, чтобы null был хуже всех; значит, в descending он должен стать самым маленьким».

Ошибка №3: случайно потерять null, когда он значим.
Функция filterNotNull() замечательная, но она буквально означает «выкинуть все null». Если null важен как маркер проблемы данных, то после filterNotNull() вы уже не сможете показать пользователю «у вас есть 2 записи без суммы». В таких случаях либо считайте null отдельно (count { it == null }), либо применяйте разбиение, а не полное удаление.

Ошибка №4: сделать ключ сортировки слишком «умным», так что его невозможно прочитать.
Иногда встречается лямбда в sortedWith или sortedBy, которая одновременно нормализует, валидирует, делает trim(), обрабатывает null, ещё и что-то прибавляет «чтобы красивее было». В результате сортировка превращается в мини-программу внутри программы. Если правило не помещается в одну понятную строку, лучше вынести в маленькую функцию с говорящим именем — и сортировка снова станет читаемой.

Ошибка №5: использовать «волшебные числа» без комментария и без связи с доменом.
Int.MIN_VALUE и Int.MAX_VALUE технически работают, но для новичка это выглядит как заклинание. Если вы всё-таки выбираете стратегию ?:, старайтесь либо выбирать доменный дефолт (например, -1, если суммы не бывают отрицательными), либо хотя бы оставлять короткий комментарий, почему именно это значение безопасно.

1
Задача
Kotlin SELF, 23 уровень, 3 лекция
Недоступна
Датчики с пропусками
Датчики с пропусками
1
Задача
Kotlin SELF, 23 уровень, 3 лекция
Недоступна
Черновики цен
Черновики цен
1
Задача
Kotlin SELF, 23 уровень, 3 лекция
Недоступна
Рейтинги с пустыми
Рейтинги с пустыми
1
Задача
Kotlin SELF, 23 уровень, 3 лекция
Недоступна
Список с null
Список с null
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ