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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ