JavaRush /Курси /Kotlin SELF /Знайомство з датами

Знайомство з датами

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

1. Вступ

Майже всі починають однаково: «ну дата ж приходить як текст — значить, я й зберігатиму її як текст». І справді: користувач вводить 2026-01-06, у файлі це теж буде рядок, у JSON — теж рядок… То чому б не залишити так у коді? Саме на цьому місці багато програм «усміхаються», кивають і… тихо готують вам сюрпризи в продакшені.

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

Розберімо три великі класи проблем:

  1. порівняння рядків не дорівнює порівнянню дат;
  2. рядок не гарантує валідності дати;
  3. формати можуть бути неоднозначними, і «один і той самий» запис може означати різні дати.

2. Порівняння рядків: чому дата стає «словом»

Коли ми порівнюємо рядки, Kotlin робить це лексикографічно: приблизно як слова у словнику — зліва направо, за символами. Така поведінка вбудована в String і загалом у механізм порівняння об’єктів, які мають «природний порядок». Для рядків цей порядок саме лексикографічний.

І ось тут починається магія рівня «чому моя бухгалтерія вирішила, що лютий раніше за січень».

Приклад: два «гарні» формати, один баг

Виглядає як дата, але порівнюється як текст:

fun main() {
    val a = "01.02.2026"
    val b = "12.01.2026"

    println(a < b) // true (але за календарем 01.02.2026 пізніше за 12.01.2026)
}

Чому true? Тому що рядок "01.02.2026" починається з '0', а "12.01.2026" — з '1'. Для рядків цього вже достатньо: '0' < '1', отже весь рядок менший.

Якщо у проєкті ви сортуєте список подій за такими рядками, то отримаєте «гарно відсортований хаос».

«Але в мене ISO-формат — там усе нормально!»

Так, існує формат, який випадково доволі непогано «дружить» із лексикографічним порівнянням, — yyyy-MM-dd (рік-місяць-день). Він часто поводиться «правильно» під час порівняння рядків, але це не робить його типом дати.

fun main() {
    val iso1 = "2026-01-06"
    val iso2 = "2026-12-31"

    println(iso1 < iso2) // true (і тут збіглося з календарем)
}

Чому збіглося? Тому що більш значущі частини (рік, потім місяць, потім день) стоять лівіше. Але це все одно порівняння тексту. Достатньо найменшого відхилення від формату — і «магія» зламається.

Мікропастка: «без провідних нулів» усе ламається

Дуже поширена ситуація: хтось вирішив зберігати місяць як 1, а не 01. Зовні — «ну яка різниця, людина ж зрозуміє». А от сортування — не зрозуміє.

fun main() {
    val d1 = "2026-1-10"
    val d2 = "2026-01-11"

    println(d1 < d2) // false (а за календарем 10 січня раніше за 11 січня)
}

Лексикографічно "2026-1-10" порівнюється не так, як "2026-01-10". І якщо ви думаєте «ми завжди писатимемо правильно», то маю для вас новини: одного дня це зробить користувач. Або ваш колега. Або ви самі в п’ятницю ввечері.

3. Валідність: рядок дозволяє «2026-13-40» і не червоніє

Після порівняння зазвичай прилітає другий удар: рядок не гарантує, що дата взагалі існує.

fun main() {
    val s = "2026-13-40"
    println(s) // 2026-13-40 (виглядає впевнено, живе зухвало, але не існує)
}

Рядок не знає, що місяць має бути від 1 до 12, що в лютому не завжди 29 днів, а 31 червня — фантастика. Це не «помилка Kotlin». Так і має бути: String не зобов’язаний бути календарем.

«Тоді я сам перевірю через split!»

Звучить бадьоро. На практиці ви швидко з’ясуєте, що «дата» — це не просто три числа, а три числа плюс чимало правил.

Ось приклад дуже спрощеної (і навмисно неповної) перевірки ISO-формату:

fun isIsoDateRough(s: String): Boolean {
    val parts = s.split("-")
    if (parts.size != 3) return false

    val year = parts[0].toIntOrNull() ?: return false
    val month = parts[1].toIntOrNull() ?: return false
    val day = parts[2].toIntOrNull() ?: return false

    return year >= 1 && month in 1..12 && day in 1..31
}

Ця функція корисна як навчальна сходинка, але вона пропускає масу «неможливих» дат: 2026-02-31, 2026-04-31, 2026-11-00 і так далі. Щоб зробити по-справжньому правильно, вам доведеться реалізувати правила календаря, високосні роки, кількість днів у кожному місяці… І в якийсь момент ви зрозумієте, що винаходите календар заново. Зазвичай це невигідна угода.

Чому «регулярний вираз» теж не рятує

Можна написати Regex("""\d{4}-\d{2}-\d{2}"""), і він гарантуватиме лише те, що рядок виглядає як 2026-13-40. Тобто регулярний вираз перевіряє «чи схоже на дату», але не «чи є датою».

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

4. Формат: «01.02.2026» — це 1 лютого чи 2 січня?

Третя велика проблема — неоднозначність форматів. Людина (і програма) мають домовитися, що означають частини дати.

Коли ви бачите 01.02.2026, це може бути:

  • 1 лютого 2026 (формат dd.MM.yyyy, популярний у Європі),
  • 2 січня 2026 (формат MM.dd.yyyy, поширений у США в побуті).

І найнеприємніше: обидві дати валідні, тобто простими перевірками ви помилки не спіймаєте.

fun main() {
    val s = "01.02.2026"
    println(s) // рядок один, а смислів — щонайменше два
}

Хороша звичка: розділяти зберігання й відображення

Навіть якщо ви поки що змушені зберігати дату рядком (наприклад, на ранній стадії проєкту), корисно домовитися про таке:

  • усередині програми дата зберігається в одному строгому форматі (часто ISO yyyy-MM-dd),
  • користувачеві показуємо у звичному вигляді (наприклад, dd.MM.yyyy),
  • введення користувача нормалізуємо (або принаймні перевіряємо) на вході.

Це ще не фінальне рішення (ми досі не використовуємо типи дати), але вже сильний крок до адекватності.

5. Мінікейс: витрати з датою, які «не сортуються»

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

Початковий варіант моделі даних часто виглядає так:

data class Expense(
    val title: String,
    val amount: Int,
    val date: String // "як увів користувач"
)

І далі хочеться зробити «показати витрати за датою» або «відсортувати за датою».

Сортування за рядком: працює… доки не перестане

data class Expense(val title: String, val amount: Int, val date: String)

fun main() {
    val expenses = listOf(
        Expense("Кава", 250, "12.01.2026"),
        Expense("Проїзд", 70, "02.02.2026"),
        Expense("Піца", 900, "05.01.2026"),
    )

    val sorted = expenses.sortedBy { it.date }
    println(sorted.joinToString("\n"))
}

Із високою ймовірністю ви отримаєте порядок, який з погляду календаря здаватиметься «випадковим». Тому що sortedBy сортує за тим, що ви дали, — за рядком, — а рядок порівнюється лексикографічно.

«Гаразд, зберігатимемо дату в ISO-рядку»

Це краще, ніж dd.MM.yyyy, тому що ISO частіше правильно сортується як текст. Давайте «поліпшимо» модель, але все ще залишимо рядок:

data class Expense(val title: String, val amount: Int, val isoDate: String)

fun main() {
    val expenses = listOf(
        Expense("Кава", 250, "2026-01-12"),
        Expense("Проїзд", 70, "2026-02-02"),
        Expense("Піца", 900, "2026-01-05"),
    )

    val sorted = expenses.sortedBy { it.isoDate }
    println(sorted.map { it.isoDate + " " + it.title }.joinToString("\n"))
    // 2026-01-05 Піца
    // 2026-01-12 Кава
    // 2026-02-02 Проїзд
}

Це вже схоже на перемогу. Але важливо чесно собі сказати: ми просто обрали формат, який вдало «маскує» проблему порівняння. Валідність усе ще не гарантована, арифметика «плюс 1 день» усе ще ручна, а «час події» (якщо він знадобиться) одразу принесе новий біль.

6. Чому рядок — погана внутрішня модель

У цьому розділі важливо трохи пригальмувати й сформулювати думку, яка потім супроводжуватиме вас усю кар’єру: внутрішні типи програми мають відображати зміст даних. Ввід і вивід майже завжди рядкові, але всередині програми мають жити змістові сутності.

Рядки зручні у трьох місцях:

  • користувацьке введення (readln() завжди дає String);
  • вивід на екран (людині потрібен текст);
  • зберігання/обмін (файли, JSON, мережеві протоколи).

Але щойно ви починаєте робити «логіку» — порівняння, сортування, обчислення, перевірку коректності, — рядок стає пасткою.

Невелика таблиця: чим погана дата-рядок

Задача Якщо дата —
String
Що йде не так
Порівняти «раніше/пізніше»
a < b
порівняння лексикографічне, залежить від формату
Перевірити коректність
split
,
Regex
легко пропустити неможливі дати
Додати «+ 1 день» вручну потрібні правила календаря, місяці різної довжини
Підтримати різні формати «якщо крапка — одне, якщо дефіс — інше» код розростається й стає крихким
Опрацювати помилку формату винятки/
null
легко «завалити» програму без акуратного опрацювання

Як зазвичай народжується баг: «рядок протік у логіку»

Щоб закріпити, корисно побачити це як потік даних. Найчастіше помилка з’являється тоді, коли рядок (пакування) раптом стає частиною внутрішньої логіки.

flowchart TD
    A[Користувач увів дату рядком] --> B[Ми зберегли String 'як є']
    B --> C[Сортуємо / порівнюємо / рахуємо]
    C --> D[Отримуємо баг: порядок неправильний або дата неможлива]
    D --> E[Починаємо лагодити милицями: split, regex, if-else]
    E --> C

Замкнене коло: що більше милиць, то складніший код — і тим більше в ньому нових багів.

7. Що робити зараз, якщо типів дати ще немає

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

Централізуйте правила формату: одна функція нормалізації

Зробімо маленьку утиліту: вона приймає рядок, намагається привести його до ISO yyyy-MM-dd, а якщо не може — повертає null. Це не ідеальна календарна валідація, але вже хороша практика: правила в одному місці, контракт зрозумілий.

fun normalizeIsoDateOrNull(input: String): String? {
    val s = input.trim()
    val parts = s.split("-")
    if (parts.size != 3) return null

    val year = parts[0].toIntOrNull() ?: return null
    val month = parts[1].toIntOrNull() ?: return null
    val day = parts[2].toIntOrNull() ?: return null

    if (month !in 1..12) return null
    if (day !in 1..31) return null

    val mm = month.toString().padStart(2, '0')
    val dd = day.toString().padStart(2, '0')
    return "${year}-$mm-$dd"
}

Так, тут усе ще є спрощення «день до 31». Але ідея корисна: нормалізуємо формат і повертаємо null замість падіння.

Використовуйте «прочитав → підготував → перевірив → зберіг»

Цей патерн ви вже робили для чисел. Для дати-рядка він теж працює:

fun main() {
    print("Введіть дату покупки (yyyy-M-d): ")
    val raw = readln()

    val iso = normalizeIsoDateOrNull(raw)
    if (iso == null) {
        println("Некоректна дата: '$raw'") // зрозуміле повідомлення
        return
    }

    println("Зберігаю дату у форматі ISO: $iso") // наприклад: 2026-01-06
}

Ми не «лікуємо» календар на 100 %, але принаймні перестаємо зберігати «що завгодно».

Календарна дата й момент часу — різні сутності

У побутовому мовленні «дата/час» — одне слово. У програмуванні це щонайменше дві різні сутності:

  • календарна дата: «6 січня 2026» (без часу доби);
  • момент часу: «2026-01-06 10:15:30» (а інколи ще й з часовим поясом).

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

Сьогодні наша мета — не розв’язати це повністю, а побачити, чому рядок як внутрішня модель приречений. У наступних лекціях ми підійдемо до розв’язання через типи дати й часу, де компілятор почне вам допомагати, а не мовчки спостерігати за пожежею.

8. Типові помилки під час роботи з датою як із рядком

Помилка №1: порівнювати дати операторами <, >, sorted() за рядком «як увів користувач».
Це майже завжди дає лексикографічний порядок, який залежить від формату й легко ламається на провідних нулях, різних роздільниках і змішаних стилях. Навіть якщо «ніби працює» на тестових даних, це крихка удача, а не гарантія.

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

Помилка №3: перевіряти дату регулярним виразом або split і думати, що тепер усе коректно.
Перевірка форми (\d{4}-\d{2}-\d{2}) не перевіряє зміст. Дуже легко прийняти 2026-13-40 або 2026-02-31 за «нормальну дату», якщо перевіряти лише кількість цифр і роздільники.

Помилка №4: змішувати формати в одному проєкті без явного контракту.
Коли частина коду очікує dd.MM.yyyy, частина — yyyy-MM-dd, а частина взагалі «як прийшло», програма починає «інколи працювати». Це найгірший режим: вона не падає одразу, а тихо псує дані й звіти.

Помилка №5: розмазувати форматування по всьому коду замість однієї-двох функцій.
Якщо ви в десяти місцях пишете "$day.$month.$year" і в п’яти місцях парсите через split("."), то за першої ж зміни вимог (наприклад, захотіли dd/MM/yyyy) у вас буде веселе «сафарі» проєктом. Централізовані функції форматування й нормалізації — це проста звичка, яка економить години життя.

Помилка №6: не опрацьовувати «погане введення» й розраховувати, що користувач завжди введе правильно.
Будь-який парсинг і будь-яка валідація можуть закінчитися помилкою. Це не «рідкісний випадок», це нормальна гілка виконання. Якщо ви не закладаєте її в логіку (через null, runCatching, try/catch), програма або падає, або зберігає сміття — і обидва варіанти однаково сумні.

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