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]. Це не «краса», а захист від падіння.
А тепер — як користуватися цим без болю. Ми можемо вивести результат користувачеві, задавши значення «для відображення» через Елвіса ?: (ви вже знайомі з ним із тем про 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.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ