DateTimeOffset(strict)

Оригинал на хабре

Сегодня утром мой приятель @kirillkos столкнулся с проблемой.

Проблемный код

Вот его код:

class Event {
   public string Message {get;set;}
   public DateTime EventTime {get;set;}
}

interface IEventProvider {
   IEnumerable<Event> GetEvents();
}

И дальше много-много реализаций IEventProvider, достающие данные из разных таблиц и баз.

Проблема: во всех этих базах все в разных временных зонах. Соответственно, при попытке вывести события на UI все ужасно перепутано.

Слава Хейлсбергу, у нас есть типы, пусть они спасут нас!

Попытка 1

class Event {
   public string Message {get;set;}
   public DateTimeOffset EventTime {get;set; }
}

DateTimeOffset замечательный тип, он хранит информацию о смещении относительно UTC. Он прекрасно поддерживается MS SQL и Entity Framework (а в версии 6.3 будет поддерживаться еще лучше). У нас в code style он обязательный для всего нового кода.

Теперь мы можем собрать информацию с этих самых provider и консистентно, полагаясь на типы, вывести все на UI. Победа!

Проблема: DateTimeOffset умеет неявно преобразовываться из DateTime. Следующий код прекрасно скомпилируется:

class Event {
   public string Message {get;set;}
   public DateTimeOffset EventTime {get;set; }
}

IEnumerable<Event> GetEvents() 
{
   return new[] {
     new Event() {EventTime = DateTime.Now, Message = "Hello from unknown time!"},
   };
}

Это потому, что у DateTimeOffset определен оператор неявного приведения типов:

// Local and Unspecified are both treated as Local
public static implicit operator DateTimeOffset (DateTime dateTime);

Это совсем не то, что нам нужно. Мы-то хотели, чтобы программист при написании кода был вынужден задуматься: «а в какой собственной временной зоне случилось это событие? Откуда взять зону?». Часто совсем из других полей, иногда из связанных таблиц. А тут совершить ошибку не задумавшись очень легко.

Проклятые неявные преобразования!

Попытка 2

С тех пор, как я услышал про ~~молоток~~ статические анализаторы, мне все кажется ~~гвоздями~~ подходящими случаями для них. Нам надо написать статический анализатор, который запрещает это неявное преобразование, и объясняет почему... Выглядит, как многовато работы. Да и вообще, это работа компилятора, проверять типы. Пока отложим эту идею, как многословную.

Попытка 3

Вот если мы были бы в мире F#, сказал @kirillkos. Мы бы тогда:

type DateTimeOffsetStrict = Value of DateTimeOffset

И дальше не придумал импровизируй какая-то магия нас спасла бы. Жаль, что у нас в конторе не пишут на F#, да и мы с @kirillkos его толком не знаем :-)

Попытка 4

Неужели что-то такое нельзя сделать на C#? Можно, но замучаешься преобразовывать туда-сюда. Стоп, но ведь мы только что видели, как можно сделать неявные преобразования!

/// <summary>
/// Same as <see cref="DateTimeOffset"/>
/// but w/o implicit conversion from <see cref="DateTime"/>
/// </summary>
public readonly struct DateTimeOffsetStrict
{
  private DateTimeOffset Internal { get; }
  private DateTimeOffsetStrict(DateTimeOffset @internal)
  {
    Internal = @internal;
  }
  
 public static implicit operator DateTimeOffsetStrict(DateTimeOffset dto) 
   => new DateTimeOffsetStrict(dto);

 public static implicit operator DateTimeOffset(DateTimeOffsetStrict strict) 
   => strict.Internal;
}

Самое интересное в этом типе, что он неявно преобразуется туда-сюда из DateTimeOffset, а вот попытка неявно преобразовать его из DateTime вызовет ошибку компиляции, преобразования из DateTime возможны только явные. Компилятор не может вызвать «цепочку» неявных преобразований, если они определены в нашем коде, это ему запрещает стандарт (цитата на SO). То есть, вот так работает:

class Event {
   public string Message {get;set;}
   public DateTimeOffsetStrict EventTime {get;set; }
}

IEnumerable<Event> GetEvents() 
{
   return new[] {
     new Event() {EventTime = DateTimeOffset.Now, Message = "Hello from unknown time!"},
   };
}

а вот так нет:

IEnumerable<Event> GetEvents() 
{
   return new[] {
     new Event() {EventTime = DateTime.Now, Message = "Hello from unknown time!"},
   };
}

Что нам и требовалось!

Итог

Пока не знаем, будем ли внедрять. Только всех приучили к DateTimeOffset, и теперь его заменять на наш тип — стремновато. Да и наверняка всплывут проблемы на уровне EF, ASP.NET parameter binding и еще в тысяче мест. Но самое решение кажется мне интересным. Аналогичные трюки я использовал, чтобы следить за безопасностью пользовательского ввода — делал тип UnsafeHtml, который неявно преобразуется из строки, а вот обратно его преобразовать в строку или IHtmlString можно только путем вызова sanitizer.