在C#中将double格式化以便输出

69

通过运行与在.NET中double乘法是否有问题?相关的快速实验并阅读一些关于C#字符串格式化的文章,我认为以下内容:

{
    double i = 10 * 0.69;
    Console.WriteLine(i);
    Console.WriteLine(String.Format("  {0:F20}", i));
    Console.WriteLine(String.Format("+ {0:F20}", 6.9 - i));
    Console.WriteLine(String.Format("= {0:F20}", 6.9));
}

这段C代码的C#等效代码是什么:

{
    double i = 10 * 0.69;

    printf ( "%f\n", i );
    printf ( "  %.20f\n", i );
    printf ( "+ %.20f\n", 6.9 - i );
    printf ( "= %.20f\n", 6.9 );
}

然而,C#生成的输出是:

6.9
  6.90000000000000000000
+ 0.00000000000000088818
= 6.90000000000000000000

尽管在调试器中显示为与值6.9相等的值6.89999999999999946709(而不是6.9)。

与 C 相比,它显示了所需格式要求的精度:

6.900000                          
  6.89999999999999946709          
+ 0.00000000000000088818          
= 6.90000000000000035527          

发生了什么事?

( Microsoft .NET Framework Version 3.51 SP1 / Visual Studio C# 2008 Express Edition )


我有数值计算背景并有实现区间算法的经验——一种用于估计复杂数值系统中由精度限制引起的误差的技术。为了获得悬赏,不要试图解释存储精度——在这种情况下,它只是一个64位双精度浮点数的一个ULP之差。

为了获得悬赏,我想知道.Net是否可以将double格式化到C代码中所需的精度。


1
在我看来,C# 显示的精度是相同的,只是它比 C++ 更精确地处理数字... - Matthew Scharley
17
C#编译器有可能会看到10*0.69这个表达式,然后自言自语地说:“哦,我知道这个!它等于6.9!”然后在运行时直接用6.9代替这个表达式,以避免实际计算。 - Matthew Scharley
22
FWIW - 在双变量中使用'i'让我产生了认知失调。 - plinth
1
@Randoplh Potter:发帖人的抱怨是,.NET的String.Format在提取到20个小数位时没有显示双精度近似值。 - Powerlord
4
关闭优化会有什么不同? - Paul Williams
显示剩余9条评论
10个回答

74
问题在于.NET在应用您的格式之前,总是会将double四舍五入为15个有效十进制位,而不考虑您所请求的精度和二进制数的确切小数值。

我猜Visual Studio调试器有自己的格式/显示例程,直接访问内部二进制数,这解释了您的C#代码、C代码和调试器之间的差异。

没有内置的方法可以让您访问double的确切小数值,也不能让您将double格式化为特定数量的小数位,但您可以通过拆分内部二进制数并重建它,作为十进制值的字符串表示来自行完成此操作。

或者,您可以使用Jon Skeet的DoubleConverter(在他的“二进制浮点数和.NET”文章中链接) 。该类具有一个ToExactString方法,可返回double的确切小数值。您可以轻松修改此方法以实现输出舍入到特定精度的功能。

double i = 10 * 0.69;
Console.WriteLine(DoubleConverter.ToExactString(i));
Console.WriteLine(DoubleConverter.ToExactString(6.9 - i));
Console.WriteLine(DoubleConverter.ToExactString(6.9));

// 6.89999999999999946709294817992486059665679931640625
// 0.00000000000000088817841970012523233890533447265625
// 6.9000000000000003552713678800500929355621337890625

17
不,这些数字不是垃圾数字,它们是一个十进制数的二进制表示的最接近逼近值的十进制表示。这不是随意的,而是基于IEEE-754规范和52位小数的纯数学计算。你提到的那个数字是四舍五入后的结果。例如,中间的数字相当于 1/2^50,换句话说,它只差了一位二进制位。 - Abel
5
它们被称为垃圾,意思是它们可以被丢弃而不会失去任何价值——双精度值代表实数线上的一个区间,而不是一个确切的值,因此6.8999999999999995和“精确”版本一样都是正确的值,因为两者都在该区间内。有三种不同的行为方式,它们在某种程度上都是正确的:在区间内具有最小位数的十进制值(往返值或leppie方案值)、 区间的中心(上面的“精确”值)或比精确值多或少半个ULP的范围。 - Pete Kirkham
3
你有没有关于".NET在应用格式之前,总是将double数据类型四舍五入到15位有效小数位"的参考资料? - Pete Kirkham
1
默认情况下,返回值只包含15位精度,尽管内部维护了最大17位精度。如果此实例的值大于15位,则ToString返回PositiveInfinitySymbolNegativeInfinitySymbol而不是预期的数字。如果您需要更高的精度,请使用“G17”格式规范指定format,它始终返回17位精度,或者使用“R”,如果该数字可以用该精度表示,则返回15位数字,否则返回17位数字。 - LukeH
1
@PeteKirkham 这通常是正确的,但有时知道 double 的确切值很重要。我在模拟方面工作,经常记录方法的双重输入值。我期望如果我在以后的模拟运行中将相同的 double 传递给相同的方法,该方法将给出完全相同的结果。这是一个著名的例子,微小的舍入误差影响了模拟:https://royalsocietypublishing.org/doi/pdf/10.1098/rsbm.2009.0004(第143页,最后两段)。 - hypehuman
显示剩余8条评论

28
Digits after decimal point
// just two decimal places
String.Format("{0:0.00}", 123.4567);      // "123.46"
String.Format("{0:0.00}", 123.4);         // "123.40"
String.Format("{0:0.00}", 123.0);         // "123.00"

// max. two decimal places
String.Format("{0:0.##}", 123.4567);      // "123.46"
String.Format("{0:0.##}", 123.4);         // "123.4"
String.Format("{0:0.##}", 123.0);         // "123"
// at least two digits before decimal point
String.Format("{0:00.0}", 123.4567);      // "123.5"
String.Format("{0:00.0}", 23.4567);       // "23.5"
String.Format("{0:00.0}", 3.4567);        // "03.5"
String.Format("{0:00.0}", -3.4567);       // "-03.5"

Thousands separator
String.Format("{0:0,0.0}", 12345.67);     // "12,345.7"
String.Format("{0:0,0}", 12345.67);       // "12,346"

Zero
Following code shows how can be formatted a zero (of double type).

String.Format("{0:0.0}", 0.0);            // "0.0"
String.Format("{0:0.#}", 0.0);            // "0"
String.Format("{0:#.0}", 0.0);            // ".0"
String.Format("{0:#.#}", 0.0);            // ""

Align numbers with spaces
String.Format("{0,10:0.0}", 123.4567);    // "     123.5"
String.Format("{0,-10:0.0}", 123.4567);   // "123.5     "
String.Format("{0,10:0.0}", -123.4567);   // "    -123.5"
String.Format("{0,-10:0.0}", -123.4567);  // "-123.5    "

Custom formatting for negative numbers and zero
String.Format("{0:0.00;minus 0.00;zero}", 123.4567);   // "123.46"
String.Format("{0:0.00;minus 0.00;zero}", -123.4567);  // "minus 123.46"
String.Format("{0:0.00;minus 0.00;zero}", 0.0);        // "zero"

Some funny examples
String.Format("{0:my number is 0.0}", 12.3);   // "my number is 12.3"
String.Format("{0:0aaa.bbb0}", 12.3);

5
我可以自己找到http://www.csharp-examples.net/string-format-double/;问题是当请求的数字位数与变量精度不一致时的行为。 - Pete Kirkham

10

请看此MSDN参考文献。其中指出,数字会被四舍五入到所请求的小数位数。

如果您使用 "{0:R}" ,它将生成所谓的“往返”值,请查看此MSDN参考文献以获取更多信息,以下是我的代码和输出:

double d = 10 * 0.69;
Console.WriteLine("  {0:R}", d);
Console.WriteLine("+ {0:F20}", 6.9 - d);
Console.WriteLine("= {0:F20}", 6.9);

输出

  6.8999999999999995
+ 0.00000000000000088818
= 6.90000000000000000000

我不需要往返值,我想知道如何获得舍入到我要求的小数位数的值,这种情况下应该是6.89999999999999946709。 - Pete Kirkham
如果我没记错的话,双精度数据类型可以精确到16位数字,所以我的示例中的数字是您可以得到的最精确的数字,任何比这更多的数字都需要更大的数据类型。 - Timothy Walters
这是MSDN文档中的一句话: 默认情况下,Double值包含15个小数位的精度,尽管内部维护了最多17个数字。 - Timothy Walters
如果FF20打印出6.89999999999999950000,我不介意,因为在d和6.9之间没有任何中间值,但它并没有这样做 - 它打印出的值既不是请求的精度,也不是内部使用的精度,也不是往返值。非常奇怪。 - Pete Kirkham
是的,我也觉得很奇怪,我试过几种方法来获得额外的 0,但都不行...我想我可以使用旧的 hack 方法进行填充,但这感觉很丑陋(例如,在 n = requestedLength - d.ToString("R").Length 的情况下,填充 n 个“0”)。 - Timothy Walters

8
尽管这个问题现在已经关闭了,但我认为值得提一下这种暴行是如何产生的。从某种意义上说,你可以责怪C#规范,规范规定double必须具有15或16位数字的精度(IEEE-754的结果)。稍微往后(第4.1.6节),规范中指出实现允许使用更高的精度。请注意:更高的精度,而不是更低的。它们甚至可以偏离IEEE-754:类型为x * y / z的表达式,其中x * y将产生+/-INF,但在除法后处于有效范围内,不必导致错误。这个特性使编译器在那些能够产生更好性能的体系结构中使用更高的精度变得更容易。
但我答应给出一个“理由”。以下是Shared Source CLI中的一句引用(你在最近的评论中请求资源),位于clr/src/vm/comnumber.cpp中:
为了得到既方便显示又可往返的数字,我们使用15位数字解析数字,然后确定它是否循环回到相同的值。如果是,我们将该数字转换为字符串,否则我们重新使用17位数字解析并显示它。换句话说:微软的CLI开发团队决定同时进行往返和显示漂亮的值,而不是让人头疼的阅读。好还是坏?我希望能有选择加入或退出。它用来找出任何给定数字的循环性的技巧是什么?转换为通用的NUMBER结构(其中具有双精度浮点数属性的单独字段)然后再转换回去,然后比较结果是否不同。如果不同,则使用确切的值(例如您的中间值与6.9-i),如果相同,则使用“漂亮的值”。
如您在对Andyp的评论中已经指出的那样,6.90...006.89...9467在位运算上是相等的。现在您知道为什么使用0.0...8818:它在位运算上与0.0不同。
这个“15位数字障碍”是硬编码的,只能通过重新编译CLI、使用Mono或呼叫Microsoft并说服他们添加一个选项来打印完整的“精度”(它实际上并不是精度,但由于缺乏更好的词语)。可能更容易的方法是自己计算52位精度或使用前面提到的库。
编辑:如果您想自己尝试IEE-754浮点数,请考虑使用此在线工具,它会显示浮点数的所有相关部分。

7

使用

Console.WriteLine(String.Format("  {0:G17}", i));

这将为您提供所有17位数字。默认情况下,Double值包含15个小数位的精度,但内部维护最多17位数字。如果可以使用该精度表示该数字,则{0:R}不会始终为您提供17个数字,它将提供15个数字。
如果该数字可以使用该精度表示,则返回15个数字;如果只能使用最大精度表示该数字,则返回17个数字。无法使double返回更多数字,因为这是实现方式。如果您不喜欢它,请自己创建新的double类...
.NET的double无法存储超过17个数字,因此您不能在调试器中看到6.89999999999999946709,而只能看到6.8999999999999995。请提供图像以证明我们错误。

1
double类型不能存储17个十进制数字,而是存储52+1个二进制数字。1/2^52的精确表示有52个十进制数字,因此在出现尾随零之前应该能够输出52个十进制数字。我正在尝试避免截断为十进制值。调试器中的值与我描述的一样 - 与C中输出的值相同。 - Pete Kirkham
我所指的是“17位数字精度”。 - ceciliaSHARP

2
这个问题的答案很简单,可以在MSDN上找到。
请记住,浮点数只能近似表示十进制数,并且浮点数的精度决定了它近似表示十进制数的准确程度。默认情况下,Double值包含15位小数精度,尽管内部保留最大17位数字。
在你的例子中,i的值为6.89999999999999946709,其第3到第16位(包括整数部分)都是数字9。转换成字符串时,框架将数字四舍五入到第15位。
i     = 6.89999999999999 946709
digit =           111111 111122
        1 23456789012345 678901

这些大致是正确的,尽管略过了IEE在x87和SSE系统中的工作原理,而这对我所做的工作很重要——特别是如果您可以在.NET上实现区间算术,在这种情况下让ULP可见非常重要。问题是如何格式化输出以获取内部值到用户指定的位数,而不是将其截断的四舍五入值。 - Pete Kirkham

2
我尝试重现你的发现,但是当我在调试器中观察'i'时,它显示为'6.8999999999999995'而不是你在问题中写的'6.89999999999999946709'。你能提供重现你所看到的步骤吗?
要查看调试器显示的内容,您可以使用DoubleConverter,如以下代码行所示:
Console.WriteLine(TypeDescriptor.GetConverter(i).ConvertTo(i, typeof(string)));

希望这可以帮助到您!

编辑:我想我比想象中更累了,当然这与格式化为往返值相同(如之前所述)。


该值在调试器中显示为C格式化呈现的值“6.89999999999999946709”,而不是.NET字符串格式化输出的“6.90..0”。6.8999999999999995与6.89999999999999946709按位相等,因此将它们区分为值是没有用的。问题在于.NET将6.89999999999999946709格式化为“6.90...”。 - Pete Kirkham
经过一些休息,我现在明白这只是同一个数字的两个表示(具有不同数量的小数位数),而你的问题实际上只涉及格式。我在 MSDN 上搜索了一段时间,但似乎找不到一种在格式化时抑制四舍五入的方法,这似乎是你想要做的。 - andyp

1
另一种方法,从该方法开始:

double i = (10 * 0.69);
Console.Write(ToStringFull(i));       // Output 6.89999999999999946709294817
Console.Write(ToStringFull(-6.9)      // Output -6.90000000000000035527136788
Console.Write(ToStringFull(i - 6.9)); // Output -0.00000000000000088817841970012523233890533

一个可直接使用的函数...
public static string ToStringFull(double value)
{
    if (value == 0.0) return "0.0";
    if (double.IsNaN(value)) return "NaN";
    if (double.IsNegativeInfinity(value)) return "-Inf";
    if (double.IsPositiveInfinity(value)) return "+Inf";

    long bits = BitConverter.DoubleToInt64Bits(value);
    BigInteger mantissa = (bits & 0xfffffffffffffL) | 0x10000000000000L;
    int exp = (int)((bits >> 52) & 0x7ffL) - 1023;
    string sign = (value < 0) ? "-" : "";

    if (54 > exp)
    {
        double offset = (exp / 3.321928094887362358); //...or =Math.Log10(Math.Abs(value))
        BigInteger temp = mantissa * BigInteger.Pow(10, 26 - (int)offset) >> (52 - exp);
        string numberText = temp.ToString();
        int digitsNeeded = (int)((numberText[0] - '5') / 10.0 - offset);
        if (exp < 0)
            return sign + "0." + new string('0', digitsNeeded) + numberText;
        else
            return sign + numberText.Insert(1 - digitsNeeded, ".");
    }
    return sign + (mantissa >> (52 - exp)).ToString();
}

它是如何工作的

为了解决这个问题,我使用了BigInteger工具。对于大值,只需通过指数左移尾数即可。对于小值,我们不能直接右移,因为那样会丢失精度位。我们必须先将其乘以10^n以增加一些额外的大小,然后进行右移。之后,我们将小数点向左移动n个位置。更多文本/代码在这里


0

答案是肯定的,.NET中存在双重打印问题,会导致打印出多余的数字。

您可以在此处阅读正确实现方法

我在IronScheme中也不得不这样做。

> (* 10.0 0.69)
6.8999999999999995
> 6.89999999999999946709
6.8999999999999995
> (- 6.9 (* 10.0 0.69))
8.881784197001252e-16
> 6.9
6.9
> (- 6.9 8.881784197001252e-16)
6.8999999999999995

注意:C和C#都有正确的值,只是打印出错了。
更新:我仍在寻找导致这一发现的邮件列表对话。

0

我找到了这个快速解决方案。

    double i = 10 * 0.69;
    System.Diagnostics.Debug.WriteLine(i);


    String s = String.Format("{0:F20}", i).Substring(0,20);
    System.Diagnostics.Debug.WriteLine(s + " " +s.Length );

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接