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.
Щоб не вгадувати, корисно мати під рукою невеличку таблицю:
| Правило | Приклад | Результат |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Якщо вам потрібно «строго менше» або «строго більше», це вже інший тип правила. Але сьогодні фіксуємо базовий варіант: межі включено.
Валідатор для 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?». Ви думаєте: «ага, це вік».
Зʼєднуємо кроки: readLine → parse → validate
Поки що не будемо будувати «повторне введення» циклом (це окрема тема й окрема звичка). Тож оберемо просту стратегію: якщо щось не вийшло — друкуємо повідомлення й завершуємо програму.
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 читається однозначно: діапазон включає межі. Це зменшує шанс «помилки на одиницю».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ