1. Введение
Почти все начинают одинаково: “ну дата же приходит как текст — значит, я и хранить её буду как текст”. И правда, пользователь вводит 2026-01-06, в файле это тоже будет строка, в JSON — тоже строка… так почему бы не оставить так в коде? На этом месте многие программы улыбаются, кивают и… тихо готовят вам сюрпризы на проде.
Проблема в том, что строка — это просто набор символов. Компилятор Kotlin видит в String только текст. Он не знает, что там “дата”, не проверяет её границы, не умеет складывать “плюс один день”, не гарантирует корректные сравнения “раньше/позже”. В результате вы получаете ситуацию: данные выглядят как даты, а по поведению — как обычный текст (потому что это и есть обычный текст).
Давайте разберём три больших класса проблем:
- сравнение строк не равно сравнению дат;
- строка не гарантирует валидность даты;
- форматы могут быть неоднозначными, и “одна и та же” запись может означать разные даты.
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, сетевые протоколы).
Но как только вы начинаете делать “логику” — сравнения, сортировки, вычисления, проверку корректности — строка становится ловушкой.
Небольшая таблица: чем плоха дата-строка
| Задача | Если дата — |
Что идёт не так |
|---|---|---|
| Сравнить “раньше/позже” | |
сравнение лексикографическое, зависит от формата |
| Проверить корректность | , |
легко пропустить невозможные даты |
| Добавить “+ 1 день” | вручную | нужны правила календаря, месяцы разной длины |
| Поддержать разные форматы | “если точка — одно, если дефис — другое” | код разрастается и становится хрупким |
| Обработать ошибку формата | исключения/ |
легко “уронить” программу без аккуратной обработки |
Как обычно рождается баг: “строка протекла в логику”
Чтобы закрепить, полезно увидеть это как поток данных. Почти всегда ошибка появляется, когда строка (упаковка) становится внутренней логикой.
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), программа либо падает, либо сохраняет мусор, и оба варианта одинаково грустные.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ