«Время - это наркотик. Слишком много его убивает ». (Терри Пратчетт, Маленькие боги)

Dart 2.0 станет последней версией, и мы воспользуемся возможностью, чтобы очистить некоторые из наших основных библиотек. Мы не можем проводить серьезный рефакторинг (хотя мы и не хотели этого), но небольшие улучшения и чистки возможны. Один из затронутых классов - DateTime (https://api.dartlang.org/stable/1.24.2/dart-core/DateTime-class.html).

В этом посте я пользуюсь возможностью выделить все трудности, с которыми сталкиваются даже простые реализации классов даты и времени. Важно отметить, что Dart не намеревается предоставлять полную библиотеку календаря со своими основными библиотеками. Полная библиотека календаря сложна и не нужна большинству пользователей. Таким образом, он должен находиться в отдельном пакете.

Dart DateTime - Дата EcmaScript

Класс DateTime Дарта был вдохновлен классом Date EcmaScript. Однако, несмотря на много общего, между этими двумя классами есть некоторые существенные различия. Для пользователей, знакомых с версией EcmaScript, этот раздел содержит их небольшое резюме. Это должно облегчить выполнение оставшейся части этого документа.

Для обоих языков дата-время - это всего лишь момент времени. В частности, он измеряет время, прошедшее с эпохи Unix (1970–01–01 00:00:00 UTC). В Dart мы используем микросекунды, тогда как EcmaScript использует разрешение миллисекунд.

Для каждого экземпляра DateTime Dart, кроме того, сохраняет бит, который отслеживает, хочет ли пользователь рассматривать этот объект как «локальный» или «UTC» экземпляр. Локальная версия учитывает часовой пояс и переход на летнее время, тогда как UTC никогда не имеет смещения. Экземпляр в Dart находится либо по местному времени, либо по всемирному координированному времени. EcmaScript Date не делает этого различия и предоставляет две версии всех методов: getDay() и getUTCDay(), getMonth() и getUTCMonth(), toString() и toUTCString(),…

Класс DateTime Дарта неизменяем. На нем нет setX методов. С одной стороны, это чище, но иногда setX методы удобны. В Dart 2.0 мы намерены улучшить класс DateTime, добавив метод with, который предоставит измененную копию экземпляра получателя.

Наконец, в Dart месяцы отсчитываются от 1. То есть новый DateTime (2017, 1, 20) возвращает дату в январе, а не в феврале (как это было бы в случае new Date(2017, 1, 20) в EcmaScript).

Различные смещения

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

Остров Лорд-Хау меняется всего на 30 минут. На другом конце спектра Станция Троллей (в Антарктиде) меняется на полные 120 минут. Поначалу это звучит правдоподобно: около южного полюса дневной свет сильно меняется, и действительно летом здесь намного больше света, чем зимой. Однако Troll Station меняет свое время в противоположном направлении: 8 часов утра во время летнего времени позже, чем когда летнее время не соблюдается.

Как оказалось, этому есть хорошее объяснение (не то, чтобы это упрощало реализацию): Troll Station - норвежская исследовательская станция. Станция находится так далеко на юге, что зимой все равно нет света, поэтому они решили выровнять свои часы с Норвегией на этот период, чтобы облегчить общение с Норвегией. Зимой на Станции Троллей в Норвегии проходит лето, где соблюдается DST: CEST (то же самое, что и в Париже). Таким образом, Troll Station необходимо изменить свои часы на два часа в направлении, противоположном направлению любой другой страны в южном полушарии.

Любой тест, связанный с местным временем, должен учитывать, что он может выполняться в одной из этих зон.

Который из?

Обычная задача - запросить у системы дату и время с заданными значениями. Например, new DateTime(2017, 10, 20, 15, 24). В UTC это никогда не проблема. Однако по местному времени возникает множество проблем. Первый - это действительно правильное вычисление. Операционные системы, как правило, выполняют большую часть работы, но есть ограничения. Позже мы рассмотрим некоторые из этих трудностей.

Вторая проблема заключается в том, что при переключении на летнее время иногда есть два возможных даты с одинаковыми значениями. Когда часы переводятся на час (или 30 минут, или 2 часа), повторно используются одни и те же значения даты и времени.

Другое направление не намного проще. Скажем, летнее время наступает в 2 часа ночи, а часы переводятся вперед на 3 часа ночи. Что должно произойти, когда пользователь запрашивает дату и время в: 2:00, 2:30 и 3:00?

Давайте сначала посмотрим на 2:00 и 3:00. Помните, что Dart DateTime хранит только метку времени (представленную значением microsecondsSinceEpoch), а также то, находится ли экземпляр в UTC или по местному времени. Это означает, что экземпляры для 2:00 и 3:00 должны быть одинаковыми. При разложении или печати значения библиотека должна сделать выбор: разложить его как 2:00 или как 3:00.

При запуске этой программы в США мы получаем следующий результат:

TZ=America/Los_Angeles dart dt.dart
2017–03–12 03:00:00.000
2017–03–12 03:00:00.000

Если мы не изменим Dart так, чтобы он запомнил, какое значение времени использовалось для построения даты и времени, невозможно узнать, какой вывод является предпочтительным. Таким образом, оба значения обрабатываются одинаково, и тот факт, что пользователь изначально запрашивал дату и время с часами 2:00, теряется.

Это не только теоретическая проблема. В некоторых странах, например в Бразилии, переход на летнее время осуществляется в полночь. В то время как очень немногие пользователи устанавливают дату и время на 2:00, пользователи действительно создают дату в 00:00. Чаще всего это происходит, когда для календарных дат используются дата-время. Тогда наиболее удобное время - 00:00.

Например, пользователь может захотеть представить дату падения Берлинской стены следующим образом: var berlinWallFell = new DateTime(1989, 11, 9);. При таком подходе сконструированная дата-время попадает непосредственно на переключатель DST.

В Бразилии 00:00 и 01:00 действительны при переходе на летнее время, и Dart (как и многие другие системы) выбирает 01:00:

$ TZ=Brazil/East dart brazil.dart 
15
1

По крайней мере, дата все еще верна. Раньше это было не так: старые версии Dart фактически перемещались на час назад:

$ TZ=Brazil/East dart-1.24 brazil.dart
14
23

Эта ошибка не была уникальной для Дарта. Многие (если не все) реализации EcmaScript имели ту же проблему. Afaik, Safari по-прежнему печатает неправильные 14 и 23.
Следующие строки из реализации Date V8 показывают причину этой широко распространенной ошибки:

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

Возвращаемся к нашему первоначальному переключению на летнее время в 2:00. Что должно произойти, если пользователь запрашивает неверную дату и время в 2:30?
var invalid = new DateTime(2017, 03, 26, 02, 30).

Никогда не бывает момента, когда часы показывают 2:30. Таким образом, наиболее разумное толкование: «30 минут после 2:00». Поскольку 02:00 и 03:00 одинаковы, это означает, что пользователь получает дату и время в 03:30. Это приводит к интересной ситуации, когда дата в 02:30 является после 03:00.

$ TZ=Europe/Paris dart /tmp/invalid.dart
dt1 is after dt2?: true

Недопустимые даты и время также необходимо учитывать, когда пользователи превышают / теряют значения. Когда пользователь запрашивает дату и время в 02:59:60, есть две интерпретации: «59 минут и 60 секунд после 2:00» или просто «03:00». Оказывается, библиотека C Linux делает различие:

В европейском часовом поясе это дает:

$ TZ=Europe/Paris ./a.out 
The two date-times are not the same.

Изменение

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

В 2009 летнее время было с 24 апреля по 21 августа.

Год спустя летнее время началось 30 апреля, было приостановлено с 11 августа по 10 сентября (из-за Рамадана). Наконец, 1 октября Египет снова перешел на летнее время.

Тогда в 2011 году в Египте летнее время вообще не было. С тех пор Египет не переходил на летнее время, за исключением 2014 года, когда он снова сделал 4 изменения за один год.

Изменение 2010 года вызвало множество проблем. Системы Windows не могли своевременно обновляться и не соблюдали правила летнего времени. На самом деле решение было принято настолько поздно, что в апреле 2010 года еще не было ясно, когда закончится летнее время. Из https://www.timeanddate.com/news/time/egypt-starts-dst-2010.html (опубликовано 21 апреля 2010 г.):

Дата окончания перехода на летнее время не подтверждена, сообщает Государственная информационная служба Египта.

Windows была не единственной жертвой. Из-за оптимизации движок JavaScript V8 Chrome также столкнулся с проблемами. В 2010 году самым популярным тестом JavaScript был SunSpider. Один из тестов выполнял множество Date операций, которые требовали поиска DST в разное (но относительно близкое) время.

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

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

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



Обратите внимание, что в Египте также изменилось время в полночь, что приводит к тем же проблемам, что и в Бразилии.

Изменения без перехода на летнее время

Очевидно, что переход на летнее время является наиболее частым источником сложностей при реализации даты и времени, но они не самые сложные…

Самоа и Токелау

В 2011 году Самоа и Токелау изменились не на час, а на два. Они пропустили целый день.

Самоа и Токелау расположены рядом с линией смены дат, что означает, что некоторые из их соседей (несмотря на то, что у них одинаковое время) находятся в один и тот же день, а другие отстают на день (сейчас). Чтобы укрепить свои связи со своими торговыми партнерами Новой Зеландией и Австралией, они решили перейти на другую сторону графика. Этот переключатель повлиял только на календари, поскольку время осталось прежним.

Самоа объявило об изменении в мае, но Токелау приняли решение только в октябре. Вот цепочка писем в списке рассылки базы данных Olson, о которой явно уведомили слишком поздно (обсуждение происходит за 14 часов до изменения):



Выравнивания

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

Например, Париж изменил свои часы на 9 минут и 21 секунду 11 марта 1911 года, за 5 лет до первой реализации DST. Подобные изменения произошли во многих странах в тот период. Например, Копенгаген перевел часы вперед на 9 минут 40 секунд в 1894 году.

Часовые пояса

Другие интересные факты о часовых поясах:

  • Некоторые часовые пояса включают смещение на полчаса по сравнению с UTC. В Индии, например, время UTC +5: 30. По крайней мере, один часовой пояс, острова Чатем, использует даже 45 минут: UTC +12: 45 / +13: 45.
  • Краткие названия часовых поясов не уникальны. Например, «IST» может означать «стандартное индийское время» (UTC +5: 30), «стандартное время Ирландии» (UTC +1: 00) или «стандартное время Израиля» (UTC +2: 00).

На следующем изображении (из Википедии) показаны регионы (кроме Антарктиды), где все местные часы были одинаковыми с 1970 года.

Stdlib

Лучший способ получить правильную дату и время - использовать базу данных Olson, которая является результатом совместных усилий по фиксации всех изменений времени. Поскольку база данных часто изменяется, программы обычно полагаются на операционную систему для выполнения обновлений, а затем используют системные вызовы для выполнения операций с датой и временем. C stdlib имеет несколько важных функций:

Они преобразуют представление времени с разбивкой (в struct tm) в календарное время (секунды с начала Эпохи, 1970–01–01 00:00 UTC).
Функции mktime и timegm возвращают -1 при обнаружении ошибки. Это прискорбно, поскольку -1 может также быть допустимым значением для даты и времени, близкого к эпохе… Нужно также проверить глобал errno, чтобы узнать, является ли -1 индикацией ошибки или просто правильным возвращаемым значением.

Более серьезная проблема с этими методами заключается в том, что стандарт не определяет тип для size_t и не дает никаких гарантий относительно интервала, в котором работают преобразования. Это приводит к противоречивому опыту на разных платформах. В MacOS, например, процедура mktime работает только для отрицательных 32 бит (до 1902 года), но работает для положительных 64 бит.

EcmaScript решил эту проблему, введя «эквивалентные годы», которые всегда производят вычисления в положительном диапазоне, который (в основном) гарантированно работает. Дарт скопировал этот подход.

Эквивалентные годы

Как упоминалось в предыдущем разделе, многие системы работают только в ограниченном диапазоне. Как следствие, ECMAScript предлагает сопоставить год ввода с эквивалентным годом (такой же високосный год и тот же начальный день недели для года), для которого среда хоста предоставляет информацию о переходе на летнее время. Единственное ограничение - все равнозначные годы должны давать одинаковый результат.

Фактический диапазон не определен в стандарте ECMAScript, но реализация V8 соответствует 2008–2037 гг.

Это имеет некоторые немедленные последствия: в течение многих лет, которые относятся к безопасному диапазону, летнее время может быть неточным. Например, даты могут переходить на летнее время еще до того, как было изобретено летнее время. На d8 (версия V8 для командной строки) у нас есть:

d8> new Date(1570, 07, 10)
Mon Aug 10 1570 00:00:00 GMT+0200 (CEST)

Обратите внимание на «CEST», что означает «Центральноевропейское летнее время».

Другая проблема возникает из-за того, что EcmaScript предлагает (и для многих версий «требуется»), что реализации работают с местными датами, как если бы они были сдвинуты в формате UTC. В этом есть смысл: UTC намного проще и позволяет полагаться на разумные предположения, например, в днях всегда 24 часа.

Чтобы вычислить свойство, такое как день недели, дата-время сначала преобразуется в UTC, так что время-дата в формате UTC имеет те же свойства, что и локализованная дата-время. Это просто состоит из вычитания полного смещения.

Точно так же, если реализации JavaScript хотят получить момент времени из разложенной локализованной даты, они сначала вычисляют дату в формате UTC, затем запрашивают у системы смещение зоны в это время и, наконец, добавляют значение ко времени значения UTC. они вычислили. (Это объяснение требует некоторых сокращений, но, как описано в предыдущем разделе, в любом случае спецификацию не следует воспринимать буквально.)

Однако, вместе с эквивалентными годами, есть интересный угловой случай. Может случиться так, что движки JavaScript начинают со значения в безопасном диапазоне, а затем корректируют ввод с учетом местного часового пояса, который выталкивает их из безопасного диапазона. (Или наоборот). Если смещение осталось прежним, это не имеет значения, но во многих странах правила изменились.

Рассмотрим пример: 1 января 1970 года в Лондоне. В 1970 году в Лондоне действительно было летнее время, даже зимой. Похоже, Firefox использует отрицательные значения в качестве значений отсечения для эквивалентного диапазона, что приводит к следующему выводу для jsshell (автономный исполняемый файл Firefox для движка JS):

TZ=Europe/London js
js> new Date(1970, 0, 1, 0, 0, 1).toString()
“Thu Jan 01 1970 01:00:01 GMT+0100 (BST)”

Обратите внимание, как мы запросили дату и время через одну секунду после полуночи, но получили Date, что на час позже. И у V8, и у Dart похожие проблемы (только в разное время).

Например, для v8:

$ TZ=America/Santo_Domingo v8
V8 version 6.2.0 (candidate)
d8> new Date(1969, 11, 31, 20, 0, 1)
Wed Dec 31 1969 21:00:01 GMT-0300 (-0430)

Обратите внимание, что запрошенное время было 20:00:01, но результат выдает 21:00:01.

Дарт имеет гораздо большую безопасную зону, поэтому проблема видна только для дат, которые намного раньше:

$ TZ=Australia/Canberra dart canberra.dart
1901–12–14 06:45:53.000
1901–12–14 07:45:52.999

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

Мы активно работаем над исправлением этой проблемы в Dart.

Рекомендации

В качестве общих рекомендаций по возможности используйте даты в формате UTC. С датами UTC можно доверять обычным предположениям. В частности, в сутках гарантировано 24 часа.

В Dart используйте конструктор DateTime.utc или установите для флагов isUtc значение true. Это особенно полезно при использовании DateTime в качестве класса календарной даты.

Будьте осторожны при использовании dt.add(new Duration(hours: 24)), чтобы получить дату и время с теми же часами, что и на приемнике. При переходе на летнее время день не имеет 24 часов, и это вычисление не даст желаемого результата.

Протестируйте свои программы в разных браузерах. Safari, Firefox и Chrome ведут себя по-разному в крайних случаях.

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

  • Европа / Париж
  • Антарктида / Тролль
  • Египет
  • Австралия / Канберра
  • Австралия / Lord_Howe
  • Америка / Лос-Анджелес
  • Азия / Калькутта
  • Бразилия / Восток