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

Контракт 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]. Це не «краса», а захист від падіння.

А тепер — як користуватися цим без болю. Ми можемо вивести результат користувачеві, задавши значення «для відображення» через Елвіса ?: (ви вже знайомі з ним із тем про 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.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ