Почему мне не нравится TDateTime

Сентябрь 3, 2009 — Шарахов А.П.

Очевидный выбор для представления времени в Delphi – тип TDateTime, число с плавающей точкой двойной точности. Однако оказывается, что его использование сопряжено с рядом неудобств и необходимостью учета некоторых деталей реализации. Попробуем в этом разобраться и предложить еще один вариант для хранения времени.

А что в нем хорошего?

Если время хранится в переменной типа TDateTime, то (с некоторыми оговорками):

  • время можно использовать в арифметических операторах,
  • для изменения времени не требуется менять значения нескольких полей записи и проверять граничные условия (например, выход за границу месяца),
  • невозможно задать недействительное время в противоположность TSystemTime,
  • невозможно задать одно и то же время различными способами в противоположность TTimeStamp,
  • в арифметических выражениях время не требуется приводить к другому типу в противоположность TFileTime,
  • имеется достаточное количество разработанных модулей, использующих TDateTime и предназначенных для самых разных нужд, например, для форматного ввода-вывода.

Ложка дегтя

О каких оговорках мы упомянули в предыдущем разделе? Вот одна из них. Из-за совместимости TDateTime с OLE 2.0 Automation арифметические операции с переменными этого типа корректны только для неотрицательных значений времени. В области отрицательных значений шкала времени становится кусочно-линейной, что усложняет программирование вычислений, если дробная часть значения ненулевая. Поэтому, на мой взгляд, лучше не использовать отрицательные значения TDateTime, или, по крайней мере, позаботиться о том, чтобы дробная часть значения была нулевой.

Интересно, что подобно нам разработчики Delphi иногда тоже мыслили исключительно в позитивном направлении. Это “доказывается” наличием ошибки в функции CompareDateTime:

procedure TForm1.Button1Click(Sender: TObject);
var
  dt1, dt2: TDateTime;
begin;
  dt1:=-99.1;
  dt2:=-99.2;
  Memo1.Lines.Add(FormatDateTime('yyyy.mm.dd  hh:nn:ss.zzz',dt1));
  Memo1.Lines.Add(FormatDateTime('yyyy.mm.dd  hh:nn:ss.zzz',dt2));
  Memo1.Lines.Add(IntToStr(CompareDateTime(dt1,dt2)));
  dt1:=-991;
  dt2:=-992;
  Memo1.Lines.Add(FormatDateTime('yyyy.mm.dd  hh:nn:ss.zzz',dt1));
  Memo1.Lines.Add(FormatDateTime('yyyy.mm.dd  hh:nn:ss.zzz',dt2));
  Memo1.Lines.Add(IntToStr(CompareDateTime(dt1,dt2)));
  end;

В первом случае при сравнении отрицательных дат CompareDateTime вернет неверный результат, хотя во втором случае сравнение чистых дат, имеющих нулевую дробную часть, выполняется верно.

Точность представления TDateTime

Может показаться, что TDateTime обеспечивает очень высокую точность представления времени, но на практике в нем хранится время, полученное конвертацией TTimeStamp или TSystemTime, которые обеспечивают не более чем миллисекундную точность представления. Возникает вопрос: почему разработчики Delphi ни в одной функции для работы с TDateTime не предусмотрели большей точности? Оказывается, на момент своего введения TDateTime просто не мог обеспечить микросекундную точность. Например, следующий код в Delphi 1 должен был зацикливаться:

//Будьте внимательны - эта процедура может вызвать зацикливание.
procedure TForm1.Button2Click(Sender: TObject);
const
  OneMicroSecond= OneMilliSecond /1000;
var
  dt1, dt2: TDateTime;
begin;
  dt1:=Date;
  //dt1:=-365 * 1327; 
  dt2:=dt1;
  repeat;
    dt2:=dt2+OneMicroSecond;
    until dt2<>dt1;
  Memo1.Lines.Add('Done');
  end;

Благодаря сдвигу точки отсчета типа TDateTime, произведенному при переходе на Delphi 2, сегодня этот код работает и ему не грозит зацикливание еще примерно 1326 лет. Однако если снять комментарий во втором операторе присваивания, легко убедиться, что теперь стала недостижимой микросекундная точность для дат, соответствующих 6 веку н.э.

При хранении данных типа TDateTime неизбежно возникают ошибки округления. Часто они не имеют существенного значения, но иногда проявляют себя весьма неожиданно. Например, для переменных этого типа, получивших значение в результате арифметических вычислений, не обеспечивается взаимная однозначность преобразования TDateTime – TTimeStamp:

 procedure TForm1.Button3Click(Sender: TObject);
var
  dt1, dt2: TDateTime;
begin;
  dt1:=Now+365 * 70;
  //dt1:=Now+OneMilliSecond;
  dt2:=TimeStampToDateTime(DateTimeToTimeStamp(dt1));
  Memo1.Lines.Add(IntToStr(ord(dt1=dt2)));
  end;

Этот код при многократном исполнении выдает последовательность нулей и единиц.

О других особенностях работы с вещественными числами можно прочитать в статье Неочевидные особенности вещественных чисел.

Накопление ошибок при работе с TDateTime

Некоторые алгоритмы могут приводить к накоплению ошибок. Выполним следующий код (сначала в том виде, как он приведен, а затем по очереди сняв комментарий во втором и третьем операторе присваивания):

procedure TForm1.Button4Click(Sender: TObject);
var
  dt1, dt2: TDateTime;
  i: integer;
begin;
  dt1:=Date;
//dt1:=Date + 365 * 70; 
//dt1:=Date + 365 * 1327;
 
  dt2:=dt1;
  i:=0;
  repeat;
    inc(i);
    dt2:=dt2+OneMilliSecond;
    until CompareDateTime(dt2,dt1+OneMilliSecond*i)<>0;
  Memo1.Lines.Add(IntToStr(i));
 
  dec(i);
  dt2:=dt2-OneMilliSecond;
  Memo1.Lines.Add(IntToStr(CompareDateTime(dt2,dt1+OneMilliSecond*i)));
  Memo1.Lines.Add(FormatDateTime('yyyy.mm.dd  hh:nn:ss.zzz',dt2));
  Memo1.Lines.Add(FormatDateTime('yyyy.mm.dd  hh:nn:ss.zzz',dt1+OneMilliSecond*i));
  end; 

Помимо демонстрации эффекта накопления ошибок этот пример показывает также, что точность вычислений ухудшается по мере увеличения значений операндов. Например, чтобы в наше время накопилась погрешность в 1 миллисекунду, необходимо выполнить 5861 сложение, через 70 лет – уже 2184 сложения, а примерно через 1326 лет (вперед или назад) для этого будет достаточно всего лишь 237 сложений. Возможно, еще одной причиной переноса нулевой даты почти на 1900 лет при переходе с Delphi 1 на Delphi 2 было желание разработчиков повысить точность вычислений в наше время.

Погрешности вычислений осложняют программирование сравнения переменных типа TDateTime. Поэтому в состав Delphi пришлось вводить функции вроде CompareDateTime и SameDateTime, выполняющие приблизительное сравнение времени с точностью до 1 миллисекунды. Но это не решает проблему: как демонстрирует приведенный выше пример, имеются значения времени, одинаковые с точки зрения этих функций, но отличающиеся на 1 миллисекунду, если эти значения выводить при помощи функции FormatDateTime.

To compare or not to compare?

Мы уже сталкивались с тем, что функция CompareDateTime может возвращать неверный результат для отрицательных значений аргументов. Теперь я хочу предупредить вас об опасности ее использования при сортировке положительных значений времени. Следующий пример демонстрирует возможность получения неверного результата при сортировке массива, содержащего близкие значения типа TDateTime:

procedure QuickSort(var A: array of TDateTime; L, R: Integer);
var
  I, J: Integer;
  Mid, T: TDateTime;
begin;
  repeat;
    I:=L;
    J:=R;
    Mid:=A[(L+R) div 2];
    repeat;
      while CompareDateTime(A[I],Mid)=-1 do Inc(I);
      while CompareDateTime(A[J],Mid)=+1 do Dec(J);
      if I<=J then begin;
        T:=A[I]; A[I]:=A[J]; A[J]:=T;
        Inc(I);
        Dec(J);
        end;
      until I>J;
    if L<J then QuickSort(A, L, J);
    L:=I;
    until I>=R;
  end;
 
procedure TForm1.Button5Click(Sender: TObject);
const
  Delta = OneMillisecond*0.999;
//Delta = OneMillisecond/0.999;
var
  dt: array of TDateTime;
  i: integer;
begin;
  SetLength(dt,3);
  dt[1]:=Now;
  dt[0]:=dt[1]-Delta;
  dt[2]:=dt[1]+Delta;
  QuickSort(dt,Low(dt),High(dt));
  for i:=Low(dt) to High(dt) do
  Memo1.Lines.Add(FormatDateTime('yyyy.mm.dd  hh:nn:ss.zzz', dt[i]));
  end;

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

Непредсказуемый результат функций *Between из модуля DateUtils

Функции из модуля DateUtils, предназначенные для измерения интервалов времени, возвращают непредсказуемый результат:

procedure TForm1.Button6Click(Sender: TObject);
var
  dt1, dt2: TDateTime;
begin;
  dt1:=EncodeTime(0, 0, 0, 0);
  dt2:=EncodeTime(1, 0, 0, 0);
  Memo1.Lines.Add(IntToStr(HoursBetween(dt2, dt1)));         //результат = 1
  dt1:=EncodeTime(0, 0, 0, 0);
  dt2:=EncodeTime(0, 1, 0, 0);
  Memo1.Lines.Add(IntToStr(MinutesBetween(dt2, dt1)));       //1
  dt1:=EncodeTime(0, 0, 0, 0);
  dt2:=EncodeTime(0, 0, 1, 0);
  Memo1.Lines.Add(IntToStr(SecondsBetween(dt2, dt1)));       //1
  dt1:=EncodeTime(0, 0, 0, 0);
  dt2:=EncodeTime(0, 0, 0, 1);
  Memo1.Lines.Add(IntToStr(MillisecondsBetween(dt2, dt1)));  //1
 
  dt1:=EncodeTime(3, 0, 0, 0);
  dt2:=EncodeTime(4, 0, 0, 0);
  Memo1.Lines.Add(IntToStr(HoursBetween(dt2, dt1)));         //0
  dt1:=EncodeTime(0, 58, 0, 0);
  dt2:=EncodeTime(0, 59, 0, 0);
  Memo1.Lines.Add(IntToStr(MinutesBetween(dt2, dt1)));       //0
  dt1:=EncodeTime(0, 0, 6, 0);
  dt2:=EncodeTime(0, 0, 7, 0);
  Memo1.Lines.Add(IntToStr(SecondsBetween(dt2, dt1)));       //0
  dt1:=EncodeTime(0, 0, 0, 998);
  dt2:=EncodeTime(0, 0, 0, 999);
  Memo1.Lines.Add(IntToStr(MillisecondsBetween(dt2, dt1)));  //0
  end;

John Herbster в статье Using Floating Point to Represent Date-Times and Dollars-Cents and the Hidden Value Problem анализирует проблему с функциями *Between и предлагает возможные решения.

Нерегулярность функции Now

Под Windows XP прерывание таймера происходит с интервалом 1/64 секунды, или через каждые 15.625 миллисекунды. Это приводит к тому, что функция Now возвращает значение текущего времени с переменным шагом (15 или 16 миллисекунд). В некоторых случаях это может оказаться нежелательным, т.к. люди испытывают дискомфорт, когда получают разные значения, измеряя одинаковые интервалы времени.

Локальное время в TDateTime

Используя тип TDateTime, необходимо иметь в виду, что функции Now и Time возвращают локальное время, т.е. учитывают часовой пояс и переход на летнее время, заданные в ОС. Сначала это может показаться приятным, т.к. не требуется вызывать лишнюю функцию, чтобы преобразовать системное время в локальное.

Но не стоит есть пирожки, не думая. Иногда вместо локального времени, которое возвращает Now, больше подходит системное время, полученное с использованием связки функций GetSystemTime и SystemTimeToDateTime. Предположим, например, что нам требуется сопоставить время наступления событий, происходящих на различных компьютерах. В этом случае поневоле придется опираться на что-то более стабильное, чем локальное время. Ситуация усугубляется, если мы имеем дело с информацией о прошлом событии, которая хранится в базе данных: мы не можем знать, какие правила вычисления локального времени действовали некоторое время назад на каждом компьютере. Это заставляет усомниться в целесообразности хранения локального времени и наводит на мысль использовать локальное время только для отображения данных.

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

Скорость работы с TDateTime

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

В этом смысле показательна функция Now, при вычислении которой выполняется большое количество преобразований и вызовов. Если вам требуется часто вызывать эту функцию, то стоит подумать над оптимизацией кода, например, использовать старое значение времени, если системное время не изменилось, или написать эквивалентную функцию, работающую в 10 раз быстрее (например, как это сделано в статье Now речь пойдет об одной функции).

Тип, приятный во всех отношениях

Давайте приглядимся к типу TFileTime. Он лишен всех недостатков TDateTime, но имеет один нюанс: в арифметических выражениях его приходится приводить к Int64. Мы обойдем это, введя новый тип TNanoTime, эквивалентный Int64, и выберем его в качестве базового типа для работы со временем. TNanoTime хранит те же самые данные, что и TFileTime, а именно: количество 100-наносекундных интервалов с 1 января 1601 года (UTC). Достоинства нового типа:

  • можно использовать в арифметических операторах, при этом обеспечивается высокая скорость вычислений,
  • для изменения не требуется менять значения нескольких полей записи и проверять граничные условия,
  • невозможно задать недействительное значение,
  • невозможно задать одно и то же значение различными способами,
  • в арифметических выражениях время не требуется приводить к другому типу,
  • линейная шкала времени,
  • 100-наносекундная точность представления,
  • отсутствие накопления ошибок при вычислениях,
  • возможность использования точного сравнения,
  • регулярность и высокая скорость работы функции GetNanoTime (обертка функции GetSystemTimeAsFileTime),
  • основной режим работы – хранение системного времени, а не локального,
  • наличие быстрых функций преобразования системного времени в локальное и обратно,
  • наличие быстрых функций преобразования в TDateTime и обратно, дающих корректный результат для положительных значений TDateTime и отрицательных значений с нулевой дробной частью,
  • и т.д. (скорее всего за этим “и т.д.” ничего не скрывается, но вдруг я что-нибудь забыл).

Вам все еще нравится TDateTime? Тогда не скачивайте этот архив.

Исходники, как они есть

В этой статье предложена идея введения нового базового типа для работы с временем. Исходники, содержащиеся в архиве, служат для демонстрации идеи. Я не ставил перед собой цель создать модуль, работающий во всех версиях Delphi, его работа проверялась только под Delphi 7.

Тип TNanoTime эквивалентен типу Int64, поддержка которого отсутствует в Delphi 2 и 3. Хотя для этих версий Delphi существует возможность вместо Int64 базироваться на типе Comp, выполнив соответствующие доработки некоторых функций, на мой взгляд, такая замена неудобна. Тем не менее, в Delphi 2 и 3, чуть ухудшив наглядность кода, можно добиться компиляции функции GetDateTime, возвращающей результат типа TDateTime, если изменить объявления констант:

 
const
  DeltaInMsLo  = $D46B8C00;
  DeltaInMsHi  = $00000894;
  Magic625InvertedLo = $3886594B;
  Magic625InvertedHi = $346DC5D6;

Еще немного о том, как заставить исходники компилироваться в Delphi 4, 5 и 6, можно прочитать в заметке Компиляция в ранних версиях Delphi.

Скорость или совместимость?

Операция деления чисел с плавающей точкой выполняется гораздо медленнее умножения. Поэтому в функциях для работы с TNanoTime вместо деления на количество миллисекунд в сутках выполняется умножение на обратное число. Результаты этих операций могут несущественно отличаться. Чтобы получить в точности тот же результат, который дают функции Delphi, требуется переписать функции GetNanoTime и NanoTimeToSystemDateTime. Например, функция NanoTimeToSystemDateTime должна выглядеть так:

function NanoTimeToSystemDateTime(ft: TNanoTime): TDateTime;
const
  dMsPerDay: dword= MsPerDay;
asm
  //Result:=(ft div TicksPerMs - DeltaInMs) / MsPerDay;
 
  //Convert TFileTime to milliseconds: divide pint64(ebp+8)^ by TicksPerMs=10000, result in edx:eax
  mov eax, Magic625InvertedLo
  mul [ebp+8]
  mov eax, Magic625InvertedLo
  mov ecx, edx
  mul [ebp+12]
  push ebx
  xor ebx, ebx
  add ecx, eax
  mov eax, Magic625InvertedHi
  adc ebx, edx
  mul [ebp+8]
  add ecx, eax
  mov eax, Magic625InvertedHi
  adc ebx, edx
  mov ecx, 0
  adc ecx, ecx
  mul [ebp+12]
  add eax, ebx
  adc edx, ecx
  shrd eax, edx, 11
  shr edx, 11
 
  sub eax, DeltaInMsLo
  sbb edx, DeltaInMsHi
  mov [ebp+8], eax
  mov [ebp+12], edx
  fild qword ptr [ebp+8]
  fild dMsPerDay
  pop ebx
  fdivp
  end;

При этом ее скорость упадет примерно в 1.85 раза.

на главную

Прикрепленный файлРазмер
ShaTime.zip1.62 кб

Comments (8)

Вопиющий случай с функцией now

Уважаемый Александр,

Я столкнулся с ситуацией, когда now начинает выдавать чудовищно округленные значения (до нескольких минут !). В результате расследования выяснилось, что это наблюдается после выполнения функции Direct3D.CreateDevice. Если же сразу после этой функции вставить
asm FNINIT end;
т.е. сбросить FPU, то тогда последующие вызовы now работают нормально со своей обычной точностью. Отсюда я делаю выводы: либо функция CreateDevice просто забыла вернуть FPU в прежнее состояние, либо она это сделала намеренно, чтобы ускорить работу по трехмерным расчетам, либо в функции now не предусмотрена инициализация FPU перед началом расчетов. У меня просто не укладывается в голове, как в XXI веке, работая на Delphi XE возможны такие подставы ! И еще мне не понятно почему на уровне пользователя Windows дает возможность выполнить инструкции сопроцессора, они разве не привилегированные?

С уважением,
Дмитрий Шеховцов
dima@transinf.ru

Программистам свойствинно ошебаться

Инструкции сопроцессора не привилегированные. Более того, вызывая функцию Get8087CW (см. модуль System), можно прочитать текущее значение управляющего слова FPU, а процедура Set8087CW позволяет изменить его. Важно не забывать после таких изменений и выполнения вычислений всегда восстанавливать прежнее значение, например, как это делает функция Trunc. Фактически, это единственный способ гарантированно добиться желаемого, если кто-то играет не по правилам. Хотя, конечно, обычно это бывает излишне, т.к. все библиотеки Delphi соблюдают правила и не портят управляющее слово FPU.
Естественно, в функции Now не предусмотрена инициализация FPU. Это делается один раз в самом начале работы программы, было бы довольно странно делать это перед каждой операцией с плавающей точкой.

Точность функции now

Согласен с Вами.
Но меня поразила огромность погрешности функции now после завершения CreateDevice. В моей предметной области миллисекундной точности now более чем достаточно, мне даже хватило бы и секундной точности. Но минуты ! Это уж слишком. Представьте только, к примеру, я делаю два измерения времени:

t1 := now;
Direct3D.CreateDevice...
t2 := now;

и в результате получается, что t2 на полторы минуты МЕНЬШЕ t1, то есть момент t2 предшествовал t1 !
У меня имеются действующие производственные системы во многих городах, и я содрогаюсь от мысли, что такое может произойти. Правда в этих системах я не использую Direct3D и практика показывает, что все работает нормально. Тем не менее этот домоклов меч, о котором я до сего момента не знал, продолжает висеть над моими разработками.

Управляя временем

Можно воспользоваться функцией GetTickCount или обернуть "плохие" вызовы в Get8087CW и Set8087CW.

Интересная реализация. Т.е.

Интересная реализация. Т.е. по сути идет каст структуры _filetime в int64? Мне как раз нужно хранить даты в базе. Но начитался ужастиков что не всегда такие преобразования оправданы(SearchRec).
Что думаете по этому поводу? Кстати дальнейшии представления дат в строку или выкусывание даты с int64 не планируется? Или просто конвертить в TDateTime.

Это не каст.

Это не каст, а использование типа int64 для представления времени.

На архитектуре IA-32 ужастиков при работе с TFileTime не стоит опасаться.

Если вам необходимо оставить от TNanoTime только сутки, можете использовать целочисленное деление.

Test

Я в асемблере не бубу. Но судя из процедуры Test результат один и тотже, а это вроде кастом называется. А третья строка преобразование.

procedure Test;
var
list: tstringlist;
int64Time: int64;
FT: TFileTime;
begin
list := tstringlist.Create;
GetNanoTime(int64Time);
GetSystemTimeAsFileTime(FT);
list.Add(searchrec.Name + ' = ' + IntToStr(int64Time));
list.Add(searchrec.Name + ' = ' + IntToStr(int64(FT)));
list.Add(searchrec.Name + ' = ' + IntToStr(StrToInt64('$' + IntToHex(FT.dwHighDateTime, 8) + IntToHex(FT.dwLowDateTime, 8))));
showmessage(list.Text);
list.Free;
end;

Ладно не суть важно, это явно лучше чем хранение в TDateTime, с плавающей точкой. Но функциями не мешало бы обзавестись побольше :). Хотя б NanoTimeToStr, FileTimeToNanoTime.

Почему мне не нравится TDateTime

Александр, спасибо за Ваши неиссякаемые идеи по оптимизации !
За "и т.д.", возможно, скрывается "упаковка" в record с class operators.

Дмитрий, DeHL Team.