1

This question is not about whether serializing a DateTime to a double and back is a sensible thing to do but about what to do when this is what you have to do.

The solution on the surface is to use DateTime.ToOADate(), as per Convert DateTime to Double but this loses precision quite badly, e.g.

let now = DateTime.UtcNow in DateTime.FromOADate(now.ToOADate()).Ticks - now.Ticks

results in something like val it : int64 = -7307L, which is pretty awful as that's almost a millisecond.

A more crude approach (of just converting between long and double (which is called float in F#) is actually somewhat better in this respect:

let now = DateTime.UtcNow in DateTime(int64(float(now.Ticks))).Ticks - now.Ticks

results in something like val it : int64 = -42L -- better, but still not exact. The reasons for loss of precision are discussed, for example, in C#: Double to long conversion.

So the question is: is there a way to round-trip a DateTime into a double and back, without loss of precision?

UPDATE: the accepted answer is clear at explaining how "it actually works", but it turns out that System.BitConverter.Int64BitsToDouble() and System.BitConverter.DoubleToInt64Bits() do more or less that, albeit obviously constrained to long<->double conversions, and on little-endian machines only. See https://referencesource.microsoft.com/#mscorlib/system/bitconverter.cs,db20ea77a561c0ac for the actual code.

mt99
  • 589
  • 3
  • 13
  • 5
    Why do you insist on using double? The most straightforward roundtripping is by `Ticks`, which is a `long`, and it can be restored simply by the corresponding constructor. – György Kőszeg Nov 09 '20 at 15:22
  • I address this in the first paragraph. I realise that serializing the long is obviously the right thing to do, but in my case the "API" for storing the DateTime happens to be via storing/reading a double - hence the birth of this question. – mt99 Nov 12 '20 at 21:03

3 Answers3

2

Since you don't seem to care about the actual content of the resulting double or "hacky" methods, but only the ability to convert them back and both types are unmanaged, you could use a very general approach.

If you enable unsafe code you can do a direct super fast implementation by using stackalloc:

        static void Main(string[] args)
        {
            Check(nameof(DateTime.MinValue), DateTime.MinValue);
            Check(nameof(DateTime.MaxValue), DateTime.MaxValue);
            Check(nameof(DateTime.Now), DateTime.Now);
            Check(nameof(DateTime.UtcNow), DateTime.UtcNow);
            Console.ReadLine();
        }

        static void Check(string name, DateTime @DateTime)
        {
            Console.WriteLine($@"{name} expected: {@DateTime}");
            var @double = ConvertUnmanaged<DateTime, double>(@DateTime);
            @DateTime = ConvertUnmanaged<double, DateTime>(@double);
            Console.WriteLine($@"{name} unmanaged returned: {@DateTime}");
            @double = ConvertFixed<DateTime, double>(@DateTime);
            @DateTime = ConvertFixed<double, DateTime>(@double);
            Console.WriteLine($@"{name} address returned: {@DateTime}");
        }

        // types can be of different size
        static unsafe TOut ConvertUnmanaged<TIn, TOut>(TIn pIn)
        where TIn : unmanaged
        where TOut : unmanaged
        {
            var mem = stackalloc byte[Math.Max(sizeof(TIn), sizeof(TOut))];
            var mIn = (TIn*)mem;
            *mIn = pIn;
            return *(TOut*)mIn;
        }

        // types should be of same size
        static unsafe TOut ConvertFixed<TIn, TOut>(TIn pIn)
        where TIn : unmanaged
        where TOut : unmanaged
        {
            if (sizeof(TIn) != sizeof(TOut)) throw new ArgumentException();
            return *(TOut*)(&pIn);
        }

this will output:

MinValue expected: 01.01.0001 00:00:00
MinValue unmanaged returned: 01.01.0001 00:00:00
MinValue address returned: 01.01.0001 00:00:00
MaxValue expected: 31.12.9999 23:59:59
MaxValue unmanaged returned: 31.12.9999 23:59:59
MaxValue address returned: 31.12.9999 23:59:59
Now expected: 09.11.2020 16:43:24
Now unmanaged returned: 09.11.2020 16:43:24
Now address returned: 09.11.2020 16:43:24
UtcNow expected: 09.11.2020 15:43:24
UtcNow unmanaged returned: 09.11.2020 15:43:24
UtcNow address returned: 09.11.2020 15:43:24

as you can see ConvertUnmanaged will simply convert any unmanaged Type, but the temporary holding type (double in your case) size should of cause be of the same or bigger size, than the size of the main type (DateTime in your case)

ConvertFixed is a little bit more limited

Patrick Beynio
  • 788
  • 1
  • 6
  • 13
  • 1
    cause it does not only work with double and datetime, but with any suitable unmanaged types. i too think @György-Kőszeg should post his comment (wich i upvoted) as an answer! since it's the preferable solution to this problem. my answer, while being a working solution, is more meant as sugar for people with similar issue, showing them how to bypass typechecking of unmanaged types by using pointers – Patrick Beynio Nov 09 '20 at 18:33
  • This is precisely the answer I was looking for. Essentially, I didn't know how to pack the bits of a long into the bits of a double, and you provided a general solution as a bonus. Thank you. – mt99 Nov 12 '20 at 21:06
1

So as others have said already, it would be better to use the native datetime value of ticks. But as @PatrickBeynio pointed out, if you had to, you could. Patrick's two methods are generic and pretty cool but I'll throw out a couple others. First using BitConverter and then using .Net Unsafe class.

        DateTime now = DateTime.Now;

        var bytes = BitConverter.GetBytes(now.ToBinary());
        var timeAsDouble = BitConverter.ToDouble(bytes);

        var timeAsBinary = BitConverter.ToInt64(BitConverter.GetBytes(timeAsDouble));

        DateTime roundTripped = DateTime.FromBinary(timeAsBinary);

        Console.WriteLine(now.ToString("hh:mm:ss:fff"));
        Console.WriteLine(roundTripped.ToString("hh:mm:ss:fff"));

        var binaryTime = now.ToBinary();
        ref double doubleTime = ref Unsafe.As<long,double>(ref binaryTime);

        Console.WriteLine(doubleTime);

        ref long backToBinaryTime = ref Unsafe.As<double, long>(ref doubleTime);
        roundTripped = DateTime.FromBinary(backToBinaryTime);

        Console.WriteLine(roundTripped.ToString("hh:mm:ss:fff"));
MikeJ
  • 1,299
  • 7
  • 10
0

The lazy option, which is domain-specific, but probably suitable for most applications, is to have a fixed known base number and the offset that you actually serialize/deseriliaze. The base needs to be big enough to bring the required offset for all of the DateTimes you care about into the range of what can be kept in a double.

Experimentally, I've determined that there is just a touch over 28.5 years to be had for that offset. For example, if you're are looking to roundtrip something like let dt = DateTime.Parse("2020-11-09"), you will need to subtract this base from dt.Ticks, cast and store the result to a double, and when you read it back in again, and add the base number back in. In our example, it can be something like let base = float(DateTime.Parse("2000-01-01").Ticks).

The less lazy -- and more correct -- option would involve actually reusing the bits of the long value for dt.Ticks and storing that in the double, as the number of bits is the same, but I'll leave that to other responders.

mt99
  • 589
  • 3
  • 13