Знаете ли вы, в чём разница между 'Y' и 'y' символами в паттерне даты в Java? В этой статье мы рассмотрим, как неправильное форматирование даты может привести к ошибке, а также расскажем вам про нашу новую диагностику V6122 для языка Java, которая убережёт вас от внезапных путешествий во времени.

Вступление

Сдув пыль с нашего большого блокнота под названием "TODO", мы наткнулись на один очень интересный кейс. Потенциальную проблему нам описали в комментарии к статье.

Комментарий.

Кстати, вот вам идея на заметку: SimpleDateFormat в Java может подготовить сюрприз к Новому году: год, написанный маленькими буквами совсем не то же самое, что год, написанный большими. В последнюю неделю старого года вы можете вдруг оказаться в будущем, потому что "YYYY" — это 2022 для дат 27.12.2021 — 31.12.2021, а не 2021, как кто-то может ожидать (Пунктуация и орфография сохранены. — Прим. ред.)

Давайте разбираться.

Анализ проблемы

Форматирование дат

Если вы вдруг забыли, что это за SimpleDateFormat такой, то можно освежить свои знания.

Для хранения и отображения дат зачастую нужно, чтобы они соответствовали какому-то определённому паттерну. И SimpleDateFormat — это класс, который позволяет нам удобно форматировать дату в соответствии с заданным паттерном. Также, помимо форматирования, SimpleDateFormat умеет парсить строки, превращая их в объект даты.

Это всё, что предлагает нам Java? Помимо SimpleDateFormat, форматировать даты умеет и класс DateTimeFormatter.

Давайте я покажу вам пример использования этих двух классов.

Форматирование даты через SimpleDateFormat:

public static void main(String[] args) {
    Date date = new Date("2024/12/31");
    var dateFormatter = new SimpleDateFormat("dd-MM-yyyy");
    System.out.println(dateFormatter.format(date));
}

Вывод в консоль:

31-12-2024

Что здесь произошло?

У нас есть дата date, и мы хотим сохранить/отобразить её нужным нам образом. Мы создали объект SimpleDateFormat, передав в конструктор строку-паттерн. Дата будет отформатирована именно в соответствии с этим паттерном. Метод format возвращает строковое представление отформатированной даты. Именно то, что мы и хотели.

То же самое, но через DateTimeFormatter:

public static void main(String[] args) {
    LocalDate date = LocalDate.of(2024, 12, 31);
    var formatter = DateTimeFormatter.ofPattern("dd-MM-yyyy");
    System.out.println(formatter.format(date));
}

Вывод в консоль:

31-12-2024

Те же действия и тот же результат, но есть небольшое отличие — DateTimeFormatter позволяет форматировать лишь те даты, которые представлены классом, реализующим интерфейс TemporalAccessor. К примеру, таковыми являются классы LocalDate и LocalDateTime. SimpleDateFormat форматирует лишь объекты класса Date.

Вернёмся к комментарию. Действительно ли при использовании 'Y' в паттерне даты вместо 'y' результат может измениться?

Код:

public static void main(String[] args) {
    Date date = new Date("2024/12/31");
    var dateFormatter = new SimpleDateFormat("dd-MM-YYYY");
    System.out.println(dateFormatter.format(date));
}

Вывод в консоль:

31-12-2025

Упс. А с DateTimeFormatter'ом будет также?

Код:

public static void main(String[] args) {
    LocalDate date = LocalDate.of(2024, 12, 31);
    var formatter = DateTimeFormatter.ofPattern("dd-MM-YYYY");
    System.out.println(formatter.format(date));
}

Вывод в консоль:

31-12-2025

Мы действительно улетели на год вперёд. Давайте разбираться.

Суть проблемы

Первым делом я отправился в документацию класса SimpleDateFormat. Вот небольшой фрагмент из таблицы, в котором описывается, как в паттерне даты интерпретируются интересующие нас буквенные символы:

Letter

Date or Time Component

Presentation

Examples

y

Year

Year

1996; 96

Y

Week year

Year

2009; 09

Week year? Попробую объяснить.

Week year — это год, основанный на номере недели в году. Что это и для чего нужно?

Существуют задачи, в рамках которых важен порядковый номер недели в году. Для того, чтобы определить порядковый номер недели, нам нужно определиться с тем, какую неделю считать первой. Ведь практически всегда один год сменяется другим так, что часть недели приходится на старый год, а другая часть — на новый. Тогда как нам определиться, к какому году эта неделя относится? Регламентирует это стандарт ISO-8601.

По этому стандарту первая неделя года обязана удовлетворять следующим условиям:

  • первый день недели — понедельник;

  • минимальное количество дней года в неделе — четыре.

Из этого можно сформулировать простое правило: неделя будет считаться первой в году, когда четверг приходится на январь.

Теперь немного нагляднее.

Возьмём дату из примера — 31.12.2024. Снизу приведён фрагмент календаря, охватывающий неделю, в которую входит наша дата (декабрь 2024 — январь 2025):

ПН

ВТ

СР

ЧТ

ПТ

СБ

ВС

30

31

1

2

3

4

5

Эта неделя будет считаться первой неделей 2025 года, поскольку удовлетворяет приведённым выше условиям (в ней пять январских дней). Поэтому, используя спецификатор 'Y', мы получим 2025 год.

Как вы можете догадаться, в случае с DateTimeFormatter'ом ситуация обстоит точно так же.

Причём важно уточнить, что путешествовать мы можем не только в будущее, но и в прошлое.

Давайте возьмём другую дату — 01.01.2027.

Будет ли первое января входить в первую неделю 2027 года? Снова обратимся к календарю.

Снизу приведён фрагмент календаря, охватывающий неделю, к которой относится наша дата (декабрь 2026 — январь 2027):

ПН

ВТ

СР

ЧТ

ПТ

СБ

ВС

28

29

30

31

1

2

3

Поскольку в рамках этой недели всего три январских дня (а по условиям первой недели должно быть 4), то она будет считаться последней неделей 2026 года. Соответственно, наша дата при форматировании с использованием 'Y' символа отобразит нам 2026 год.

Доказательства в студию. Код:

public static void main(String[] args) {
    LocalDate date = LocalDate.of(2027, 1, 1);
    var formatter = DateTimeFormatter.ofPattern("dd-MM-YYYY");
    System.out.println(formatter.format(date));
}

Вывод в консоль:

01-01-2026

Итак, проблема разобрана. Нам она показалась достаточно интересной, чтобы добавить соответствующую диагностику в анализатор PVS-Studio для языка Java.

Ошибки в реальных проектах

Диагностика написана, пришло время тестировать.

При разработке анализатора один из этапов тестирования — прогон регрессионных тестов. На нашем сайте есть статья о том, как этот процесс у нас реализован. Если вкратце, то при добавлении новой диагностики мы анализируем большой пул Open Source проектов и сравниваем новые отчёты с эталонными.

В случае с этой диагностикой на нескольких проектах новые срабатывания были. Предлагаю на них взглянуть.

Bouncy Castle

В этом проекте фрагменты кода, на которые указывает диагностика, идентичные, поэтому я приведу только один из них.

Код:

public Builder setPersonalisation(Date date, .... {
    ....
    final OutputStreamWriter 
        out = new OutputStreamWriter(bout, "UTF-8");
    final DateFormat 
        format = new SimpleDateFormat("YYYYMMdd");   // <=
    out.write(format.format(date));
    ....
}

Предупреждение PVS-Studio:

V6122 Usage of 'Y' (week year) pattern was detected: it was probably intended to use 'y' (year). SkeinParameters.java 246

Первым делом я решил заглянуть на GitHub. Вдруг, если это действительно ошибка, разработчики обнаружили это и закоммитили исправления? Так и произошло. Вот ссылка на коммит, можете ознакомиться. Все наши 'Y' (week year) символы в паттерне заменили на 'y' (year).

И здесь у вас может возникнуть вопрос, почему коммиты были залиты относительно давно. Давайте объясню. Задача наших регрессионных тестов заключается не в том, чтобы мы непрерывно контролировали качество того или иного Open Source проекта. Задача состоит в том, чтобы посмотреть, как при добавлении новой диагностики изменится отчёт: не должны пропасть старые срабатывания, не должны вылезти ошибки, которые сигнализируют о том, что анализатор сломался. Соответственно, проверяемый код должен быть одним и тем же.

Opengrok

Теперь давайте взглянем на второй проект, в котором сработала диагностика.

Срабатывание PVS-Studio:

V6122 Usage of 'Y' (week year) pattern was detected: it was probably intended to use 'y' (year). RepositoryInfo.java 77

Код:

public class RepositoryInfo implements Serializable {
    ....
    protected static final SimpleDateFormat 
        outputDateFormat = new SimpleDateFormat("YYYY-MM-dd HH:mm Z"); 
    ....
}

По аналогии с предыдущим проектом я побежал смотреть, что там с коммитами. Для начала стоит отметить, что в результате рефакторинга (ссылка на коммит) поле переехало к его классу-наследнику Repository. Я пошёл искать дальше и нашёл коммит с исправлением. 'Y' символ в паттерне даты заменили на 'y':

public abstract class Repository extends RepositoryInfo {
    ....
    protected static final SimpleDateFormat 
        OUTPUT_DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm Z");
    ....
}

Значит, и здесь это было ошибкой.

Заключение

Мы в PVS-Studio рады любому фидбэку от сообщества. Поработав с комментарием от пользователя с Хабра, мы сделали Java-анализатор немного лучше, добавив полезную диагностику. Так что, если у вас есть какие-то мысли, которыми вы хотите с нами поделиться, мы с радостью пообщаемся с вами в комментариях к этой статье.

К слову, эта диагностика вышла в нашем октябрьском релизе 7.33, поэтому если у вас появилось желание попробовать наш анализатор, вы можете сделать это по этой ссылке.

А на этом всё, буду с вами прощаться. Надеюсь, внезапное путешествие на год вперёд (или назад) не застигнет вас врасплох.

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Vladislav Bogdanov. YYYY? yyyy!.

Комментарии (22)


  1. TimReset
    14.11.2024 12:37

    Забавно - была такая же ошибка в проде у нас несколько лет назад. Очень тогда удивился что есть week year. В итоге сделал check style правило что нельзя писать YYYY, что бы никто больше не использовал.


    1. areful
      14.11.2024 12:37

      Подорвался на этой штуке лет 8 - 10 назад. Ошибочно заблокировал несколько тысяч учетных записей. Запомнился этот week year на всю жизнь.


  1. GGribkov
    14.11.2024 12:37

    Отличная статья, и заголовок тоже! Выглядит, правда, знакомо:

    https://habr.com/en/companies/pvs-studio/articles/484166/


    1. vlade1k Автор
      14.11.2024 12:37

      Спасибо большое. Название статьи своего рода отсылка, как раз таки на вашу статью)


  1. Mingun
    14.11.2024 12:37

    Круто! Рад, что реализовали мое предложение (не понял правда, на что пеняли в орфографии, вроде все в порядке...)


    1. vlade1k Автор
      14.11.2024 12:37

      Вам большое спасибо) Не подумайте, в случае с орфографией и пунктуацией мы ни на что не пеняли. То примечание оставлено для того, чтобы читатели знали, что мы ваш комментарий поместили в статью ровно таким, каким он был. Без каких-либо изменений с нашей стороны


  1. TimsTims
    14.11.2024 12:37

    new Date("2024/12/31");

    По привычке глаз дёргается когда подставляется дата текстом в класс с полной уверенностью, что она там сама разберётся где что.

    В некоторых случаях , в зависимости от локали или поведения пользователя (пользователь например из США) - дни и месяцы могут быть спутаны местами, и при этом проходить валидацию, например: 2024/12/11 - и вот сиди гадай - это 11 декабря, или 12 ноября? А как код это примет распознает? А точно ли мы дату проверяем на входе, а не просто просим пользователя ввести циферки? А точно ли сервер правильно локаль выставил, чтобы автоматически порядок yyyy mm dd понимал?)


    1. Staya_Krokodilov
      14.11.2024 12:37

      С датами вообще аккуратнее бы, там и с точкой, как выяснилось, некоторые js библиотеки читают наоборот. А в каком виде дату отдаст база после обновления - лотерея, приходилось на стороне базы выдачу сразу форматировать, чтобы ошибки исключить, возникшие после обновления сервера.


  1. NoYesDontKnow
    14.11.2024 12:37

    Когда отойдете от точечных диагностик, реализация которых обходится вам в 10-30 строк кода ? На них далеко не уедите

    Когда уже двинетесь в сторону spring, на котором все сидят ?


    1. vlade1k Автор
      14.11.2024 12:37

      За последнее время у нас вышло большое количество диагностик достаточно сложных по реализации. Для большинства из них нужно было внести весомое количество правок и расширений в наш data-flow механизм, что представляет из себя на порядок больше, чем 10-30 строк кода.

      В случае со Spring мы сами за. В будущем Spring специализированные диагностики мы обязательно добавим


      1. NoYesDontKnow
        14.11.2024 12:37

        Кстати, у меня крепко-накрепко засела мысль, что у вас датафлоу сидит на С++(давно было не помню, то ли в ранних статьях, то ли на конференциях/кулуарах узнал)

        Какой плюс от этого был еще, кроме быстрого запуска анализатора, который на старте что-то умеет? А потом стагнация на 5 лет - вроде ничего такого вы не публиковали и не "хвастались" (

        Только диагностики, которые охватывают дай бог скоуп всего класса

        Когда планируете ударными силами джавовый кор развивать? Может на первых порах будет затишье с вашей стороны, но потом можно навернео сказать про вас "Русские долго запрягают, но быстро едут" :D

        Ведь проще развивать и искать людей на развитие всего этого

        С с++ кором экспертизы на человека одного должно быть сильно больше либо задействование профильных коллег,

        Отсюда наверное и весомое количество правок и расширений пришлось накрутить и много времени потратить пришлось ИМХО


        1. Volokhovskii
          14.11.2024 12:37

          Плюсы нашего DataFlow заканчиваются на языке, на котором он написан (простите, не удержался :))
          Тогда казалось что неплохо переиспользовать уже готовый код, но по итогу это действительно проблематично поддерживать именно в таком виде. Поэтому и по сумме других накопившихся проблем активное развитие на некоторое время притихло.

          Зато теперь оно идёт ударными темпами и, надеюсь, уже совсем-совсем скоро покажем расскажем о новых технологиях, реализованых на Java. Можно сказать, что весь этот год как раз "запрягали" :) - всё же надо не только ядро прокачивать, но и над самим анализатором работать.


      1. NoYesDontKnow
        14.11.2024 12:37

        А еще стремно, что запуская Java анализатор под капотом еще и подгружается c++ библиотека =)
        У многих настроена "безопасность" все дела, и чтобы скачать потыкать посмотреть рассказать банально может не получиться тк "безопасность" это не даст. Нужно будет пройти некоторый путь чтобы это все же сделать, легче махнуть рукой: "штош я попытался"


  1. ImagineTables
    14.11.2024 12:37

    И какой вывод? Помимо необходимости покупки PVS-Studio, конечно.

    Я бы сделал такой. Авторы форматтеров соблазнились на краткую запись (dd, MM и т.д.), чтобы форматирующие строки выглядели наглядно. Но когда потребовалось ввести дополнительный функционал (week year) в рамках той же концепции повышения наглядности, это на самом деле привело к закладыванию чудовищной мины. А вот если бы они не гонялись за наглядностью, и использовали что-то типа {year} и {weekyear} (или, как вариант, {year} и {year:week}), проблемы бы не возникло никогда. Обобщить в виде правила довольно трудно, но на уровне интуиции это весьма знакомая ситуация. «Если сделать так, код/формула/DSL/выражение будет выглядеть почти как запись на человеческом языке». — «…Но при этом пострадает универсальность, поэтому лучше воздержаться!».


    1. Maccimo
      14.11.2024 12:37

      И какой вывод?

      Баги будут всегда, на работу лучше приходить выспавшимся, javadoc стоит читать, но и это не даст стопроцентной гарантии.

      А вот если бы они не гонялись за наглядностью, и использовали что-то типа {year} и {weekyear} (или, как вариант, {year} и {year:week}), проблемы бы не возникло никогда.

      Те же баги, вид сбоку.
      Или вы никогда не печатали совершенно другое слово вместо нужного?
      Кто-то внезапно заржёт в опенспейсе, жена сковородку на кухне уронит или в наушниках начнётся любимая песня, отвлёкся и — привет.

      Баг возник не из-за лаконичности, а из-за выбранного символа. Если бы вместо Y для week year выбрали какой-нибудь Q, то ошибиться было бы сложнее.


      1. ImagineTables
        14.11.2024 12:37

        Если выбрать Q, пропадает весь смысл в схеме форматирования, когда форматирующая строка понятна без чтения документации. Зато появляется риск, что Q встретится где-нибудь в обрамлении и будет интерпретирована как элемент форматирования. У китайцев, например, часто встречается QQ, что, насколько я понимаю, соответствовало бы двухзначной записи недельного года. То есть, Q это худшее из обоих подходов. И небезопасно, и не наглядно.

        Сделать и безопасно, и наглядно в данном случае невозможно, и стоило бы сосредоточиться на безопасности, например, использовав схему {type:flags} или любую аналогичную.

        Баги будут всегда

        Это не оправдание, чтобы не думать, откуда взялась ошибка в каждом конкретном случае, и как предотвратить этот тип ошибок в будущем.


        1. Maccimo
          14.11.2024 12:37

          форматирующая строка понятна без чтения документации.

          Мы с вами общаемся в комментариях к статье, которая показывает, что эта точка зрения — опасное заблуждение, ведущее к трудноуловимым багам.

          Авторы приведённых фрагментов кода тоже, вероятно, думали «год — это четыре игрека, смысла открывать JavaDoc нет». И code review этот код мог пройти примерно по таким же соображениям. Но, как оказалось, есть нюанс.

          Вот фрагмент таблицы из JavaDoc к SimpleDateFormat:

          | Letter | Date or Time Component |
          |------- + -----------------------|
          | H      | Hour in day (0-23)     |
          | k      | Hour in day (1-24)     |
          | K      | Hour in am/pm (0-11)   |
          | h      | Hour in am/pm (1-12)   |
          

          Вам действительно понятно без чтения документации что это за K, k такие и что они означают?

          Не читаешь документацию ≡ стреляешь себе в ногу.

          Зато появляется риск, что Q встретится где-нибудь в обрамлении и будет интерпретирована как элемент форматирования.

          Только если не читать JavaDoc.
          SimpleDateFormat в Java 21 в качестве специальных символов используется 15 латинских букв из 26. Это если не учитывать регистр. Обрамление должно быть заключено в одинарные кавычки, иначе жизнь разработчика будет полна сюрпризов.

          То есть, Q это худшее из обоих подходов. И небезопасно, и не наглядно.

          Наткнувшись во время code review на Q ревьювер задался бы вопросом «что это вообще за хрень?» и полез в JavaDoc, тем самым эту безопасность повысив.

          Сделать и безопасно, и наглядно в данном случае невозможно, и стоило бы сосредоточиться на безопасности, например, использовав схему {type:flags} или любую аналогичную.

          Чем длиннее ключевые слова, тем выше вероятность использования метода «Copy-Paste» без критического осмысления.

          Это не оправдание, чтобы не думать, откуда взялась ошибка в каждом конкретном случае, и как предотвратить этот тип ошибок в будущем.

          Ошибка могла взяться от невнимательности, из-за использования бредогенератора на основе языковой модели, из копипасты с StackOverflow. Из-за лени открыть JavaDoc, наконец.

          Предотвратить в общем случае никак, кроме дисциплины. Некоторые люди ничтоже сумняшеся коммитят код, который в IntelliJ IDEA чуть более чем полностью залит жёлтой краской и им не стыдно.

          Ну и что вы им сделаете?


          1. ImagineTables
            14.11.2024 12:37

            Я не знал, что они добавили K (я редко пишу на Java), но это значит, что они как раз и совершили ту самую ошибку с гипотетической Q: взяли худшее из обоих миров. С чем я их и поздравляю.

            А чтобы люди читали документацию, их не надо было соблазнять dd и MM.

            Вообще, не очень понимаю, о чём спор, если мы видим ситуацию примерно одинаково.


            1. Mingun
              14.11.2024 12:37

              А Q под номер квартала не занята разве уже? Странно, на Java же куча приложений написана, активно с финансами работающих, а там кварталы и полугодия очень часто встречаются.


  1. Cerberuser
    14.11.2024 12:37

    Интереса ради - а это безусловно выдаваемое замечание, или есть какие-то случаи (эвристические или явно указанные), когда YYYY в формате даты считается корректным?


    1. vlade1k Автор
      14.11.2024 12:37

      В данной диагностике есть исключение на ситуацию, когда в паттерне вместе с 'Y' используется 'w' (week in year)


  1. evlam
    14.11.2024 12:37

    Кодеру, который взял чужой проект, сделать глобальный поиск в коде: найти YYYY и попытаться разобраться зачем это там.