Что же всё-таки не так со структурой DateTime?

Замечания:
1. В предыдущей заметке "time zone" я перевёл как "временнАя зона", поскольку речь шла о часовых поясах США, имеющих специфическое название. В данном случае корректнее использовать "часовой пояс". Здесь используется более корректный перевод.

2. Небольшая врезка из Википедии даст вам понимание что такое UTC и чем оно отличается от GMT -

Всеми́рное координи́рованное вре́мя (UTC) — стандарт, по которому общество регулирует часы и время. Отличается на целое количество секунд от атомного времени и на дробное количество секунд от всемирного времени UT1.

UTC было введено вместо устаревшего среднего времени по Гринвичу (GMT). Новая шкала времени UTC была введена, поскольку шкала GMT является неравномерной шкалой и связана с суточным вращением Земли. Шкала UTC основана на равномерной шкале атомного времени (TAI) и является более удобной для гражданского использования.

Часовые пояса вокруг земного шара выражаются как положительное и отрицательное смещение от UTC.

Следует помнить, что время по UTC не переводится ни зимой, ни летом. Поэтому для тех мест, где есть переход на летнее время, меняется смещение относительно UTC.


Теперь продолжим разбираться со структурами, обслуживающими такие сущности, как дата и время.

Через некоторое время после публикации твита о Noda Time, меня начали спрашивать, какой смысл в использовании Noda Time - люди верили, что поддержка дат и времени в .NET вполне хороша. Я конечно не видел их код, но подозреваю, что практически любая кодовая база, имеющая дело с датами, станет яснее, если будет использовать Noda Time, а также, вполне возможно, станет более корректной благодаря подходу, с помощью которого Noda Time заставляет вас принимать некоторые, не очевидные в .NET, решения. В этой заметке мы обсудим недостатки .NET API, обеспечивающего работу с датами и временем. Моё отношение к этой теме выглядит несколько предвзятым, но я надеюсь, что эта заметка не выглядит неуважительно по отношению к команде, работающей над BCL (Base Class Library - прим. переводчика) - поскольку, кроме всего прочего, они работают в условиях, заставляющих их принимать во внимание взаимодействие с COM и т.п.

Что значит DateTime?

Когда я натыкаюсь на сайте Stack Overflow на вопрос, в котором говорится о том, что DateTime не делает того, чего от него ожидают, я часто обнаруживаю себя в состоянии размышления - что же конкретно должно было представлять указанное значение? Ответ прост - дату и время, так? Но всё куда сложнее, как только начинаешь разбираться с проблемой более тщательно. Например, предположим, что часы не тикнули между вызовами двух свойств в ниже приведенном коде. Так какое же значение примет в результате переменная "mystery"?

Код
  1. DateTime utc = DateTime.UtcNow;
  2. DateTime local = DateTime.Now;
  3. bool mystery = local == utc;

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

  • Значение будет всегда равным true: два значения ассоциированы с одним и тем же моментом во времени, только одно выражено локально, а второе универсально
  • Значение будет всегда равным false: два значения представляют два разных значения дат, т.е. автоматически неравны
  • И ещё раз true - если ваш локальный часовой пояс синхронизирован с UTC (Всемирным координированным временем), т.е. тогда, когда часовые пояса вообще не берутся во внимание - оба значения будут равны

Меня мало волнует, какой ответ верен - неочевидность логики поведения кода является признаком более глубоких проблем. На самом деле, все возвращается к свойству DateTime.Kind, которое позволяет DateTime представлять три типа значений:

DateTimeKind.Utc: дату и время UTC
DateTimeKind.Local: дату и время, локальные для данной системы, в которой исполняется код
DateTimeKind.Unspecified: М-м-м, хитро. Зависит от того, что вы с ним делаете.

Значение свойства оказывает различное влияние на разные операции. Например, если вы применяете метод ToUniversalTime() к "unspecified" значению DateTime, метод сделает предположение о том, что вы пытаетесь конвертировать локальное значение. С другой стороны, если вы применяете метод ToLocalTime() к "unspecified" значению DateTime, то будет сделано предположение о том, что изначально у вас было значение в виде UTC. Это одна модель поведения.

Если же вы создаёте DateTimeOffset из DateTime и TimeSpan, поведение несколько отличается:
  • со значением UTC всё просто - передаём UTC, хотим получить представление "UTC + указанное смещение"
  • локальное значение верно только иногда: конструктор проверяет совпадение смещения от UTC в указанном локальном времени в часовом поясе, используемым системой по умолчанию, со смещением, указанным вами
  • неуказанное значение ("unspecified") всегда верно и представляет собой локальное время в некотором неуказанном часовом поясе так, что смещение является верным в это время.

Не знаю как у вас, а у меня такое положение дел вызывает лёгкую семантическую истерику. Это всё равно, что иметь "числовой" тип, который содержит последовательность цифр, но вы должны использовать другое свойство для того, чтобы узнать десятичная эта цифра или шестнадцатеричная, а ответом иногда будет - "Ну, а как ты сам думаешь?".

Конечно, в .NET 1.1, свойство DateTimeKind вообще отсутствовало. Это не значит, что и проблемы не существовало. Это значит, что сбивающее с толку поведение, которое пытается придать смысл типу, хранящему разные виды значений и не пыталось быть сколько-нибудь последовательным. Оно базировалось на предположении, что значение даты перманентно имеет вид Unspecified.

Устраняет ли проблему использование структуры DateTimeOffset?

Хорошо. Теперь мы знаем, что нам не очень-то нравится тип DateTime. Поможет ли нам DateTimeOffset? Да, отчасти. Значение типа DateTimeOffset несёт чёткий смысл: оно содержит локальные дату и время с указанным смещением от UTC. Возможно теперь я должен сделать отступление и объяснить вам, что я имею ввиду под "локальными" датой и временем, а также (временнЫми) моментами.

Локальные дата и время не привязаны к конкретному часовому поясу. Настоящий момент - это позже или раньше "16:00 13 марта 2012 года"? Зависит от того, где вы находитесь в этом мире (это, кстати, я ещё не беру во внимание не-ISO календари). Поэтому DateTimeOffset содержит компонент, независимый от часового пояса (это "16:00 ..."), но также и смещение от UTC - что указывает на возможность его конвертации в момент времени на временной линии. Если проигнорировать теорию относительности, то все люди на планете воспринимают текущий момент одновременно. Если я щелкну пальцами (бесконечно быстро), то любое событие во вселенной случится до этого события или после него. Были ли вы в определенном часовом поясе или нет - неважно. В этом отношении моменты являются глобальными, по сравнению с локальными датой и временем, которые каждый конкретный индивидуум может наблюдать в конкретный момент времени.

(Вы ещё здесь? Продолжим - надеюсь, что предыдущий параграф был самым тяжелым в этой заметке. Хотя в нём описана очень важная концепция.)

Итак, DateTimeOffset относится к (глобальному) моменту времени, но также работает с локальной датой и временем. Это значит, что эта структура не является идеальным типом для представления локальных даты и времени - но такой не является и DateTime. DateTime с установленным свойством DateTimeKind.Local в действительности не является локальным по тем же соображениям - она привязана к часовому поясу, установленному по умолчанию в той системе, в которой она используется. DateTime вида DateTimeKind.Unspecified подходит немного лучше в отдельных случаях - например, при создании DateTimeOffset - но семантика выглядит странной в других случаях, что описано выше. В итоге ни DateTimeOffset ни DateTime не являются хорошими типами для отображения действительно локальных даты и времени.

Структура DateTimeOffset также не является хорошим выбором для привязки к конкретному часовому поясу, поскольку она понятия не имеет, какой часовой пояс дал соответствующее смещение первым. В .NET 3.5 есть вполне адекватный класс TimeZoneInfo, но нет типа, который говорит о "локальном времени в конкретном часовом поясе". Поэтому имея переменную типа DateTimeOffset, вы знаете конкретное время в некотором часовом поясе, но вы не знаете, каким будет локальное время минуту спустя, поскольку смещение для этого пояса могло измениться (обычно благодаря переходу на летнее/зимнее время).

Как насчёт дат и времени?

До этого мы обсуждали только значения, хранящие "дату и время". А что можно сказать о типах, в которых хранятся либо только дата либо только время? Чаще, конечно, необходимо хранить только дату, но бывают и случаи, когда необходимо хранить только время.

И таки да, вы можете использовать тип DateTime для хранения даты - чёрт, да есть даже свойство DateTime.Date, возвращающее дату для конкретных даты и времени... но только в виде другого значения типа DateTime с временем, установленным в полночь. Это не тоже самое, что иметь отдельный тип, который легко идентифицировать как "только дата" (или "только время" - .NET использует TimeSpan для этого, что снова не кажется мне совсем уж правильным).

А что же собственно с часовыми поясами? Тут TimeZoneInfo выглядит вполне прилично.

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

Во-первых, за основу взяты идентификаторы часовых поясов Windows. С одной стороны логично, а с другой - это не то, что использует весь остальной мир. Все не-Windows системы, которые я видел, используют базу данных часовых поясов Олсона (Olson) (также известной как tz или zoneinfo), соответственно в ней есть свои идентификаторы. Возможно вы их видели - "Europe/London" или "America/Los_Angeles" - это и есть идентификаторы Олсона. Поработайте с веб-сервисом, предоставляющем геоинформацию - есть шансы, что он использует идентификаторы Олсона. Поработайте с другой календарной системой - есть шансы, что и она использует идентификаторы Олсона. Здесь тоже есть свои проблемы. Например, со стабильностью идентификаторов, которые Unicode Consortium пытается решить с помощью CLDR... но, по крайней мере, у вас есть хороший шанс. Было бы здорово, если бы TimeZoneInfo предлагала какой нибудь способ установки соответствия между двумя схемами идентификаторов или это было бы реализовано ещё где-нибудь в .NET. (Noda Time знает об обоих наборах идентификаторов, хотя соответствие (mapping) для всех пока ещё недоступно. Это будет исправлено перед финальным выпуском.)

Во-вторых, этот класс основан на использовании DateTime и DateTimeOffset, т.е. вы должны быть осторожны при его использовании - если вы установите один вид DateTime, а передадите другой, то у вас могут быть проблемы. Класс достаточно хорошо документирован, но, честно говоря, объяснение такого рода вещей по своей сути достаточно непросто без запутывания ситуации с помощью использования противоречивых терминов.

Есть проблемы и с двусмысленными или ошибочными значениями локальных дат и времени. Они возникают при переходе на летнее/зимнее время: если время переводят вперёд (например, с 1:00 на 2:00), то есть шанс получения неверного локального времени (например, в этот день 1:30 так и не наступит). Если часы переводят назад (например, с 2:00 на 1:00), это приводит к двусмысленности: 1.30 случается дважды. Вы можете явно уточнить у TimeZoneInfo, когда конкретное значение неверно или двусмысленно, но легко просто забыть о такой возможности. Если вы попробуете конвертировать локальное время во время UTC с помощью часового пояса, будет сгенерировано исключение, если время неверно. А вот двусмысленное время будет принято за стандартное по умолчанию (а не как летнее время). Такого рода решение не позволяют разработчикам даже учесть использованные особенности. Говорить о которых...

Слишком сложно

Сейчас вы вполне можете подумать: "Раздул из мухи слона. Я не хочу думать об этом - зачем ты пытаешься настолько всё усложнить? Я использовал .NET API годами и не испытывал проблем". Если вы так подумали, я могу предложить три варианта:

  • Вы намного, намного умнее меня, и понимаете все эти сложности на интуитивном уровне. Вы всегда используете правильный вид для переменной типа DateTime; там, где надо - используете DateTimeOffset и всегда поступаете правильно с некорректными или двусмысленными локальными датой/временем. Без всяких сомнений, вы также пишете не блокирующийся многопоточный код с совместным доступом к состоянию объекта самым эффективным и в тоже время надёжным способом. Так какого чёрта вы всё это читаете, позвольте узнать?
  • Вы сталкивались с этими проблемами, но большей частью забыли о них - в конце концов они забрали всего 10 минут вашей жизни, пока вы экспериментировали над получением приемлемого результата (или по-крайней мере для прохождения теста; хотя такие тесты вполне могли быть концептуально неверны). Может быть вы были озадачены, столкнувшись с этой проблемой, но решили, что проблема в вас, а не в API.
  • Вы не сталкивались с этой проблемой, поскольку считаете тестирование кода скучным занятием, поскольку он работает в одном часовом поясе, на компьютерах, которые всегда выключены ночью (т.е. не подвержены влиянию переходов на зимнее/летнее время). Вы, в какой-то степени, счастливчик, но всё-таки вы забываете о часовом поясе.

Шутки шутками, но проблема действительно реальна. И если вы никогда прежде не задумывались о разнице между "локальным" временем и "глобальным" моментом до этой заметки, то вот вы это и сделали. Это важное различие - оно подобно разнице между двоичными числами с плавающей точкой и десятичными числами с плавающей точкой. Ошибки могут быть не очевидными, тяжёлыми в диагностике, плохо объяснимыми, плохо корректирующимися и вновь возникающими в другом месте программы.

Обработка значений переменных, хранящих дату/время действительно непроста. Есть такие неприятные случаи, как дни, не начинающиеся в полночь - благодаря переходу на летнее/зимнее время (например, воскресенье, 17 октября 2010 г., в Бразилии началось в 1:00). Если вам особенно не повезло, то вам придётся работать с многокалендарными системами (Грегорианский, Юлианский, Коптский, Буддистский и т.п.). Если вы имели дело с датами и временем начала 20-го столетия, то наверняка заметили очень странные переходы часовых поясов по мере перехода строго долготных смещений к более "округлённым" значениям (например, в Париже в 1911 г.). Вы можете столкнуться с правительствами, меняющими часовые пояса с предупреждением об этом за пару недель до собственно фактической смены. Можете также столкнуться со сменой идентификаторов часовых поясов (например, Asia/Calcutta на Asia/Kolcata).

Всё это, конечно, лежит на поверхности тех актуальных бизнес-правил, которые вы пытаетесь реализовать. А они тоже могут быть непростыми. С учётом всех этих сложностей, вы как минимум должны иметь API, который позволит вам относительно ясно выразить то, что вы подразумеваете.

Так идеален ли Noda Time?

Конечно нет. У Noda Time есть несколько проблем:
  • Несмотря на всё выше сказанное, я любитель, когда речь идёт о теории даты и времени. Секунда координации сбивает меня с толку. При мысли о точке смены Юлианского календаря на Грегорианский меня охватывает желание заплакать, и поэтому я её ещё не реализовал. Насколько я знаю, ни один из участников проекта Noda Time не является экспертом, хотя Стефен Коулборн (Stephen Colebourne), автор Joda Time и глава JSR-310 притаился в списке рассылки. (Кстати, он присутствовал на первой презентации Noda Time. Я спросил, знает ли кто-нибудь в зале разницу между Грегорианским календарем и календарем стандарта ISO-8601. Он поднял руку и дал правильный ответ. Я спросил, как случилось, что он знает ответ, а он ответил: "Я Стефен Коулборн". Я чуть не упал).
  • Мы ещё не закончили. Замечательный дизайн API бесполезен, если нет реализации.
  • Там обязательно будут ошибки - код команды BCL постоянно исполняется на сотнях тысяч машин по всему миру. Ошибки появятся быстро.
  • У нас нет ресурсов - мы просто активная группа разработчиков, работающих в удовольствие. Я не говорю об этом с сожалением (это действительно здорово), но неизбежны проблемы со временем, которое можно выделить на работу над дополнительными фишками, документацией и т.п.
  • Мы не часть BCL. Хотите использовать Noda Time в запросах на LINQ to SQL (или даже NHibernate)? Удачи. Даже если мы преуспеем свыше моих ожиданий, я не думаю, что найдутся другие проекты с открытым кодом, которые будут веками зависимы от нас.
Должен сказать, что получившимся дизайном я доволен. Мы постарались сохранить баланс между гибкостью и простотой достижения любой конкретной цели (с бОльшими усилиями, конечно). Как-нибудь я напишу ещё одну заметку об использованном стиле дизайна, сравнив его как с дизайном Joda Time, так и использованном в .NET. Лучший результат - получившийся набор типов, каждый из которых имеет свою вполне ясную роль. Я не стану вас утомлять деталями - для этого будет документация и другие заметки.

Как ни странно, для всех будет лучше, если команда, работающая над BCL, примет к сведению эту статью и решит радикально переработать API для .NET 6 (я предполагаю, что корабль ".NET 5" уже спущен на воду). И в то время, пока я занимаюсь этим, я уверен, что есть много других проектов, которые доставят мне удовольствие - откровенно говоря, вопросы дат и времени слишком важны для .NET сообщества, чтобы лежать длительное время исключительно на моих плечах.

Выводы

Надеюсь, что я убедил вас в том, что в .NET API есть существенные недочёты. Возможно, я также убедил вас в том, что Noda Time достойна более близкого знакомства, но не это было главной целью. Если вы действительно понимаете недостатки встроенных типов - в особенности семантическую двусмысленность DateTime - то должны использовать эти типы в вашем коде с особенной осторожностью и аккуратностью. И уже только это сделает меня счастливым.

(Ну а если вы дочитали этот опус до конца, значит и я не зря потратил своё время на перевод - прим.переводчика).

Очень вольный перевод (с) Чужа В.Ф. aka hDrummer, оригинал здесь.

Комментарии

Популярные сообщения из этого блога

Библиотека расчёта периодов времени для платформы .NET

Маленькие чудеса C#/.NET – структура DateTimeOffset

Гэри МакЛинн - Так ли уж вам нужен ORM?