Como obter a real diferença entre dois valores TDateTime

Categoria: Artigos
Categoria Pai: Addicted 2 Delphi!
Acessos: 12746
Imagem meramente ilustrativa

Quem estiver interessado na proposta sobre mudança da VCL, é só dar uma olhada em Report #56957 - A Fix for DateUtils Date-Time Compare Functions. Eu não sei se esta página ainda está ativa, mas se não estiver, faça uma busca pelo seu título. Se você tiver sorte, achará o texto em algum outro local.

Datas e Pontos Flutuantes 

Como se sabe, as datas no Delphi (TDate, TDateTime e TTime) são armazenadas como números do tipo extended, de ponto flutuante. Sabe-se também que números de ponto flutuante não tem uma precisão muito boa. Comparações e operações matemáticas com tais números causam erros de arredondamento que não são percebidos até que se precise manipular casas decimais com mais precisão, fato que acontece quando tentamos obter valores muito pequenos a partir de datas, como milissegundos. É neste ponto onde a leitura deste artigo pode ajudar você.

Primeiramente quero salientar que existem inúmeros outros algoritmos que fazem o mesmo e que até retornam a quantidade de meses, mas estejam avisados; a quantidade de meses exatos entre duas datas não pode ser conseguida de forma direta, apenas usando mais um loop, o que deixaria a função mais lenta. Deixei propositalmente a quantidade de meses de fora para que o leitor tente implementar.

Antes de começar preciso dizer que não vou simplesmente colar aqui a solução, pretendo explicar de forma didática, e estou subentendendo que o leitor já possui conhecimento básico da linguagem Delphi, bem como termos desta linguagem. Estejam avisados. O que vem a seguir é um caminho com lacunas faltando. Não espere copiar, colar e compilar sem erros. Sem mais delongas, lá vamos nós....


As mudanças propostas para a VCL

Infelizmente não lembro em qual Delphi a VCL foi corrigida, por isso não tenho como dizer com certeza se você vai ou não precisar utilizar as funções apresentadas aqui ou aquelas que VCL já dispõe. O exemplo de uso no final deste artigo demostra esta função para que ela retorne exatamente 1 milissegundo. Caso ao executar a função o valor retornado não seja 1 milissegundo, provavelmente seu Delphi não possui a VCL corrigida e assim será necessário usar as funções que estão aqui. Esta seção só será útil, portanto, caso seu Delphi ainda não tenha sido corrigido.

Precisamos criar duas funções para o cálculo preciso de milissegundos entre duas datas. Estas funções foram extraídas e modificadas a partir da proposta de alteração da unit DateUtils explicada anteriormente. O crédito por estas funções deve ser dado a John Herbster , que propôs as mudanças na unit DateUtils (Report #56957 - A Fix for DateUtils Date-Time Compare Functions). Os comentários originais, em inglês, foram preservados.

{ Converts a TDateTime variable to Int64 milliseconds from 0001-01-01.}
function DateTimeToMilliseconds(aDateTime: TDateTime): Int64;
var
  TimeStamp: TTimeStamp;
begin
  { Call DateTimeToTimeStamp to convert DateTime to TimeStamp: }
  TimeStamp := DateTimeToTimeStamp(aDateTime);
  { Multiply and add to complete the conversion: }
  Result := Int64(TimeStamp.Date) * MSecsPerDay + TimeStamp.Time;
end;

{ Uses DateTimeToTimeStamp, TimeStampToMilliseconds, and DateTimeToMilliseconds. }
function MillisecondsBetween(const aNow, aThen: TDateTime): Int64;
begin
  if aNow > aThen then
    Result := DateTimeToMilliseconds(aNow) - DateTimeToMilliseconds(aThen)
  else
    Result := DateTimeToMilliseconds(aThen) - DateTimeToMilliseconds(aNow);
end;

A unit DateUtils também possui uma função de nome MillisecondsBetween no entanto esta função não é tão precisa como parece. A experiência mostra que ao criar dois valores TDateTime usando a função EncodeDateTime e que distam entre si apenas 1 milissegundo, o retorno da função MillisecondsBetween não retorna 1, como era de se esperar, provando que ela não é precisa.


Apresentando a função DecodeDateDiff

Primeiramente devemos declarar o tipo do resultado. Um record, com todas as variáveis retornáveis.

type
  TDecodedDateDiff = record
     Years: Word;
     Weeks: Byte;
     Days: Word;
     Hours: Byte;
     Minutes: Byte;
     Seconds: Byte;
     Milliseconds: Word;
  end;

Agora vamos à função que faz todo o trabalho. Comentários internos dão as dicas do que está sendo feito. Quero salientar que esta função foi criada há cerca de 10 anos e que provavelmente hoje em dia devem haver formas mais eficientes de se obter o mesmo resultado.

function DecodeDateDiff(aStartDateTime, aFinishDateTime: TDateTime): TDecodedDateDiff;
var
  MilliSeconds: Int64;
  WholeStartDate, WholeEndDate: TDateTime;
  Days: Cardinal;
  Years: Word;
begin
  { Validando as datas, que devem ser passadas corretamente para função, isto é,
  a data final tem de ser maior ou igual à data inicial. Qualquer outro caso é
  inválido e lança a exceção }
  if aStartDateTime >= aFinishDateTime then
    raise Exception.Create('A data final é menor que a data inicial');

  { Zerando as variáveis que serão usadas no decorrer da função }
  ZeroMemory(@Result,SizeOf(TDecodedDateDiff));
  Years := 0;

  { Obtendo a quantidade exata de millissegundos entre as datas }
  MilliSeconds := MilliSecondsBetween(aStartDateTime,aFinishDateTime);

  { A partir da quantidade exata de millissegundos, podemos obter a quantidade
  exata de dias, já que sabemos quantos millissegundos hão exatamente em um dia }
  Days := MilliSeconds div MSecsPerDay;

  { Abaixo estamos normalizando as datas, de forma que a data inicial começe
  exatamente no início do ano subsequente a ela, e a data final termine
  exatamente no final do ano anterior a ela }
  WholeStartDate := IncMilliSecond(EndOfTheYear(aStartDateTime));
  WholeEndDate := IncMilliSecond(StartOfTheYear(aFinishDateTime),-1);

  { O loop abaixo vai realizar duas ações: Decrementar a quantidade de dias
  obtida anteriormente da quantidade de dias no ano sendo verificado no momento
  e incrementar a variável Years, de forma a obter a quantidade de anos. }
  while WholeStartDate < WholeEndDate do
  begin
    Dec(Days,DaysInYear(WholeStartDate));

    Inc(Years);

    WholeStartDate := IncDay(WholeStartDate,DaysInYear(WholeStartDate));
  end;

  { Caso a quantidade de dias seja maior ou igual a quantidade de dias no ano
  final, precisamos realizar um último ajuste para incrementar anos e
  decrementar dias de acordo com a quantidade de dias no ano da data final }
  if Days >= DaysInYear(aFinishDateTime) then
  begin
    Inc(Years);
    Dec(Days,DaysInYear(aFinishDateTime));
  end;

  { Neste ponto, a variável Years contém a quantidade de anos inteiros entre as
  datas inicial final ... }

  Result.Years := Years;

  { ... E a variável Days contém a quantidade de dias que sobraram. Obtemos
  portanto a quantidade de semanas, que é um valor exato }

  Result.Weeks := Days div 7;
  Result.Days := Days mod 7;

  { A partir daqui a verificação é simples matemática, extraindo horas, minutos
  e segundos dos millisegundos }
  MilliSeconds   := MilliSeconds mod MSecsPerDay;

  Result.Hours   := MilliSeconds div (SecsPerHour * MSecsPerSec);
  MilliSeconds   := MilliSeconds mod (SecsPerHour * MSecsPerSec);

  Result.Minutes := MilliSeconds div (SecsPerMin * MSecsPerSec);
  MilliSeconds   := MilliSeconds mod (SecsPerMin * MSecsPerSec);

  Result.Seconds := MilliSeconds div MSecsPerSec;
  MilliSeconds   := MilliSeconds mod MSecsPerSec;

  { O que sobrar no final, será apenas milissegundos! }
  Result.Milliseconds := MilliSeconds;
end;

Um exemplo de uso

var
  Data1, Data2: TDateTime;
  Diferenca: TDecodedDateDiff;
begin
  data1 := EncodeDateTime(2017,12,13,0,0,0,0);
  data2 := EncodeDateTime(2017,12,13,0,0,0,1);
  Diferenca := DecodeDateDiff(data1,data2);
  // Todos os membros de "Diferenca" devem ser zero, exceto o membro "Milliseconds"
  // que deve possuir o valor 1
end;

É isso! Agora você pode obter um intervalo entre duas datas com precisão de milissegundos! Se você quiser modificar o algoritmo, sinta-se a vontade, mas compartilhe com todos (como eu fiz).