JavaRush /Курси /Kotlin SELF /Валідація як окремий шар: діапазони, «не порожньо», повер...

Валідація як окремий шар: діапазони, «не порожньо», повернення T?

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

1. Навіщо виділяти валідацію в окремий шар

Якщо ви коли-небудь писали код у стилі «зчитую рядок → тут же парсю → тут же перевіряю діапазон → тут же друкую помилку → тут же прошу повторити», то ви вже знаєте, як швидко все перетворюється на текстовий лабіринт. Причому лабіринт із мінотавром: логіка губиться, фрагменти коду повторюються, а потім страшно щось зачепити — бо «раптом усе розвалиться».

Ідея шару валідації проста: парсер відповідає за формат, а валідатор — за правила.

Наприклад, рядок "12" може коректно розпарситися в Int, але водночас бути неправильним як «вік кошеняти в роках» (якщо ми очікуємо 0..30) або як «відсоток знижки» (якщо очікуємо 0..100). Тобто парсинг пройшов, але значення все одно «погане». І саме тут починається валідація.

Щоб це було легше тримати в голові, зручно уявляти конвеєр так:

flowchart TD
    A["Сирий рядок із readln()"] --> B[Нормалізація: trim/lowercase]
    B --> C[Парсинг: String -> T?]
    C --> D[Валідація: T -> T?]
    D --> E[Сценарій: використовуємо значення або показуємо повідомлення]

Зверніть увагу: і парсинг, і валідація часто повертають …?, тобто nullable-тип. Так кроки конвеєра легко «стикуються»: будь-який етап може сказати «не вийшло» через null, а наступний рівень уже вирішить, що робити далі.

2. Валідатор: що це і чому він повертає T?

Валідатор — це невелика функція, яка отримує вже розпарсене значення й перевіряє його за правилом. Якщо правило виконується — повертає значення (зазвичай те саме). Якщо ні — повертає null.

Чому саме T?? Тому що нам потрібен єдиний сигнал «не пройшло», який у Kotlin легко й безпечно обробляти.

Наприклад, валідатор «число в діапазоні» може мати таку змістову сигнатуру:

  • вхід: Int, межі min, max
  • вихід: Int? — або те саме число, або null

Є ще одна важлива думка: валідатор зазвичай має бути «тихим». Він не друкує в консоль і не вирішує долю програми. Він просто відповідає на запитання: «валідно чи ні?». Завдяки цьому його можна повторно використовувати. Той самий валідатор підійде для віку, відсотка знижки або кількості спроб у грі: сценарії різні, а перевірка діапазону — однакова.

І так: усередині валідатора часто добре працює стиль guard clauses — послідовні перевірки з раннім виходом. Це читається як прямий текст: «якщо менше мінімуму — не підходить; якщо більше максимуму — не підходить; інакше підходить».

3. Діапазони для чисел: in, min..max і межі

Перевірка діапазону — найчастіший приклад валідації, бо реальні дані майже завжди мають межі. Вік не може бути -500, відсоток не може бути 250, а ширина прямокутника в нашому навчальному введенні не має бути «0», якщо ми очікуємо хоча б 1.

У Kotlin є дуже читабельна форма: x in min..max. Вона використовує діапазон min..max і оператор in.

Валідатор для Int у діапазоні

Почнемо з простої функції:

fun validateIntInRangeOrNull(x: Int, min: Int, max: Int): Int? {
    if (x !in min..max) return null
    return x
}

Зверніть увагу, що тут важливо: ми не друкуємо «Помилка!», не просимо ввести ще раз і не робимо нічого драматичного. Ми просто повертаємо null, якщо значення не потрапило в діапазон.

Перевіримо на прикладі:

fun main() {
    val a = validateIntInRangeOrNull(10, 1, 100)
    val b = validateIntInRangeOrNull(0, 1, 100)

    println(a) // 10
    println(b) // null
}

Межі — це частина правила

Іноді початківці «трохи» плутаються: діапазон 1..100 включає 100 чи ні? У Kotlin діапазон через .. включає обидві межі. Тобто 100 in 1..100 — це true.

Щоб не вгадувати, корисно мати під рукою невеличку таблицю:

Правило Приклад Результат
x in 1..3
1 in 1..3
true
x in 1..3
3 in 1..3
true
x in 1..3
0 in 1..3
false
x in 1..3
4 in 1..3
false

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

Валідатор для Double у діапазоні

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

fun validateDoubleInRangeOrNull(x: Double, min: Double, max: Double): Double? {
    if (x < min) return null
    if (x > max) return null
    return x
}

Тут я спеціально показав варіант без in, щоб ви бачили «під капотом»: ідея все та сама — два порівняння. Іноді такий запис навіть читається ясніше, коли працюєте з Double.

4. Рядки: перевірка «не порожньо» після trim()

Правило «рядок не має бути порожнім» здається простим… доки ви не побачите введення на кшталт " " (чотири пробіли). Формально це не порожній рядок, але за змістом користувач «нічого не ввів». Тому в реальних програмах «не порожньо» майже завжди означає таке: спочатку trim(), потім перевірка на порожнечу.

І тут знову допомагає проста ідея: парсер відповідає «рядок → значення», а валідатор — «значення підходить?». Рядок — теж значення, отже, його також можна валідувати.

Валідатор «не порожньо» для рядків

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

Перевіримо:

fun main() {
    println(validateNonEmptyOrNull("Alice"))   // Alice
    println(validateNonEmptyOrNull("   "))     // null
    println(validateNonEmptyOrNull(""))        // null
}

Чому валідатор повертає нормалізований рядок

Зверніть увагу: ми повертаємо s, а не raw. Тобто валідатор у цьому прикладі не лише перевіряє правило, а й повертає рядок, «приведений до нормального вигляду».

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

5. Композиція: readln() → парсинг → валідація

Найприємніше починається тоді, коли ви складаєте все в ланцюжок. Зробімо маленький навчальний сценарій: «мініанкету», яка просить імʼя та вік, а потім друкує привітання. Головна мета — не анкета, а те, щоб ви побачили: валідація — це окремий шар, і його можна підʼєднати як LEGO-цеглинку.

Парсер і валідатор не мають сперечатися про ролі

Спочатку — «чистий» парсер (як ми робили в минулій лекції):

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

Тепер валідатор:

fun validateAgeOrNull(age: Int): Int? {
    return validateIntInRangeOrNull(age, 0, 150)
}

Зверніть увагу: validateAgeOrNull — це просто «змістовий аліас». Він робить код сценарію читабельнішим: коли ви бачите validateAgeOrNull, ви не думаєте «що за 0..150?». Ви думаєте: «ага, це вік».

Зʼєднуємо кроки: readLineparsevalidate

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

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

fun main() {
    val name = validateNonEmptyOrNull(readLine("Імʼя: "))
        ?: return println("Імʼя не повинно бути порожнім.")

    val age = parseIntOrNull(readLine("Вік: "))
        ?.let(::validateAgeOrNull)
        ?: return println("Вік має бути числом від 0 до 150.")

    println("Привіт, $name! Твій вік: $age") // Привіт, Alice! Твій вік: 20
}

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

fun main() {
    val name = validateNonEmptyOrNull(readLine("Імʼя: "))
        ?: return println("Імʼя не повинно бути порожнім.")

    val ageRaw = readLine("Вік: ")
    val ageParsed = parseIntOrNull(ageRaw) ?: return println("Вік: введіть ціле число.")
    val age = validateAgeOrNull(ageParsed) ?: return println("Вік: допустимий діапазон 0..150.")

    println("Привіт, $name! Твій вік: $age")
}

Так, рядків трохи більше. Зате все максимально прямолінійно: спочатку розпарсили, потім провалідовували. Для новачка це часто найкращий варіант.

6. Як не перетворити валідатори на «комбайн»

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

Правило, яке береже нерви: валідатор має бути маленьким, передбачуваним і придатним до повторного використання. Він приймає значення, перевіряє правило й повертає або значення, або null. Він не повинен знати, звідки прийшло значення (консоль, тести, файл) і що з ним буде далі.

У назвах теж корисна дисципліна. Якщо функція може повертати null як сигнал «не пройшло», це має бути видно з назви. Суфікс OrNull — чудова домовленість із майбутнім собою. А майбутній ви — це ви ж, тільки втомлений, о 2:30 ночі, перед дедлайном.

І ще одне: намагайтеся повертати «нормалізований» варіант значення, якщо нормалізація потрібна для правила. У рядках це особливо помітно: якщо ви перевіряєте trim()-версію, то логічно її й повернути, щоб не тягнути далі пробіли.

Мінібібліотека валідаторів на сьогодні

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

fun validateIntInRangeOrNull(x: Int, min: Int, max: Int): Int? {
    if (x !in min..max) return null
    return x
}

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

fun validateDoubleInRangeOrNull(x: Double, min: Double, max: Double): Double? {
    if (x < min) return null
    if (x > max) return null
    return x
}

Зверніть увагу: перевірка x in min..max — це дуже котлінівський спосіб сказати: «значення в межах діапазону».

7. Типові помилки під час виділення валідації в окремий шар

Помилка №1: змішування парсингу й валідації в одну функцію на «півтори сторінки».
Дуже легко почати з ідеї «зроблю одну функцію readAge(), і вона все вирішить», а за кілька правок отримати 40 рядків, де перемішалися trim(), toIntOrNull(), діапазони, повідомлення про помилки та якісь спеціальні випадки. Такий код складно повторно використовувати й майже неможливо акуратно розширювати. Поділ на «парсер» і «валідатор» здається зайвою морокою рівно до моменту, поки ви не додасте друге поле введення й не зрозумієте, що копіюєте одне й те саме.

Помилка №2: валідатор друкує повідомлення в консоль.
Коли валідатор починає робити println("Помилка!"), він втрачає універсальність. Сьогодні ви хочете одне формулювання помилки, завтра — інше, післязавтра — взагалі не консоль, а тести, де вивід не потрібен. Значно спокійніше, коли валідатор просто повертає null, а рішення «що сказати користувачу» ухвалює сценарій. Тоді ви не отримуєте «голос у голові програми», який раптом починає говорити не тим тоном.

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

Помилка №4: перевірка «не порожньо» без trim().
Якщо ви перевіряєте лише raw.isEmpty(), то рядок із пробілів пройде як «не порожній». Користувач буде впевнений, що «нічого не ввів», а програма — що «все чудово, імʼя задано». Тому правило «не порожньо» майже завжди слід розуміти як «не порожньо після видалення пробілів по краях» — і повертати вже trim()-версію.

Помилка №5: не домовитися про межі діапазону й випадково змінити зміст перевірки.
Сьогодні ви вважаєте, що вік 150 допустимий, завтра — що ні. У результаті починаєте вручну змінювати перевірки в різних місцях. Винесений валідатор розвʼязує цю проблему: правило живе в одному місці. І що не менш важливо — код x in min..max читається однозначно: діапазон включає межі. Це зменшує шанс «помилки на одиницю».

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