JavaRush /Курси /Kotlin SELF /Парсинг і форматування: parse(), toString(), runCatching ...

Парсинг і форматування: parse(), toString(), runCatching і користувацький формат

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

1. Вступ

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

Парсинг — це перетворення рядка на тип (LocalDate, LocalDateTime, Instant). Форматування — зворотна операція: перетворення типу на рядок. Ці операції схожі на вхід і вихід у метро: переплутали — і ви вже не там, ще й із відчуттям, ніби хтось украв ваш здоровий глузд.

У цій лекції важливо втримати одну думку: ми не «зберігаємо дату рядком». Ми зберігаємо дату як дату, а рядок робимо лише «обгорткою» для людини.

2. ISO-8601 і parse()

Якби програмісти з усього світу зібралися й домовилися про єдиний формат дати, то, найімовірніше, це був би ISO‑8601. Він виглядає не дуже «по‑людськи», зате дуже «по‑машинному»: 2026-01-14 читається однозначно, сортується передбачувано й не змушує вгадувати, що посередині — місяць чи день. У kotlinx.datetime багато типів уміють парсити саме ISO‑рядки через parse().

Одразу домовімося про практичне правило: для обміну та зберігання використовуємо ISO (те, що розуміє parse()), а для відображення людині — можемо зробити свій формат (наприклад, dd.MM.yyyy). Таке розділення — як «внутрішня мова» застосунку та «мова інтерфейсу».

Невеликий приклад із LocalDate:

import kotlinx.datetime.LocalDate

fun main() {
    val d = LocalDate.parse("2026-01-14")
    println(d) // 2026-01-14
}

Тут println(d) викликає toString() (під капотом), а toString() для LocalDate зазвичай повертає ISO‑форму.

А ось приклад із Instant:

import kotlinx.datetime.Instant

fun main() {
    val t = Instant.parse("2026-01-14T12:30:00Z")
    println(t) // 2026-01-14T12:30:00Z
}

Зверніть увагу на Z: це «UTC». Для Instant важливо, щоб рядок задавав момент часу однозначно (наприклад, через Z або зміщення). Інакше ви отримаєте «локальний годинник без правил переведення», а це вже зовсім інша сутність.

Чому parse() може падати — і чому це нормально

Слова «може кинути виняток» звучать лячно, але у світі парсингу це звичайні будні. Якщо користувач увів 2026-13-40, така дата неможлива. Якщо увів hello, це взагалі не схоже на дату. І в цей момент бібліотека має повне моральне право сказати: «Я не розумію» — через виняток.

У попередніх темах ми вже бачили схожу історію з числами: toInt() може кинути NumberFormatException, якщо рядок не є числом. Тому для користувацького введення краще використовувати toIntOrNull().

З датою та часом ідея та сама. Тільки замість toIntOrNull() у нас зазвичай буде «власна функція …OrNull», побудована на runCatching.

3. Безпечний парсинг: try/catch і runCatching

Коли ви пишете консольний застосунок, користувацьке введення — це зовнішній світ. А зовнішній світ, як відомо, не зобовʼязаний бути акуратним. Тому парсинг має бути безпечним: некоректне введення не повинно «валити» застосунок. У Kotlin є класичний шлях через try/catch і компактніший — через runCatching.

Варіант через try/catch

У цьому підході ми явно перехоплюємо виняток і повертаємо null, якщо парсинг не вдався:

import kotlinx.datetime.LocalDate

fun parseIsoDateOrNull(input: String): LocalDate? {
    return try {
        LocalDate.parse(input.trim())
    } catch (e: Exception) {
        null
    }
}

Цей код читається нормально, але швидко розростається, якщо таких парсерів стає багато.

Варіант через runCatching

runCatching дозволяє компактно записати ідею «спробуй — якщо не вийшло, не падай»:

import kotlinx.datetime.LocalDate

fun parseIsoDateOrNull(input: String): LocalDate? =
    runCatching { LocalDate.parse(input.trim()) }.getOrNull()

Тут getOrNull() повертає результат у разі успіху або null у разі помилки. Це ідеально для шаблону «прочитав → підготував → розпарсив → перевірив».

Історично навколо runCatching навіть є нюанси контрактів і поведінки у складніших випадках — це видно з журналів змін Kotlin. Але на нашому рівні важливе просте: runCatching допомагає не перетворювати код на ліс із try/catch.

Чому ми повертаємо T?, а не рядок помилки

Можна спокуситися й повертати текст помилки: "Невірний формат дати". Але це швидко призводить до плутанини: функція наче парсить, а насправді повертає або дату, або текст. Контракт T? простіший: або тип вийшов, або null. А повідомлення користувачеві — це вже турбота шару введення/виведення (CLI), який вирішить, що саме друкувати.

4. toString() як формат за замовчуванням

Коли ви виводите обʼєкт у println, Kotlin automatically викликає toString(). Для типів із kotlinx.datetime toString() зазвичай повертає ISO‑подання. Це зручно з двох причин: воно однозначне, і його потім можна знову розпарсити через parse().

Тобто ви отримуєте природний «кругообіг даних» у застосунку:

flowchart LR
    A["Тип дати/часу"] -->|"toString()"| B["Рядок (зазвичай ISO)"]
    B -->|"parse()"| A

У цьому колі особливо важливо, що тип лишається типом, а рядок — рядком, і кожен інструмент робить свою роботу.

Приклад:

import kotlinx.datetime.LocalDate

fun main() {
    val original = LocalDate.parse("2026-01-14")
    val text = original.toString()

    val parsedBack = LocalDate.parse(text)

    println(text)       // 2026-01-14
    println(parsedBack) // 2026-01-14
}

У цей момент зазвичай зʼявляється думка: «Чудово, тоді завжди показуватимемо ISO користувачеві». І… інколи це справді нормально. Але часто користувачеві звичніше 14.01.2026, і тоді починається користувацьке форматування.

5. Користувацький формат і введення

Форматування «для людини» — це не про розумну математику, а про акуратне складання рядка. У базовому курсі ми не використовуватимемо складні форматери й локалізації. Натомість зробимо зрозумілу функцію форматування вручну, використовуючи padStart, інтерполяцію та поля дати.

Формат dd.MM.yyyy для LocalDate

import kotlinx.datetime.LocalDate

fun formatRu(date: LocalDate): String {
    val dd = date.dayOfMonth.toString().padStart(2, '0')
    val mm = date.monthNumber.toString().padStart(2, '0')
    return "$dd.$mm.${date.year}"
}

fun main() {
    val d = LocalDate.parse("2026-01-04")
    println(formatRu(d)) // 04.01.2026
}

Тут важливо, що ми робимо централізоване правило. Якщо завтра ви вирішите, що потрібен формат dd/MM/yyyy, ви зміните одну функцію, а не 25 println по всьому проєкту.

Формат dd.MM.yyyy HH:mm для LocalDateTime

LocalDateTime містить і дату, і час. Ми можемо розширити форматування:

import kotlinx.datetime.LocalDateTime

fun formatRu(dt: LocalDateTime): String {
    val dd = dt.dayOfMonth.toString().padStart(2, '0')
    val mm = dt.monthNumber.toString().padStart(2, '0')
    val hh = dt.hour.toString().padStart(2, '0')
    val min = dt.minute.toString().padStart(2, '0')
    return "$dd.$mm.${dt.year} $hh:$min"
}

fun main() {
    val dt = LocalDateTime.parse("2026-01-14T09:05:00")
    println(formatRu(dt)) // 14.01.2026 09:05
}

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

Користувацьке введення: парсимо dd.MM.yyyy безпечно

Тепер неприємна частина: LocalDate.parse() не зобовʼязаний розуміти формат 14.01.2026. Тому для користувацького формату ми робимо окремий парсер.

Тут легко потрапити в пастку «та я зараз на split за пʼять секунд зроблю». І так, зробите. Але потім зʼясується, що 32.01.2026 теж «розпарсилося», а це вже не дата, а фанфік за календарем. Тож правильніше діяти так: розібрати числа, а потім створити LocalDate через конструктор і перехопити виняток, якщо дата неможлива.

import kotlinx.datetime.LocalDate

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

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

    return runCatching { LocalDate(year, month, day) }.getOrNull()
}

Зверніть увагу: toIntOrNull() — це знайомий нам «безпечний парсинг числа», який запобігає NumberFormatException. У документації про винятки цей підхід показано як рекомендовану альтернативу небезпечному toInt().

«Розумний» парсер: приймаємо ISO або dd.MM.yyyy

У реальних застосунках часто хочеться бути добрими до користувача й приймати кілька варіантів формату. Але доброта має бути контрольованою — інакше ви почнете «вгадувати» введення та випадково трактувати одне як інше.

Компромісний варіант: спочатку пробуємо ISO (як найсуворіший і однозначний), потім пробуємо формат dd.MM.yyyy.

import kotlinx.datetime.LocalDate

fun parseDateFlexibleOrNull(input: String): LocalDate? {
    val s = input.trim()
    return runCatching { LocalDate.parse(s) }.getOrNull()
        ?: parseRuDateOrNull(s)
}

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

6. Вбудовуємо в застосунок: додаємо дату операції

Уявімо, що наш практичний консольний проєкт — це простий облік витрат. Раніше витрата могла складатися із суми та категорії. Тепер додамо дату операції як LocalDate. Наша мета тут не «зробити супер‑застосунок», а показати, як парсинг і форматування перестають бути хаосом, коли їх винесено в окремі функції.

Модель даних Expense з датою

import kotlinx.datetime.LocalDate

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

Так, тут лише три поля. І так, це вже робить застосунок на порядок зрозумілішим: дата — не рядок, а саме LocalDate.

Введення витрати: читаємо дату рядком і парсимо

import kotlinx.datetime.LocalDate

fun readExpenseDate(): LocalDate {
    while (true) {
        print("Дата (YYYY-MM-DD або dd.MM.yyyy): ")
        val input = readln()
        val date = parseDateFlexibleOrNull(input)
        if (date != null) return date
        println("Не вдалося розпізнати дату. Приклад: 2026-01-14 або 14.01.2026")
    }
}

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

Гарне виведення списку витрат: форматуємо дату окремо

fun printExpense(e: Expense) {
    val dateText = formatRu(e.date)
    println("${dateText} | ${e.category} | ${e.amount}")
    // 14.01.2026 | Food | 1200
}

У результаті виведення стає «людським», а дані всередині лишаються «машинними».

7. Повідомлення про помилки: акуратно не губимо причину

Іноді дуже хочеться пояснити користувачеві, «чому» не розпарсилося: наприклад, «місяць має бути від 1 до 12». Для цього під час налагодження можна дістати виняток із runCatching, але робити так всюди й завжди не варто: ви швидко почнете друкувати моторошні тексти на кшталт IllegalArgumentException: Invalid date..., які зрозумілі бібліотеці, але не людині.

Документація про винятки добре показує, що винятки бувають різними, а їхній текст — це передусім інструмент розробника, а не інтерфейс для користувача. Тому зазвичай роблять так: користувачеві — коротко й ясно, а детальну причину — в лог (якщо він є) або в налагоджувальне виведення.

Міні‑варіант «дістати причину» (наприклад, для налагодження):

import kotlinx.datetime.LocalDate

fun parseIsoDateDebug(input: String): LocalDate? {
    val result = runCatching { LocalDate.parse(input.trim()) }
    result.exceptionOrNull()?.let { e ->
        println("DEBUG: parse error = ${e.message}")
    }
    return result.getOrNull()
}

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

8. Типові помилки

Помилка № 1: парсити дату «як вийде» через split, але не перевіряти календарну коректність.
Часто новачки розбивають рядок на частини, перетворюють їх на числа й вважають, що на цьому все. Проблема в тому, що так можна прийняти 99.99.2026 як «валідну дату» на рівні типів Int. Правильніше — після розбору чисел усе одно створити LocalDate(...) і перехопити помилку через runCatching, щоб календар сказав своє чесне «ні».

Помилка № 2: викликати parse() напряму на користувацькому введенні без обробки помилок.
LocalDate.parse(...) і Instant.parse(...) розраховані на коректний рядок. Зовнішнє введення майже ніколи не гарантує коректності, тому код починає падати «у найнедоречніший момент» і псує UX. Натомість краще обгортати парсинг у runCatching { ... }.getOrNull() і повертати null, а далі вже спокійно перепитувати користувача.

Помилка № 3: змішувати «як зберігати» і «як показувати».
Якщо ви починаєте зберігати дату вже відформатованим рядком 14.01.2026, то далі самі собі ускладнюєте життя: сортування, порівняння, фільтри — усе знову перетворюється на роботу з рядками. Значно стійкіше зберігати LocalDate, а форматування робити лише перед виведенням — окремою функцією на кшталт formatRu(date).

Помилка № 4: розносити форматування по всьому коду замість однієї функції.
Коли формат дати розмазаний по println, за тиждень ви отримаєте три різні вигляди дати в одному застосунку: десь 2026-01-14, десь 14.1.2026, десь ще й без провідних нулів. Виправлення перетворюється на археологію. Централізована функція форматування розвʼязує це одразу й дисциплінує проєкт.

Помилка № 5: намагатися зробити «універсальний парсер усього» надто рано.
Іноді хочеться підтримати десять форматів, пробіли, слеші, коми та місячний календар. На практиці це перетворює введення на вгадування, а код — на набір крихких евристик. На базовому рівні краще тримати один формат обміну (ISO через parse()) і один формат для користувача (наприклад, dd.MM.yyyy). А якщо ви підтримуєте обидва — робіть це строго й передбачувано, як у parseDateFlexibleOrNull.

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