1. Зачем vararg нужен контракт на пустой набор
Если обычная функция принимает два числа, то «особый случай» там обычно только один: кто-то передал не то число (или не то значение). У vararg появляется ещё одна ось реальности: количество аргументов может быть от нуля до бесконечности (ну, почти). И ноль — это не «ошибка компиляции», а нормальный вызов.
Представьте, что вы пишете функцию maxOf(...). В голове она выглядит как «найди максимум». А потом внезапно кто-то вызывает maxOf() — и вы стоите как разработчик на кухне в 3 ночи и спрашиваете у чайника: «А максимум чего именно?». Чайник молчит, компилятор молчит, а программа может упасть, если вы полезете в xs[0].
Поэтому у любой vararg-функции (даже самой милой и доброй) есть важная часть API: контракт поведения на пустом наборе. И сегодня мы научимся этот контракт не «оставлять на авось», а проектировать осознанно.
Что означает «контракт» функции
Слово «контракт» звучит серьёзно, как будто сейчас приедет юрист и попросит вас подписать Terms and Conditions. В программировании контракт — это всего лишь договорённость: какие входные данные допустимы и что именно функция обещает вернуть/сделать.
У vararg контракт особенно важен, потому что «пустой набор» — легальный вход. А раз он легальный, то либо:
- функция обязана корректно отработать и вернуть какой-то результат,
- либо функция обязана честно сказать: «результата нет»,
- либо функция обязана «упасть» сразу и громко, потому что это ошибка использования.
И вы выбираете это не по настроению, а по смыслу задачи.
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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ