Why does .NET decimal.ToString(string) round away from zero, apparently inconsistent with the language spec?

10,964

Solution 1

ToString() by default formats according to the Culture, not according to a computational aspect of the specification. Apparently the Culture for your locale (and most, from the looks of it) expects rounding away from zero.

If you want different behavior, you can pass an IFormatProvider in to ToString()

I thought the above, but you are correct that it always rounds away from zero no matter the Culture.


As also linked by a comment on this answer, here (MS Docs) is official documentation on the behavior. Excerpting from the top of that linked page, and focusing on the last two list items:

Standard numeric format strings are used to format common numeric types. A standard numeric format string takes the form Axx, where:

  • A is a single alphabetic character called the format specifier. Any numeric format string that contains more than one alphabetic character, including white space, is interpreted as a custom numeric format string. For more information, see Custom Numeric Format Strings.

  • xx is an optional integer called the precision specifier. The precision specifier ranges from 0 to 99 and affects the number of digits in the result. Note that the precision specifier controls the number of digits in the string representation of a number. It does not round the number itself. To perform a rounding operation, use the Math.Ceiling, Math.Floor, or Math.Round method.

    When precision specifier controls the number of fractional digits in the result string, the result string reflects a number that is rounded to a representable result nearest to the infinitely precise result. If there are two equally near representable results:

    • On the .NET Framework and .NET Core up to .NET Core 2.0, the runtime selects the result with the greater least significant digit (that is, using MidpointRounding.AwayFromZero).

    • On .NET Core 2.1 and later, the runtime selects the result with an even least significant digit (that is, using MidpointRounding.ToEven).


As far as your question ---

Is there a good reason this is the case? Or is this just an inconsistency in the language?

--- the answer implied by the change in behavior from Framework to Core 2.1+ is possibly, "No, there was no good reason, so we (Microsoft) went ahead and made the runtime consistent with the language in .NET Core 2.1 and later."

Solution 2

If you read the spec carefully, you will see that there is no inconsistency here.

Here's that paragraph again, with the important parts highlighted:

The result of an operation on values of type decimal is that which would result from calculating an exact result (preserving scale, as defined for each operator) and then rounding to fit the representation. Results are rounded to the nearest representable value, and, when a result is equally close to two representable values, to the value that has an even number in the least significant digit position (this is known as “banker’s rounding”). A zero result always has a sign of 0 and a scale of 0.

This part of the spec applies to arithmetic operations on decimal; string formatting is not one of those, and even if it were, it wouldn't matter because your examples are low-precision.

To demonstrate the behaviour referred to in the spec, use the following code:

Decimal d1 = 0.00000000000000000000000000090m;
Decimal d2 = 0.00000000000000000000000000110m;

// Prints: 0.0000000000000000000000000004 (rounds down)
Console.WriteLine(d1 / 2);

// Prints: 0.0000000000000000000000000006 (rounds up)
Console.WriteLine(d2 / 2);

That's all the spec is talking about. If the result of some calculation would exceed the precision limit of the decimal type (29 digits), banker's rounding is used to determine what the result will be.

Solution 3

Most likely because this is the standard way of dealing with currency. The impetus for the creation of decimal was that floating point does a poor job of dealing with currency values, so you would expect it's rules to be more aligned with accounting standards than mathematical correctness.

Share:
10,964
stack
Author by

stack

I'm a business analyst/program manager type, with C# and MS SQL skillz.

Updated on June 05, 2022

Comments

  • stack
    stack almost 2 years

    I see that, in C#, rounding a decimal, by default, uses MidpointRounding.ToEven. This is expected, and is what the C# spec dictates. However, given the following:

    • A decimal dVal
    • A format string sFmt that, when passed in to dVal.ToString(sFmt), will result in a string containing a rounded version of dVal

    ...it is apparent that decimal.ToString(string) returns a value rounded using MidpointRounding.AwayFromZero. This would appear to be a direct contradiction of the C# spec.

    My question is this: is there a good reason this is the case? Or is this just an inconsistency in the language?

    Below, for reference, I've included some code that writes to console an assortment of rounding operation results and decimal.ToString(string) operation results, each on every value in an array of decimal values. The actual outputs are embedded. After that, I've included a relevant paragraph from the C# Language Specification section on the decimal type.

    The example code:

    static void Main(string[] args)
    {
        decimal[] dArr = new decimal[] { 12.345m, 12.355m };
    
        OutputBaseValues(dArr);
        // Base values:
        // d[0] = 12.345
        // d[1] = 12.355
    
        OutputRoundedValues(dArr);
        // Rounding with default MidpointRounding:
        // Math.Round(12.345, 2) => 12.34
        // Math.Round(12.355, 2) => 12.36
        // decimal.Round(12.345, 2) => 12.34
        // decimal.Round(12.355, 2) => 12.36
    
        OutputRoundedValues(dArr, MidpointRounding.ToEven);
        // Rounding with mr = MidpointRounding.ToEven:
        // Math.Round(12.345, 2, mr) => 12.34
        // Math.Round(12.355, 2, mr) => 12.36
        // decimal.Round(12.345, 2, mr) => 12.34
        // decimal.Round(12.355, 2, mr) => 12.36
    
        OutputRoundedValues(dArr, MidpointRounding.AwayFromZero);
        // Rounding with mr = MidpointRounding.AwayFromZero:
        // Math.Round(12.345, 2, mr) => 12.35
        // Math.Round(12.355, 2, mr) => 12.36
        // decimal.Round(12.345, 2, mr) => 12.35
        // decimal.Round(12.355, 2, mr) => 12.36
    
        OutputToStringFormatted(dArr, "N2");
        // decimal.ToString("N2"):
        // 12.345.ToString("N2") => 12.35
        // 12.355.ToString("N2") => 12.36
    
        OutputToStringFormatted(dArr, "F2");
        // decimal.ToString("F2"):
        // 12.345.ToString("F2") => 12.35
        // 12.355.ToString("F2") => 12.36
    
        OutputToStringFormatted(dArr, "###.##");
        // decimal.ToString("###.##"):
        // 12.345.ToString("###.##") => 12.35
        // 12.355.ToString("###.##") => 12.36
    
        Console.ReadKey();
    }
    
    private static void OutputBaseValues(decimal[] dArr)
    {
        Console.WriteLine("Base values:");
        for (int i = 0; i < dArr.Length; i++) Console.WriteLine("d[{0}] = {1}", i, dArr[i]);
        Console.WriteLine();
    }
    
    private static void OutputRoundedValues(decimal[] dArr)
    {
        Console.WriteLine("Rounding with default MidpointRounding:");
        foreach (decimal d in dArr) Console.WriteLine("Math.Round({0}, 2) => {1}", d, Math.Round(d, 2));
        foreach (decimal d in dArr) Console.WriteLine("decimal.Round({0}, 2) => {1}", d, decimal.Round(d, 2));
        Console.WriteLine();
    }
    
    private static void OutputRoundedValues(decimal[] dArr, MidpointRounding mr)
    {
        Console.WriteLine("Rounding with mr = MidpointRounding.{0}:", mr);
        foreach (decimal d in dArr) Console.WriteLine("Math.Round({0}, 2, mr) => {1}", d, Math.Round(d, 2, mr));
        foreach (decimal d in dArr) Console.WriteLine("decimal.Round({0}, 2, mr) => {1}", d, decimal.Round(d, 2, mr));
        Console.WriteLine();
    }
    
    private static void OutputToStringFormatted(decimal[] dArr, string format)
    {
        Console.WriteLine("decimal.ToString(\"{0}\"):", format);
        foreach (decimal d in dArr) Console.WriteLine("{0}.ToString(\"{1}\") => {2}", d, format, d.ToString(format));
        Console.WriteLine();
    }
    


    The paragraph from section 4.1.7 of the C# Language Specification ("The decimal type") (get the full spec here (.doc)):

    The result of an operation on values of type decimal is that which would result from calculating an exact result (preserving scale, as defined for each operator) and then rounding to fit the representation. Results are rounded to the nearest representable value, and, when a result is equally close to two representable values, to the value that has an even number in the least significant digit position (this is known as “banker’s rounding”). A zero result always has a sign of 0 and a scale of 0.

    It's easy to see that they may not have been considering ToString(string) in this paragraph, but I'm inclined to think it fits in this description.

  • JonDrnek
    JonDrnek over 14 years
    I thought this recently as well, but after being prompted I could not actually find anywhere in CultureInfo or NumberFormatInfo where rounding is specified or determined. Can you point to where this actually occurs?
  • Michael Greene
    Michael Greene over 14 years
    I cannot, and it looks like I am probably wrong. License Warning: CLR source after link It looks like koders.com/cpp/… NumberToStringFormat calls koders.com/cpp/… RoundNumber which always rounds away from zero.
  • stack
    stack over 14 years
    "It's easy to see that they may not have been considering ToString(string) in this paragraph, but I'm inclined to think it fits in this description."
  • stack
    stack over 14 years
    I'm not sure I understand your answer as it relates to the question.
  • stack
    stack over 14 years
    Since the best answer seems to be that it's an inconsistency in the language, I'm going to mark this response as the answer. Thanks!
  • Michael Greene
    Michael Greene about 14 years
    Rex et al.: I couldn't find anything when I initially answered this question, but running ANTS on some code of mine I do see System.Decimal.ToString() > System.Globalization.NumberFormatInfo.get_CurrentInfo() -- I can't explain it given my comment above though.
  • Ashley
    Ashley over 9 years
    Just for the sake of completeness, I found that the MSDN documentation on formatting strings explicitly states the rounding behaviour: link. "When precision specifier controls the number of fractional digits in the result string, the result strings reflect numbers that are rounded away from zero (that is, using MidpointRounding.AwayFromZero)."
  • reggaeguitar
    reggaeguitar over 9 years
    The links in the second comment are broken