JavaRush /Курсы /Kotlin SELF /Контракт vararg‑функций: пустой набор, ...OrNull и requir...

Контракт vararg‑функций: пустой набор, ...OrNull и require

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

1. Зачем vararg нужен контракт на пустой набор

Если обычная функция принимает два числа, то «особый случай» там обычно только один: кто-то передал не то число (или не то значение). У vararg появляется ещё одна ось реальности: количество аргументов может быть от нуля до бесконечности (ну, почти). И ноль — это не «ошибка компиляции», а нормальный вызов.

Представьте, что вы пишете функцию maxOf(...). В голове она выглядит как «найди максимум». А потом внезапно кто-то вызывает maxOf() — и вы стоите как разработчик на кухне в 3 ночи и спрашиваете у чайника: «А максимум чего именно?». Чайник молчит, компилятор молчит, а программа может упасть, если вы полезете в xs[0].

Поэтому у любой vararg-функции (даже самой милой и доброй) есть важная часть API: контракт поведения на пустом наборе. И сегодня мы научимся этот контракт не «оставлять на авось», а проектировать осознанно.

Что означает «контракт» функции

Слово «контракт» звучит серьёзно, как будто сейчас приедет юрист и попросит вас подписать Terms and Conditions. В программировании контракт — это всего лишь договорённость: какие входные данные допустимы и что именно функция обещает вернуть/сделать.

У vararg контракт особенно важен, потому что «пустой набор» — легальный вход. А раз он легальный, то либо:

  1. функция обязана корректно отработать и вернуть какой-то результат,
  2. либо функция обязана честно сказать: «результата нет»,
  3. либо функция обязана «упасть» сразу и громко, потому что это ошибка использования.

И вы выбираете это не по настроению, а по смыслу задачи.

2. Стратегии контракта для пустого набора

В этом разделе полезно не начинать сразу с кода, а сначала зафиксировать варианты поведения. Мы будем использовать три стратегии, которые встречаются постоянно (и которые вы будете выбирать снова и снова всю жизнь разработчика, даже если станете писать не игры, а космические ракеты — там тоже есть vararg, просто называется «пакет телеметрии»).

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

Стратегия Идея Что возвращаем при пустом наборе Когда подходит
Нейтральный результат Есть естественное «значение по умолчанию» Например 0, "", «ничего не печатать» Сумма, конкатенация, печать
Nullable‑результат Результат не определён, поэтому честно возвращаем «нет значения» null (и тип результата T?) Максимум/минимум, среднее, «лучший результат»
Fail-fast Пустой набор — ошибка программиста, продолжать нельзя Бросаем исключение через require(...) Когда без аргументов смысла нет вообще

Теперь давайте разберём каждую стратегию спокойно и с маленькими примерами.

Нейтральный результат

Самый приятный случай — когда для операции существует нейтральный элемент. Например, сумма пустого набора логично равна нулю: вы ничего не сложили — получили 0. Это не магия, а просто удобная математика, которая сильно упрощает жизнь.

Сделаем маленькую функцию для нашего учебного приложения. Напомню, в мини‑проекте «Угадай число» у нас есть попытки (числа, которые вводил пользователь). Иногда мы хотим посчитать их сумму — например, для статистики или для «шуточного рейтинга упорства».

fun sumAttempts(vararg attempts: Int): Int {
    var sum = 0
    for (a in attempts) sum += a
    return sum
}

fun main() {
    println(sumAttempts(3, 7, 10)) // 20
    println(sumAttempts())         // 0
}

Здесь контракт очевиден и безопасен: sumAttempts() возвращает 0, и это не выглядит как «мы что-то придумали». Это естественный итог.

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

...OrNull

Теперь возьмём задачу «найти максимум». Для пустого набора максимум не определён. Некоторые пытаются сделать «ну пусть максимум пустого набора будет 0». И это звучит безобидно ровно до тех пор, пока реальные данные не содержат отрицательные числа. Тогда «максимум пустого набора = 0» начинает врать, и вы получаете баг, который очень тяжело заметить.

Когда результата нет, самый честный путь в Kotlin — вернуть null, то есть сделать тип результата nullable: Int?.

И здесь нам помогает хорошая привычка именования: суффикс OrNull. Он буквально говорит читателю: «Если результат невозможно получить — вернём null». Вы уже видели такой стиль на строках и числах, например toIntOrNull().

Сделаем функцию bestAttemptOrNull(...) — «лучшая попытка» (в нашем случае “лучшая” пусть будет минимальным номером попытки, то есть чем меньше, тем лучше). Но чтобы не путаться, в примере просто найдём минимум: меньше попыток — лучше.

fun minAttemptOrNull(vararg attempts: Int): Int? {
    if (attempts.size == 0) return null

    var min = attempts[0]
    for (a in attempts) {
        if (a < min) min = a
    }
    return min
}

fun main() {
    println(minAttemptOrNull(5, 3, 9)) // 3
    println(minAttemptOrNull())        // null
}

Обратите внимание на порядок: сначала мы проверяем size == 0, и только потом берём attempts[0]. Это не «красота», это защита от падения.

А теперь — как этим пользоваться без боли. Мы можем вывести результат пользователю, задав значение «для отображения» через Elvis ?: (вы уже знакомы с ним из тем про null):

fun main() {
    val best = minAttemptOrNull()
    println(best ?: -1) // -1 (условно: "нет попыток")
}

Важно различать: -1 здесь не значит, что функция вернула -1. Функция честно вернула null. А -1 — это наше решение только для вывода, потому что println(null) выглядит грустно и неинформативно.

Fail-fast через require(...)

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

И вот здесь уместна стратегия fail-fast: «упади сразу, громко и понятным сообщением». В Kotlin для этого часто используют require(...), которая бросает IllegalArgumentException, если условие не выполнено. Это стандартный приём: require для проверки аргументов, check для проверки состояния.

Напишем строгую версию минимума: minAttemptStrict(...). Её смысл: «в приложении невозможно искать минимум, если попыток нет — это ошибка логики».

fun minAttemptStrict(vararg attempts: Int): Int {
    require(attempts.size > 0) { "minAttemptStrict: нужен хотя бы один аргумент" }

    var min = attempts[0]
    for (a in attempts) {
        if (a < min) min = a
    }
    return min
}

fun main() {
    println(minAttemptStrict(5, 3, 9)) // 3
}

Почему мы используем require, а не пишем if ... throw ... руками? Потому что require — это узнаваемый стандартный стиль Kotlin, и читатель сразу понимает: «это проверка предусловия».

При этом важно понимать идеологию: require — не способ «обработать ошибку ввода пользователя». Если пользователь ввёл что-то не то, вы обычно не хотите падать исключением — вы хотите попросить ввести ещё раз. А вот если программист вызвал minAttemptStrict() без аргументов — это баг в коде, и лучше узнать о нём как можно раньше.

3. Как выбрать стратегию

Сейчас будет немного самоиронии: блок‑схемы обычно рисуют, когда хотят сделать вид, что всё очень формально. Но в нашем случае это реально помогает не гадать каждый раз заново.

flowchart TD
    A["Есть vararg-функция"] --> B{"Может ли смысл быть у пустого набора?"}
    B -->|Да, есть нейтральный результат| C["Вернуть нейтральное значение (например 0 или '')"]
    B -->|Да, но результата нет| D["Вернуть null и назвать ...OrNull"]
    B -->|Нет, это ошибка использования| E["require(size > 0) и понятное сообщение"]

И теперь важная мысль: выбор стратегии — часть дизайна API. То есть не «как проще написать», а «как будет удобнее и безопаснее пользоваться».

Почему xs[0] — ловушка

Есть одна очень распространённая ошибка, которая появляется почти у всех. Она выглядит так: вы пишете функцию, вам нужен стартовый максимум/минимум, вы берёте xs[0], а потом «добавите проверку позже». Потом вы забываете, а потом кто-то вызывает f() и программа падает.

Чтобы не попадаться на это, полезно держать в голове простой шаблон:

Сначала обработай пустоту → потом используй первый элемент как стартовую точку.

Вот аккуратная версия maxOrNull (для разнообразия — максимум):

fun maxScoreOrNull(vararg scores: Int): Int? {
    if (scores.size == 0) return null

    var max = scores[0]
    for (s in scores) {
        if (s > max) max = s
    }
    return max
}

fun main() {
    println(maxScoreOrNull(10, 7, 12)) // 12
    println(maxScoreOrNull())          // null
}

Такой код читается как нормальный рассказ: «если значений нет — результата нет; иначе начинаем с первого и ищем больше».

Имена как часть контракта

В программировании очень легко сделать «формально правильно», но «человечески непонятно». Поэтому отдельно проговорим про имена.

Когда вы называете функцию maxOf(...), вы как бы обещаете: «максимум будет всегда». Если внутри она возвращает null на пустом наборе, то это не катастрофа, но читателю придётся лезть в реализацию или документацию.

Когда вы называете функцию maxOfOrNull(...), вы заранее предупреждаете: «иногда null — и это нормально». Такой стиль очень распространён в Kotlin‑экосистеме, и он делает код читаемее просто потому, что контракт лежит на поверхности имени.

А если вы делаете строгую версию, которая не принимает пустой набор, удобно добавить в имя намёк на «строгость»: ...Strict, ...Required, ...NonEmpty. Не потому что так «по правилам», а потому что это спасает коллег (и будущего вас) от сюрпризов.

4. Пример из приложения: отчёт по попыткам

Чтобы было ощущение, что мы развиваем один проект, давайте сделаем мини‑утилиту, которая печатает отчёт по попыткам в «Угадай число». Здесь vararg удобен, потому что мы можем быстро передать несколько чисел прямо в вызов, не думая про массивы.

Смысл: если попыток нет, мы не будем падать — мы выведем «нет данных». Это хороший пример, где ...OrNull даёт удобный контракт.

fun minAttemptOrNull(vararg attempts: Int): Int? {
    if (attempts.size == 0) return null

    var min = attempts[0]
    for (a in attempts) if (a < min) min = a
    return min
}

fun printAttemptsReport(vararg attempts: Int) {
    val best = minAttemptOrNull(*attempts)
    println("Попыток: ${attempts.size}")               // например: Попыток: 3
    println("Лучшая попытка: ${best ?: "нет данных"}") // например: Лучшая попытка: 3
}

fun main() {
    printAttemptsReport(5, 3, 9)
    printAttemptsReport()
}

Здесь есть маленький, но важный момент: мы «проксируем» attempts как vararg дальше в minAttemptOrNull, поэтому используем *attempts. Мы обсуждали это в лекции про spread‑оператор, но сейчас полезно увидеть, что даже в «контрактной» теме это встречается естественно.

5. require: сообщения, которые помогают

require хорош не тем, что «падает». Падает и NullPointerException, и от него радости примерно как от будильника, который зазвонил на час раньше. require хорош тем, что вы контролируете: где упасть и с каким сообщением.

Сообщение должно отвечать на два вопроса: что было не так и что ожидалось. В примере:

require(attempts.size > 0) { "minAttemptStrict: нужен хотя бы один аргумент" }

Здесь есть имя функции (чтобы быстро найти виновника) и человеческое ожидание («нужен хотя бы один аргумент»). Такой текст потом очень помогает при отладке.

И ещё одно: require кидает IllegalArgumentException. Это полезно помнить хотя бы потому, что если вы случайно обернёте всё try/catch (Exception), вы можете «спрятать» настоящую ошибку.

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

Ошибка №1: функция падает на xs[0], потому что забыли про пустой набор.
Это классика жанра: вы написали поиск минимума/максимума, взяли первый элемент как стартовый — и всё отлично, пока кто-то не вызвал f(). Лечится просто: либо делайте ранний return null и тип T?, либо ставьте require(size > 0) до первого доступа по индексу.

Ошибка №2: «волшебное значение» вместо честного null.
Возвращать 0 как «максимум пустого набора» выглядит удобно, но это удобство обманчивое. Если реальные данные могут быть отрицательными, вы получите неправду без единого исключения — а это самый противный тип багов. В таких задачах лучше использовать ...OrNull и вернуть null, а значение «для печати» задавать снаружи через ?:.

Ошибка №3: require(...) используется как обработка пользовательского ввода.
require — это не «попросить пользователя ввести ещё раз», это «программе дальше нельзя». Если пользователь ввёл ерунду — обычно вы хотите мягко объяснить и повторить ввод. А require оставляйте для ошибок программиста: когда нарушен контракт функции и продолжение работы бессмысленно.

Ошибка №4: непонятное имя скрывает контракт.
Если функция называется так, будто результат всегда существует (maxAttempt, bestScore), но на пустом наборе она возвращает null или падает — это сюрприз. Суффикс OrNull делает контракт читаемым. А если пустой набор запрещён, хорошо отражать это в имени (Strict, NonEmpty) и в сообщении require.

Ошибка №5: require без сообщения или с сообщением вроде "Invalid input".
Формально код работает, но когда ошибка случится, вы получите бесполезную диагностику. Пишите сообщение так, чтобы оно помогало исправить вызов: что ожидалось, что было не так, где искать.

Ошибка №6: забыли, что vararg всегда допускает f() — и это часть API.
Иногда разработчик «в голове» считает, что «эту функцию всегда будут вызывать хотя бы с одним аргументом». Но Kotlin этого не гарантирует: пустой вызов компилируется. Поэтому контракт на пустоту нужно фиксировать прямо в коде: нейтральным значением, null, либо require.

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