Number.DecimalToNumber(ref value, ref number);
byte precision = (format.Precision == StandardFormat.NoPrecision) ? (byte)2 : format.Precision;
- Number.RoundNumber(ref number, number.Scale + precision);
+ Number.RoundNumber(ref number, number.Scale + precision, isCorrectlyRounded: false);
Debug.Assert((number.Digits[0] != 0) || !number.IsNegative); // For Decimals, -0 must print as normal 0. As it happens, Number.RoundNumber already ensures this invariant.
return TryFormatDecimalF(ref number, destination, out bytesWritten, precision);
}
Number.DecimalToNumber(ref value, ref number);
byte precision = (format.Precision == StandardFormat.NoPrecision) ? (byte)6 : format.Precision;
- Number.RoundNumber(ref number, precision + 1);
+ Number.RoundNumber(ref number, precision + 1, isCorrectlyRounded: false);
Debug.Assert((number.Digits[0] != 0) || !number.IsNegative); // For Decimals, -0 must print as normal 0. As it happens, Number.RoundNumber already ensures this invariant.
return TryFormatDecimalE(ref number, destination, out bytesWritten, precision, exponentSymbol: (byte)format.Symbol);
}
}
else
{
- // In the scenario where the first significand digit is after the cutoff, we want to treat that
- // first significand digit as the rounding digit and increase the decimalExponent by one. This
- // ensures we correctly handle the case where the first significand digit is exactly one after
+ // In the scenario where the first significant digit is after the cutoff, we want to treat that
+ // first significant digit as the rounding digit. If the first significant would cause the next
+ // digit to round, we will increase the decimalExponent by one and set the previous digit to one.
+ // This ensures we correctly handle the case where the first significant digit is exactly one after
// the cutoff, it is a 4, and the subsequent digit would round that to 5 inducing a double rounding
- // bug when NumberToString is does its own rounding checks.
-
- decimalExponent++;
+ // bug when NumberToString does its own rounding checks. However, if the first significant digit
+ // would not cause the next one to round, we preserve that digit as is.
// divide out the scale to extract the digit
outputDigit = BigInteger.HeuristicDivide(ref scaledValue, ref scale);
- Debug.Assert(outputDigit < 10);
+ Debug.Assert((0 < outputDigit) && (outputDigit < 10));
if ((outputDigit > 5) || ((outputDigit == 5) && !scaledValue.IsZero()))
{
- buffer[curDigit] = (byte)('1');
- curDigit += 1;
+ decimalExponent++;
+ outputDigit = 1;
}
+ buffer[curDigit] = (byte)('0' + outputDigit);
+ curDigit += 1;
+
// return the number of digits output
return (uint)(curDigit);
}
// In order to support more digits, we would need to update ParseFormatSpecifier to pre-parse
// the format and determine exactly how many digits are being requested and whether they
// represent "significant digits" or "digits after the decimal point".
- private const int SinglePrecisionCustomFormat = 6;
+ private const int SinglePrecisionCustomFormat = 7;
private const int DoublePrecisionCustomFormat = 15;
private const int DefaultPrecisionExponentialFormat = 6;
internal static unsafe void NumberToString(ref ValueStringBuilder sb, ref NumberBuffer number, char format, int nMaxDigits, NumberFormatInfo info)
{
number.CheckConsistency();
+ bool isCorrectlyRounded = (number.Kind == NumberBufferKind.FloatingPoint);
switch (format)
{
if (nMaxDigits < 0)
nMaxDigits = info.CurrencyDecimalDigits;
- RoundNumber(ref number, number.Scale + nMaxDigits); // Don't change this line to use digPos since digCount could have its sign changed.
+ RoundNumber(ref number, number.Scale + nMaxDigits, isCorrectlyRounded); // Don't change this line to use digPos since digCount could have its sign changed.
FormatCurrency(ref sb, ref number, nMaxDigits, info);
if (nMaxDigits < 0)
nMaxDigits = info.NumberDecimalDigits;
- RoundNumber(ref number, number.Scale + nMaxDigits);
+ RoundNumber(ref number, number.Scale + nMaxDigits, isCorrectlyRounded);
if (number.IsNegative)
sb.Append(info.NegativeSign);
if (nMaxDigits < 0)
nMaxDigits = info.NumberDecimalDigits; // Since we are using digits in our calculation
- RoundNumber(ref number, number.Scale + nMaxDigits);
+ RoundNumber(ref number, number.Scale + nMaxDigits, isCorrectlyRounded);
FormatNumber(ref sb, ref number, nMaxDigits, info);
nMaxDigits = DefaultPrecisionExponentialFormat;
nMaxDigits++;
- RoundNumber(ref number, nMaxDigits);
+ RoundNumber(ref number, nMaxDigits, isCorrectlyRounded);
if (number.IsNegative)
sb.Append(info.NegativeSign);
}
}
- RoundNumber(ref number, nMaxDigits);
+ RoundNumber(ref number, nMaxDigits, isCorrectlyRounded);
SkipRounding:
if (number.IsNegative)
nMaxDigits = info.PercentDecimalDigits;
number.Scale += 2;
- RoundNumber(ref number, number.Scale + nMaxDigits);
+ RoundNumber(ref number, number.Scale + nMaxDigits, isCorrectlyRounded);
FormatPercent(ref sb, ref number, nMaxDigits, info);
{
number.Scale += scaleAdjust;
int pos = scientific ? digitCount : number.Scale + digitCount - decimalPos;
- RoundNumber(ref number, pos);
+ RoundNumber(ref number, pos, isCorrectlyRounded: false);
if (dig[0] == 0)
{
src = FindSection(format, 2);
}
}
- internal static unsafe void RoundNumber(ref NumberBuffer number, int pos)
+ internal static unsafe void RoundNumber(ref NumberBuffer number, int pos, bool isCorrectlyRounded)
{
byte* dig = number.GetDigitsPointer();
int i = 0;
- while (i < pos && dig[i] != 0)
+ while (i < pos && dig[i] != '\0')
i++;
- if (i == pos && dig[i] >= '5')
+ if ((i == pos) && ShouldRoundUp(dig, i, number.Kind, isCorrectlyRounded))
{
while (i > 0 && dig[i - 1] == '9')
i--;
while (i > 0 && dig[i - 1] == '0')
i--;
}
+
if (i == 0)
{
if (number.Kind != NumberBufferKind.FloatingPoint)
dig[i] = (byte)('\0');
number.DigitsCount = i;
number.CheckConsistency();
+
+ bool ShouldRoundUp(byte* dig, int i, NumberBufferKind numberKind, bool isCorrectlyRounded)
+ {
+ // We only want to round up if the digit is greater than or equal to 5 and we are
+ // not rounding a floating-point number. If we are rounding a floating-point number
+ // we have one of two cases.
+ //
+ // In the case of a standard numeric-format specifier, the exact and correctly rounded
+ // string will have been produced. In this scenario, pos will have pointed to the
+ // terminating null for the buffer and so this will return false.
+ //
+ // However, in the case of a custom numeric-format specifier, we currently fall back
+ // to generating Single/DoublePrecisionCustomFormat digits and then rely on this
+ // function to round correctly instead. This can unfortunately lead to double-rounding
+ // bugs but is the best we have right now due to back-compat concerns.
+
+ var digit = dig[i];
+
+ if ((digit == '\0') || isCorrectlyRounded)
+ {
+ // Fast path for the common case with no rounding
+ return false;
+ }
+
+ if (digit > '5')
+ {
+ // Values greater than 5 always round up
+ return true;
+ }
+
+ if (digit != '5')
+ {
+ // Values less than 5 always round down
+ return false;
+ }
+
+ if (numberKind != NumberBufferKind.FloatingPoint)
+ {
+ // Non floating-point values always round up for 5
+ return true;
+ }
+
+ // Floating-point values round up if there is a non-zero tail
+ return (dig[i + 1] != '\0');
+ }
}
private static unsafe int FindSection(ReadOnlySpan<char> format, int section)