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

Парсинг и форматирование: 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 даже есть нюансы контрактов и поведения в сложных случаях, что видно по changelog’ам Kotlin. Но на нашем уровне важно простое: runCatching помогает не превращать код в лес из try/catch.

Почему мы возвращаем T?, а не строку ошибки

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

4. toString() как формат по умолчанию

Когда вы выводите объект в println, Kotlin автоматически вызывает 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 (как самый строгий и однозначный), потом пробуем русский формат.

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.

1
Задача
Kotlin SELF, 51 уровень, 3 лекция
Недоступна
Дата для людей
Дата для людей
1
Задача
Kotlin SELF, 51 уровень, 3 лекция
Недоступна
Дата без падений
Дата без падений
1
Задача
Kotlin SELF, 51 уровень, 3 лекция
Недоступна
Точки и проверка
Точки и проверка
1
Задача
Kotlin SELF, 51 уровень, 3 лекция
Недоступна
Короткое время
Короткое время
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ