JavaRush /Курси /Kotlin SELF /Збирання міні‑API без дублів

Збирання міні‑API без дублів

Kotlin SELF
Рівень 14 , Лекція 5
Відкрита

1. Ідея та принципи міні‑API введення

Коли ви пишете перші консольні програми, здається, що копіювати 5–7 рядків введення — це нормально: «ну я ж просто ще раз запитав число». Але минає буквально пів години — і раптом у вас уже десять місць, де ви забули trim(), три місця, де випадково використали toInt() замість toIntOrNull(), і ще одне місце, де ви друкуєте підказку без пробілу. У результаті користувач вводить число впритул до тексту. А все це — різні прояви однієї й тієї самої проблеми: логіка повторюється, тож вона неминуче «розʼїжджається».

Міні‑API — це не «великий фреймворк введення», а невеликий набір функцій, про які ви заздалегідь домовилися й використовуєте їх завжди. Наша мета — прибрати повторювані фрагменти (виведення prompt, нормалізацію, безпечний парсинг, перевірку діапазону) і залишити в main() тільки те, що належить до сценарію програми: які саме дані ми запитуємо й що робимо далі.

Домовленості щодо імен

Якщо називати функції як заманеться, міні‑API швидко перетвориться на набір випадкових заклинань: «а readNumber кидає виняток чи повертає null?», «а getInt робить trim() чи ні?». Тому запроваджуємо прості правила й дотримуємося їх. Це як домовитися в команді, що «чай із бергамотом» — це не „кава лате“ (інакше будуть сльози, образи й раптові null).

Домовимося про такий стиль іменування:

Префікс / суфікс Приклад Зміст (обіцянка функції)
read…
readIntOrNull(prompt)
Функція читає з консолі (
readln()
усередині)
parse…
parseIntOrNull(raw)
Функція не читає й не друкує, а лише розбирає рядок
validate…
validateIntInRangeOrNull(x, min, max)
Функція перевіряє правила для вже готового значення
…OrNull
readNonEmptyLineOrNull(...)
Може повернути
null
, якщо «не вдалося отримати коректне значення»
без
OrNull
readLinePrompt(...)
Повертає значення гарантовано (або це просто «сирий рядок», або функція „не може не повернути“)

Окрема важлива думка про require і check: це не «про неправильне введення користувача», а про неправильне використання функції програмістом. Вони кидають винятки й застосовуються як перевірки передумов.

Композиція без дублів

Щоб міні‑API не розросталося в «комбайн на всі випадки життя», корисно тримати в голові просту схему рівнів. Уявіть конвеєр: на вході — сирий рядок від користувача, на виході — або коректне значення, або null, що означає «на цьому кроці не вдалося».

Нижче — схема, до якої ми будемо прагнути (її легко розширювати, не копіюючи код):

flowchart TD
    A["prompt + readln()"] --> B[нормалізація: trim/lowercase]
    B --> C[parse...OrNull: String -> T?]
    C --> D[validate...OrNull: T -> T?]
    D --> E[read...OrNull рівня сценарію]

Тут важливо, що «цеглинки» маленькі й чесні. parseIntOrNull() не друкує «Ви ввели не число», бо не знає, як саме ви хочете спілкуватися з користувачем. Вона просто каже: «можу розібрати — тримайте Int, не можу — тримайте null». А вже сценарій (зазвичай main) вирішує, що робити з null: завершитися, попросити повторити (цикли ви вже знаєте), показати підказку або вибрати значення за замовчуванням.

2. Утиліти введення в InputUtils.kt

Хочеться одразу написати одну функцію readIntInRangeWithRetriesAndAngryMessages(...), але це прямий шлях до монстра на 60 рядків. Підемо іншим шляхом: зберемо файл (або просто блок в одному .kt), де кожна функція виконує один зрозумілий крок. Потім із цих кроків складемо зручніші «комбіновані» утиліти.

Читання рядка з prompt

Почнемо з безпечного й передбачуваного «нульового рівня»: друкуємо підказку й читаємо рядок. Так, це банально. Саме тому це варто винести: банальності в коді краще централізувати.


fun readLinePrompt(prompt: String): String {
    print(prompt)
    return readln()
}

Тут ми свідомо повертаємо String, а не String?: у наших сценаріях введення з консолі вважаємо, що користувач усе-таки щось введе. (Кінець потоку введення — окрема тема; зараз нам важливіший загальний патерн.)

Нормалізація введення

Нормалізація — це коли ми робимо з «  Так  » нормальне «так», а з «  10  » — нормальне «10». Це не «валідація», бо ми нічого не забороняємо. Ми просто прибираємо шум.

fun normalizeTrim(raw: String): String {
    return raw.trim()
}

fun normalizeCommand(raw: String): String {
    return raw.trim().lowercase()
}

Якщо вам здається, що це надто дрібно — чудово. Саме такі дрібниці потім економлять десятки рядків: ви перестаєте розкидати trim() по всьому коду, мов конфеті.

Чистий парсинг

Тепер напишемо парсери. Вони не читають із консолі й не друкують. Також вони вміють акуратно обробляти порожній рядок.

fun parseIntOrNull(raw: String): Int? {
    val s = normalizeTrim(raw)
    if (s.isEmpty()) return null
    return s.toIntOrNull()
}

fun parseDoubleOrNull(raw: String): Double? {
    val s = normalizeTrim(raw)
    if (s.isEmpty()) return null
    return s.toDoubleOrNull()
}

Так, toIntOrNull() і так поверне null на порожньому рядку. Але явна перевірка тут підвищує читабельність для новачка: видно, що порожнеча — це нормальний варіант «не вийшло».

Тепер парсер «так/ні». Ми хочемо розрізняти «ні» й «я не зрозумів». Тому повертаємо Boolean?, а не Boolean.

fun parseYesNoOrNull(raw: String): Boolean? {
    return when (normalizeCommand(raw)) {
        "yes", "y", "так", "т" -> true
        "no", "n", "ні", "н" -> false
        else -> null
    }
}

Валідація

Тепер — валідатори. Вони приймають уже нормальний тип (Int, Double, String) і перевіряють правила.

fun validateIntInRangeOrNull(x: Int, min: Int, max: Int): Int? {
    require(min <= max) { "Очікували min <= max (min=$min, max=$max)" }
    if (x < min) return null
    if (x > max) return null
    return x
}

Тут require(min <= max) — це перевірка передумов: якщо програміст передав неправильні межі, продовжувати «ніби все ок» не можна. require саме для цього й існує та в разі провалу кидає IllegalArgumentException.

Для рядків зробимо валідатор «не порожньо після trim()»:

fun validateNonEmptyOrNull(raw: String): String? {
    val s = normalizeTrim(raw)
    if (s.isEmpty()) return null
    return s
}

І приклад перевірки «строго додатне» для Double (корисно для цін, коефіцієнтів тощо):

fun validatePositiveDoubleOrNull(x: Double): Double? {
    if (x <= 0.0) return null
    return x
}

Комбіновані read…OrNull для сценарію

Ось тепер можна складати функції, які читають із консолі й усередині використовують наші цеглинки. Головна мета — щоб усередині не було дублів і щоб однакові речі виглядали однаково.

fun readIntOrNull(prompt: String): Int? {
    val raw = readLinePrompt(prompt)
    return parseIntOrNull(raw)
}

fun readDoubleOrNull(prompt: String): Double? {
    val raw = readLinePrompt(prompt)
    return parseDoubleOrNull(raw)
}

Зробимо «непорожній рядок»:

fun readNonEmptyLineOrNull(prompt: String): String? {
    val raw = readLinePrompt(prompt)
    return validateNonEmptyOrNull(raw)
}

Зробимо «так/ні»:

fun readYesNoOrNull(prompt: String): Boolean? {
    val raw = readLinePrompt(prompt)
    return parseYesNoOrNull(raw)
}

І нарешті най«смачніша» утиліта: число в діапазоні. Важливо, що тут немає власного парсингу й немає власної логіки діапазону — ми повторно використовуємо вже написане.

fun readIntInRangeOrNull(prompt: String, min: Int, max: Int): Int? {
    require(min <= max) { "Очікували min <= max (min=$min, max=$max)" }

    val x = readIntOrNull(prompt) ?: return null
    return validateIntInRangeOrNull(x, min, max)
}

Зверніть увагу на ефект: якщо завтра ви вирішите, що readIntOrNull має, наприклад, інакше робити trim(), — ви зміните це в одному місці. І це автоматично поліпшить усі більш високорівневі функції.

3. Читабельний main() як сценарій

Зараз ми зробимо найважливішу вправу цієї лекції: напишемо main() так, щоб він виглядав як сценарій спілкування з користувачем. Тобто: спочатку запитали назву кімнати, потім розміри, потім ціну, потім виведення. Без каші з trim() і toDoubleOrNull() на кожному кроці. Якщо ви коли‑небудь читали чужий код і відчували, як мозок намагається вистрибнути у вікно, — це якраз про відсутність таких утиліт.

Нижче — невеликий приклад застосунку, який рахує площу стін (і, за потреби, стелі) та оцінює вартість фарбування. Ми не використовуємо колекції, файли й «складну архітектуру» — лише те, що вже доступно до рівня 14.

fun main() {
    println("Калькулятор фарбування кімнати v0")

    val roomName = readNonEmptyLineOrNull("Назва кімнати: ")
    if (roomName == null) {
        println("Назва не повинна бути порожньою.")
        return
    }

    val width = readIntInRangeOrNull("Ширина (1..50), м: ", 1, 50)
    if (width == null) {
        println("Ширину введено некоректно.")
        return
    }

    val length = readIntInRangeOrNull("Довжина (1..50), м: ", 1, 50)
    if (length == null) {
        println("Довжину введено некоректно.")
        return
    }

    val height = readIntInRangeOrNull("Висота (2..10), м: ", 2, 10)
    if (height == null) {
        println("Висоту введено некоректно.")
        return
    }

    val priceRaw = readDoubleOrNull("Ціна фарбування за 1 м² (наприклад 12.5): ")
    val pricePerSqm = if (priceRaw == null) null else validatePositiveDoubleOrNull(priceRaw)
    if (pricePerSqm == null) {
        println("Ціна має бути числом більшим за 0.")
        return
    }

    val includeCeiling = readYesNoOrNull("Фарбуємо стелю? (yes/no): ")
    if (includeCeiling == null) {
        println("Не зрозумів відповідь. Введіть yes/no.")
        return
    }

    val wallsArea = 2.0 * (width + length) * height
    val ceilingArea = width.toDouble() * length.toDouble()
    val totalArea = if (includeCeiling) wallsArea + ceilingArea else wallsArea

    val totalCost = totalArea * pricePerSqm

    println()
    println("Кімната: $roomName")
    println("Площа стін: ${"%.2f".format(wallsArea)} м²")
    println("Загальна площа: ${"%.2f".format(totalArea)} м²")
    println("Вартість: ${"%.2f".format(totalCost)}")
}

Так, тут є повторюваний шаблон:

val x = read...
if (x == null) { println(...); return }

Але важлива відмінність у тому, що це повторення — уже «рівня сценарію». Воно описує бізнес‑рішення: «якщо не вдалося отримати коректні дані, завершуємо програму». А низькорівневі повтори (прочитав рядок, trim(), розібрав) ми прибрали.

Якщо згодом вам захочеться змінити поведінку на «не завершуватися, а просити повторити», ви зможете переробити лише сценарій, не чіпаючи парсери й валідатори. (Цикли у вас уже є, але сьогодні ми свідомо не робимо введення «з повтором», щоб не змішувати теми.)

4. Як утримати міні‑API маленьким

Дуже легко, відчувши смак до утиліт, почати писати «все більш зручні» функції, які зрештою робитимуть усе одразу: читати, валідувати, друкувати помилки, повторювати введення, форматувати підказки — і ще й ображатися на користувача за «неправильну кому». Це весело перші пʼять хвилин, а потім ви розумієте: у вас уже немає міні‑API. У вас є другий main, просто схований у функцію.

Хороший баланс зазвичай такий: у вас є кілька базових функцій (readLinePrompt, parseIntOrNull, validateIntInRangeOrNull) і кілька зручних «комбінованих» функцій, які справді часто потрібні (readIntInRangeOrNull, readNonEmptyLineOrNull, readYesNoOrNull). Якщо ви бачите, що чергова функція вийшла з шістьма параметрами та фразою «про всяк випадок», — це майже завжди сигнал, що ви змішали рівні відповідальності.

Окремо варто тримати в голові різницю між «помилкою користувача» і «помилкою програміста». Користувач може ввести сорок два замість 42 — це очікувано, і тоді …OrNull повертає null. А от якщо програміст викликає readIntInRangeOrNull(prompt, 10, 1), це вже порушення передумов. Таку помилку краще ловити одразу через require(min <= max). require і check якраз призначені для fail‑fast поведінки: вони кидають винятки, щоб проблема була помітною, а не перетворювалася на «тихий дивний баг».

5. Типові помилки під час збирання міні‑API утиліт

Помилка №1: функція називається так, ніби вона «не null», але повертає null.
Якщо ви назвали функцію readInt(...), а вона повертає Int?, ви змушуєте читача коду займатися археологією: «а де ж тут може бути null?». Суфікс OrNull — це чесний сигнал: «обробляйте null, це частина контракту».

Помилка №2: утиліти друкують помилки всередині себе, а потім main() друкує ще раз.
Спочатку це здається зручним: «нехай readIntOrNull сам пише „Ви ввели не число“». Але щойно у вас зʼявляються два сценарії (наприклад, в одному місці ви хочете „не число“, а в іншому — „очікував вік“), утиліта починає заважати. Зазвичай краще, щоб низький рівень повертав null, а рівень сценарію вирішував, що саме й як казати користувачу.

Помилка №3: змішування парсингу та валідації в одній довгій функції.
Коли в одній функції одночасно trim(), потім toIntOrNull(), потім перевірка діапазону, потім друк помилок — її складно повторно використовувати й складно тестувати «в голові». Розділення на parse…OrNull і validate…OrNull робить код лінійним і дає змогу складати різні комбінації без копіпасту.

Помилка №4: повернення «магічних значень» замість null.
Іноді хочеться повернути -1, 0 або Int.MIN_VALUE, щоб «позначити помилку». Це майже завжди самообман: -1 може бути реальним значенням (наприклад, температура), 0 може бути допустимим (наприклад, знижка), і ви починаєте плодити додаткові умови. null у Kotlin для таких ситуацій набагато чесніший: «значення не отримано / не пройшло перевірку».

Помилка №5: відсутність перевірок передумов там, де вони справді потрібні.
Якщо функція приймає min і max, і ви не перевіряєте, що min <= max, то десь у майбутньому отримаєте дивну поведінку (наприклад, «діапазон нічого не приймає») і витратите час на налагодження того, що можна було спіймати одразу. У таких місцях доречний require(...), бо це помилка використання функції, а не помилка користувача.

Помилка №6: роздування сигнатур „про всяк випадок“.
Міні‑API перестає бути міні‑API, коли у вас зʼявляються функції на кшталт readIntInRangeOrNull(prompt, min, max, retries, defaultValue, errorMessage, allowEmpty, ...). Як правило, це означає, що ви змішали логіку сценарію (що робити в разі помилки) і логіку утиліти (як безпечно отримати значення). Краще залишати утиліти простими, а складну поведінку збирати зовні — у main() або в окремих функціях сценарію.

Помилка №7: хаотична нормалізація в різних місцях.
Якщо один парсер робить trim(), інший — ні, а десь іще ви вручну робите lowercase() у main(), ви неминуче отримаєте «дивні» баги на кшталт „YES не розпізнали, а yes розпізнали“. Нормалізація має бути узгодженою: краще мати 1–2 маленькі функції на кшталт normalizeTrim і normalizeCommand та використовувати їх усюди однаково.

1
Опитування
Функції для введення та валідації, рівень 14, лекція 5
Недоступний
Функції для введення та валідації
Функції для введення та валідації
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ