13

I am new to Delphi (been programming in it for about 6 months now). So far, it's been an extremely frustrating experience, most of it coming from how bad Delphi is at handling dates and times. Maybe I think it's bad because I don't know how to use TDate and TTime properly, I don't know. Here is what is happening on me right now :

// This shows 570, as expected
ShowMessage(IntToStr(MinutesBetween(StrToTime('8:00'), StrToTime('17:30'))));

// Here I would expect 630, but instead 629 is displayed. WTF!?
ShowMessage(IntToStr(MinutesBetween(StrToTime('7:00'), StrToTime('17:30'))));

That's not the exact code I use, everything is in variables and used in another context, but I think you can see the problem. Why is that calculation wrong? How am I suppose to work around this problem?

marco-fiset
  • 1,933
  • 1
  • 19
  • 31
  • The big problem with delphi's `TDateTime`s is that they're doubles representing days, instead of fixedpoints. This means that they can't represent minutes exactly. (Not sure if that's what causes the problem, but I wouldn't be surprised) – CodesInChaos Feb 22 '13 at 19:25
  • 1
    @CodesInChaos: I've been told that. It's not a proper excuse to me. They could have just make it work the right way instead of having a [leaky abstraction](http://www.joelonsoftware.com/articles/LeakyAbstractions.html). – marco-fiset Feb 22 '13 at 19:27
  • In newer versions of delphi (2006 or so) you can create your own records representing dates/time and overload the operators. Then you can drop the inherently flawed double-datetimes. – CodesInChaos Feb 22 '13 at 19:28
  • Your example works OK in my Delphi XE and XE3, but fails in D2007, so there's maybe a bug in your particular version. – jachguate Feb 22 '13 at 19:29
  • @David: But I can confirm the issue on my system (Delphi 2009). – Andreas Rejbrand Feb 22 '13 at 19:30
  • @Andreas, it looks like it works OK from XE – jachguate Feb 22 '13 at 19:31
  • 2
    For a summary of most problems with Delphi `TDateTime` comparison routines, see John Herbsters [QC report 56957](http://qc.embarcadero.com/wc/qcmain.aspx?d=56957) where he proposes an improvement. This suggestion is also described by Zarko Gajic: [accurate-difference-between-two-delphi-tdatetime-values](http://delphi.about.com/od/objectpascalide/a/accurate-difference-between-two-delphi-tdatetime-values.htm). As noted in comments, these shortcomings are rectified in later Delphi versions. – LU RD Feb 22 '13 at 21:24

2 Answers2

21

Given

a := StrToTime('7:00');
b := StrToTime('17:30');

ShowMessage(FloatToStr(a));
ShowMessage(FloatToStr(b));

your code, using MinutesBetween, effectively does this:

ShowMessage(IntToStr(trunc(MinuteSpan(a, b)))); // Gives 629

However, it might be better to round:

ShowMessage(IntToStr(round(MinuteSpan(a, b)))); // Gives 630

What is actually the floating-point value?

ShowMessage(FloatToStr(MinuteSpan(a, b))); // Gives 630

so you are clearly suffering from traditional floating-point problems here.

Update:

The major benefit of Round is that if the minute span is very close to an integer, then the rounded value will guaranteed be that integer, while the truncated value might very well be the preceding integer.

The major benefit of Trunc is that you might actually want this kind of logic: Indeed, if you turn 18 in five days, legally you are still not allowed to apply for a Swedish driving licence.

So you if you'd like to use Round instead of Trunc, you can just add

function MinutesBetween(const ANow, AThen: TDateTime): Int64;
begin
  Result := Round(MinuteSpan(ANow, AThen));
end;

to your unit. Then the identifier MinutesBetween will refer to this one, in the same unit, instead of the one in DateUtils. The general rule is that the compiler will use the function it found latest. So, for instance, if you'd put this function above in your own unit DateUtilsFix, then

implementation

uses DateUtils, DateUtilsFix

will use the new MinutesBetween, since DateUtilsFix occurss to the right of DateUtils.

Update 2:

Another plausible approach might be

function MinutesBetween(const ANow, AThen: TDateTime): Int64;
var
  spn: double;
begin
  spn := MinuteSpan(ANow, AThen);
  if SameValue(spn, round(spn)) then
    result := round(spn)
  else
    result := trunc(spn);
end;

This will return round(spn) is the span is within the fuzz range of an integer, and trunc(spn) otherwise.

For example, using this approach

07:00:00 and 07:00:58

will yield 0 minutes, just like the original trunc-based version, and just like the Swedish Trafikverket would like. But it will not suffer from the problem that triggered the OP's question.

Andreas Rejbrand
  • 105,602
  • 8
  • 282
  • 384
  • 1
    This approach is bullet proof – David Heffernan Feb 22 '13 at 19:29
  • Seems like the way to go. Thank you! – marco-fiset Feb 22 '13 at 19:35
  • I'm not sure I understand your second update. What value does it add ? – marco-fiset Feb 22 '13 at 20:17
  • If the span is `3.9` minutes, it will round down to `3`, just like `trunc`. But if it is `3.999999999999999999999` or something, it will use `round` and reutrn `4`. So it is basically a fixed version of the original function in `DateUtils`. In particular, of course, it will yield 630 in your example. [To compare: if you simply use `round` instead of `trunc`, `3.9` will be rounded to `4`.] – Andreas Rejbrand Feb 22 '13 at 20:19
  • 1
    Update 2 doesn't help. Stick to the original replacement of `Trunc` by `Round`. – David Heffernan Feb 22 '13 at 20:24
  • @David: Well, yes, it does... (At least it works in theory, and I also tested it on my system.) – Andreas Rejbrand Feb 22 '13 at 20:25
  • To convince me, you're going to need to show me some date time values where update 2 beats your original approach. I cannot see it. – David Heffernan Feb 22 '13 at 20:28
  • @David: You do know that `trunc` rounds down, like `floor`, right? The point is that you might *want* that behaviour (like Trafikverket which issues the driving licenses in Sweden), but you do not want the issue that triggered the OP's question. – Andreas Rejbrand Feb 22 '13 at 20:29
  • If you have two date times that represent time values that are exact minutes, then there is only one answer. If the time values have seconds, then you'll need to use `SecondsBetween` with a similar fix. Ultimately these are integer calculations and should be treated as such. It's just a cop out to use floating point. The original designers made a fundamental mistake in choosing to use Double. That was bogus. `TTimeStamp` would have been a much better choice. – David Heffernan Feb 22 '13 at 20:32
  • @David, the `MinutesBetween` function is supposed to work also with times that have seconds. I still think my 'Update 2' is plausible. Of course, the desired behaviour depends on the application! The point of 'Update 2' is that it behaves like the original `MinutesBetween` function for 'almost all' inputs, while the approach using only `round` often will differ. [But both my solutions fix the original issue with 629.] – Andreas Rejbrand Feb 22 '13 at 20:35
  • I'm not much of a fan of `SameValue`, `DoubleResolution` and `FuzzFactor`. It looks sloppy to me. I do see where you are coming from though now. So, I retract my objections. So, I'll restore my +1. By the way, the comments to my answer are now obsolete. – David Heffernan Feb 22 '13 at 20:44
  • @David: And deleted a long time ago! :) – Andreas Rejbrand Feb 22 '13 at 20:45
8

This is an issue that is resolved in the latest versions of Delphi. So you could either upgrade, or simply use the new code in Delphi 2010. For example this program produces the output you expect:

{$APPTYPE CONSOLE}
uses
  SysUtils, DateUtils;

function DateTimeToMilliseconds(const ADateTime: TDateTime): Int64;
var
  LTimeStamp: TTimeStamp;
begin
  LTimeStamp := DateTimeToTimeStamp(ADateTime);
  Result := LTimeStamp.Date;
  Result := (Result * MSecsPerDay) + LTimeStamp.Time;
end;

function MinutesBetween(const ANow, AThen: TDateTime): Int64;
begin
  Result := Abs(DateTimeToMilliseconds(ANow) - DateTimeToMilliseconds(AThen))
    div (MSecsPerSec * SecsPerMin);
end;

begin
  Writeln(IntToStr(MinutesBetween(StrToTime('7:00'), StrToTime('17:30'))));
  Readln;
end.

The Delphi 2010 code for MinutesBetween looks like this:

function SpanOfNowAndThen(const ANow, AThen: TDateTime): TDateTime;
begin
  if ANow < AThen then
    Result := AThen - ANow
  else
    Result := ANow - AThen;
end;

function MinuteSpan(const ANow, AThen: TDateTime): Double;
begin
  Result := MinsPerDay * SpanOfNowAndThen(ANow, AThen);
end;

function MinutesBetween(const ANow, AThen: TDateTime): Int64;
begin
  Result := Trunc(MinuteSpan(ANow, AThen));
end;

So, MinutesBetween effectively boils down to a floating point subtraction of the two date/time values. Because of the inherent in-exactness of floating point arithmetic, this subtraction can yield a value that is slightly above or below the true value. When it is below the true value, the use of Trunc will take you all the way down to the previous minute. Simply replacing Trunc with Round would resolve the problem.


As it happens the latest Delphi versions, completely overhaul the date/time calculations. There are major changes in DateUtils. It's a little harder to analyse, but the new version relies on DateTimeToTimeStamp. That converts the time portion of the value to the number of milliseconds since midnight. And it does so like this:

function DateTimeToTimeStamp(DateTime: TDateTime): TTimeStamp;
var
  LTemp, LTemp2: Int64;
begin
  LTemp := Round(DateTime * FMSecsPerDay);
  LTemp2 := (LTemp div IMSecsPerDay);
  Result.Date := DateDelta + LTemp2;
  Result.Time := Abs(LTemp) mod IMSecsPerDay;
end;

Note the use of Round. The use of Round rather than Trunc is the reason why the latest Delphi code handles MinutesBetween in a robust fashion.


Assuming that you cannot upgrade right now, I would deal with the problem like this:

  1. Leave your code unchanged. Continue to call MinutesBetween etc.
  2. When you do upgrade, your code that calls MinutesBetween etc. will now work.
  3. In the meantime fix MinutesBetween etc. with code hooks. When you do come to upgrade, you can simply remove the hooks.
Community
  • 1
  • 1
David Heffernan
  • 601,492
  • 42
  • 1,072
  • 1,490