Search code examples
c#double

double.ToString(string) with custom numeric format string does not produce the expected result


I want to display double values with a specific number of decimal digits.

The documentation for custom numeric formats for double.ToString(string) says:

The "0" custom format specifier serves as a zero-placeholder symbol. If the value that is being formatted has a digit in the position where the zero appears in the format string, that digit is copied to the result string; otherwise, a zero appears in the result string. The position of the leftmost zero before the decimal point and the rightmost zero after the decimal point determines the range of digits that are always present in the result string.

Source: https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-numeric-format-strings#Specifier0

All examples given in the documentation for double.ToString(string) result in what I would expect from the above quote.

However, this does not seem to work in all cases for me:

double d = 63712373026.615219;
d.ToString("R"); // "63712373026.615219"
d.ToString("G17"); // "63712373026.615219"
d.ToString("0.000000") // "63712373026.615200"
d.ToString("#.######") // "63712373026.6152"

What is going on here?


Solution

  • Floating-point numbers are approximations: they have a limited number of bits, and they do their best to represent a number close to the one you asked for within the constraints of the available bits.

    Most of the time this works fine, but things start breaking down as you reach the limits of their precision which is around 16 digits for double and 9 digits for float.

    Specifically, a double cannot represent 63712373026.615219 exactly. With G50 or Jon Skeet's DoubleConverter, we can take a look at the exact number that the double does represent:

    63712373026.615219.ToString("G50"); // 63712373026.6152191162109375
    

    We're fine up to the 7th decimal place, but see how the closest representable number to 63712373026.615219 is actually a little bit larger?

    With some trial-and-error, we can see the range of values which all get represented as 63712373026.6152191162109375:

    63712373026.6152230.ToString("G50"); // 63712373026.61522674560546875
    63712373026.6152229.ToString("G50"); // 63712373026.6152191162109375
    63712373026.615219.ToString("G50");  // 63712373026.6152191162109375
    63712373026.6152154.ToString("G50"); // 63712373026.6152191162109375
    63712373026.6152153.ToString("G50"); // 63712373026.61521148681640625
    

    The precision limitations of double mean that everything between 63712373026.6152154 and 63712373026.6152229 gets stored as the number 63712373026.6152191162109375.

    This presents a problem for the formatter: if you asked for 63712373026.615219.ToString("0.000000"), should it give you 63712373026.615223 or 63712373026.615215 or anything in between?

    In practice, what it appears to do is to work out the range of possible values which the double might be representing, and then round to the digits which are common to all. Since 63712373026.6152229 and 63712373026.6152154 and everything in between all start with 63712373026.6152, that is what the formatter works with. Which is why it will print 63712373026.615200 if you force it to: it knows that it doesn't have enough information to fill in those last 2 digits.


    Note that I think the round-trip and G17 formats are misleading you slightly. Round-trip basically prints the fewest digits which will be parsed back into the same underlying double value. So 63712373026.615219 contains the smallest number of decimal places which gets parsed back into 63712373026.6152191162109375.

    Note that they fixed R on .NET 5:

    63712373026.615219.ToString("R"); // 63712373026.61522
    

    G17 just prints 17 digits, regardless of the underlying value of the double. Because double only has around 16 digits of precision, this is also enough to safely round-trip the double.

    This can be seen with simpler values, such as 0.1. double, not being base 10, can't exactly represent 0.1. Instead its closest value is:

    0.1.ToString("G99"); // 0.1000000000000000055511151231257827021181583404541015625
    

    However:

    0.1.ToString("R"); // 0.1
    

    The shortest value which gets represented as 0.1000000000000000055511151231257827021181583404541015625 is 0.1, so this is what R returns, even though it doesn't quite match the underlying representation. This is fine, because parsing 0.1 will result in a double whose underlying representation is 0.1000000000000000055511151231257827021181583404541015625, thus successfully round-tripping it.