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), программа либо падает, либо сохраняет мусор, и оба варианта одинаково грустные.

1
Задача
Kotlin SELF, 51 уровень, 0 лекция
Недоступна
Спорные даты
Спорные даты
1
Задача
Kotlin SELF, 51 уровень, 0 лекция
Недоступна
Ровный календарь
Ровный календарь
1
Задача
Kotlin SELF, 51 уровень, 0 лекция
Недоступна
Похожая дата
Похожая дата
1
Задача
Kotlin SELF, 51 уровень, 0 лекция
Недоступна
Дата туриста
Дата туриста
Комментарии (1)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
kasnil Уровень 66
26 апреля 2026
Все задачи 51 уровня успешно компилируются на дату 2026.04.26 с использование kotlinx-datetime версии не старше kotlinx-datetime/0.6.2 При использовании версии 0.7.0 и выше, некоторые задачи потребуют изменения кода на совместимость с версией 0.6.2 при отправке на проверку, иначе AI-валидатором могут быть не приняты.