【问题标题】:Roundtripping DateTime via a double without loss of precision通过 double 往返 DateTime 而不会损失精度
【发布时间】:2020-11-09 14:25:51
【问题描述】:

这个问题不是关于将 DateTime 序列化为 double 和 back 是否明智的做法,而是关于当这是您必须做的事情时该怎么做。

表面上的解决方案是使用DateTime.ToOADate(),按照Convert DateTime to Double,但这会严重损失精度,例如

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

导致类似 val it : int64 = -7307L,这几乎是一毫秒。

在这方面,更粗略的方法(只是在longdouble(在F# 中称为float)之间进行转换实际上要好一些:

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

产生类似val it : int64 = -42L 的结果——更好,但仍然不准确。例如,C#: Double to long conversion 中讨论了精度损失的原因。

所以问题是:有没有办法将DateTime 往返于double 并返回,而不会损失精度?

更新:接受的答案很清楚地解释了“它实际上是如何工作的”,但事实证明System.BitConverter.Int64BitsToDouble()System.BitConverter.DoubleToInt64Bits() 或多或少地做了这些,尽管显然受限于longdouble转换,并且仅在 little-endian 机器上。实际代码见https://referencesource.microsoft.com/#mscorlib/system/bitconverter.cs,db20ea77a561c0ac

【问题讨论】:

  • 为什么要坚持使用double?最直接的往返是通过Ticks,也就是long,通过对应的构造函数就可以恢复。
  • 我在第一段中解决了这个问题。我意识到序列化 long 显然是正确的做法,但在我的情况下,用于存储 DateTime 的“API”恰好是通过存储/读取双精度 - 因此产生了这个问题。

标签: c# .net datetime serialization f#


【解决方案1】:

由于您似乎并不关心生成的双精度或“hacky”方法的实际内容,而只关心将它们转换回来的能力并且两种类型都是unmanaged,因此您可以使用非常通用的方法。

如果您启用不安全代码,您可以使用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);
        }

这将输出:

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

如您所见,ConvertUnmanaged 将简单地转换任何 unmanaged 类型,但临时持有类型(在您的情况下为双倍)大小应该与主要类型的大小相同或更大(日期时间在你的情况下)

ConvertFixed 有点受限

【讨论】:

  • 因为它不仅适用于 double 和 datetime,而且适用于任何合适的非托管类型。我也认为@György-Kőszeg 应该发表他的评论(我赞成)作为答案!因为它是解决这个问题的首选方案。我的回答虽然是一个可行的解决方案,但对于有类似问题的人来说更像是糖,向他们展示如何使用指针绕过非托管类型的类型检查
  • 这正是我正在寻找的答案。本质上,我不知道如何将 long 的位打包成 double 的位,并且您提供了一个通用解决方案作为奖励。谢谢。
【解决方案2】:

正如其他人已经说过的那样,最好使用刻度的本机日期时间值。但正如@PatrickBeynio 指出的那样,如果你必须这样做,你可以。 Patrick 的两种方法是通用的并且非常酷,但我会扔掉其他一些方法。首先使用 BitConverter,然后使用 .Net Unsafe 类。

        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"));

【讨论】:

    【解决方案3】:

    惰性选项是特定于域的,但可能适用于大多数应用程序,它具有固定的已知基数和您实际序列化/反序列化的偏移量。基数需要足够大,以将您关心的所有日期时间所需的偏移量带入可以保存在 double 中的范围内。

    在实验中,我已经确定,这个抵消只需要 28.5 年的时间。例如,如果您希望往返于 let dt = DateTime.Parse("2020-11-09") 之类的东西,则需要从 dt.Ticks 中减去此 base,将结果转换并存储为双精度数,当您再次读回它时,然后重新添加base 号码。在我们的示例中,它可以类似于let base = float(DateTime.Parse("2000-01-01").Ticks)

    不那么懒惰——更正确——的选项将涉及实际重用dt.Tickslong 值的位并将其存储在double 中,因为位数是相同的,但我'将把它留给其他响应者。

    【讨论】:

      猜你喜欢
      • 2023-03-18
      • 1970-01-01
      • 1970-01-01
      • 2010-10-29
      • 1970-01-01
      • 2013-02-18
      • 1970-01-01
      • 2016-06-01
      • 1970-01-01
      相关资源
      最近更新 更多