diff --git a/CLAUDE.md b/CLAUDE.md index 68cdb2b..71d7425 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,7 +40,7 @@ dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' --job s - The `sanitize` constructor parameter controls whether trailing zeros are removed (default: true) - Constants (`Zero`, `One`, `Pi`, `E`, `Tau`) are pre-computed static instances - As a value type it can't be null or inherited. Don't add null checks for `PreciseNumber` parameters, and don't reintroduce `protected` members -- Conversions to integer types go through `BigInteger`, so range checks, clamping, and wrapping follow its conventions. Conversions to `double`, `float`, `Half`, and `decimal` render `significand E exponent` and parse it, because the runtime parsers round correctly, with Clinger's fast path for small values. NaN and infinity coming in follow `BigInteger` too +- Conversions to integer types go through `BigInteger`, so range checks, clamping, and wrapping follow its conventions. Conversions to `double`, `float`, `Half`, and `decimal` render normalized scientific notation (`d.ddd…E±n`) and parse it, because the runtime parsers round correctly, with Clinger's fast path for small values. Keep one digit before the point. The .NET 7 and 8 parsers clamp an exponent above 1000 and still offset it by every digit ahead of the point, so a long significand rendered as an integer parses as zero there. Conversions from `double`, `float`, and `Half` use the shortest text that round-trips (`"R"`). NaN and infinity coming in follow `BigInteger` too ### Test Structure @@ -58,8 +58,10 @@ Most classes are parameterised by `Digits` (8, 30, 200). That axis is the point: Allocation is reported alongside time and matters just as much. The number is a value type, so the only allocations are `BigInteger` digit arrays, and avoiding an intermediate shows up in `Allocated` -before it shows up in `Mean`. Comparisons, and addition, subtraction, and multiplication of -significands that fit in an `int`, should allocate nothing at all. +before it shows up in `Mean`. Comparison, addition, subtraction, and multiplication should allocate +nothing when the operands and every intermediate and final significand fit in an `int`. Exponent +alignment counts, so `1 + 0.0000000001` allocates because it scales 1 by 10^10, and `99999 * 99999` +allocates because its product is 9,999,800,001. Run the relevant benchmarks before and after any change to the library's internals. See `PreciseNumber.Benchmarks/README.md` for details. diff --git a/PreciseNumber.Test/PreciseNumberConversionTests.cs b/PreciseNumber.Test/PreciseNumberConversionTests.cs index 01871cd..bb69740 100644 --- a/PreciseNumber.Test/PreciseNumberConversionTests.cs +++ b/PreciseNumber.Test/PreciseNumberConversionTests.cs @@ -45,8 +45,11 @@ private static void AssertFromInAllModes(TFrom value, string expected) private static void AssertToInAllModes(string value, TTo expected) where TTo : INumberBase + => AssertToInAllModes(P(value), expected); + + private static void AssertToInAllModes(PreciseNumber number, TTo expected) + where TTo : INumberBase { - PreciseNumber number = P(value); Assert.AreEqual(expected, Checked(number), $"Checked to {typeof(TTo).Name}"); Assert.AreEqual(expected, Saturating(number), $"Saturating to {typeof(TTo).Name}"); Assert.AreEqual(expected, Truncating(number), $"Truncating to {typeof(TTo).Name}"); @@ -295,4 +298,84 @@ public void ToUsesTheSameConversions() double.Parse("3.14159265358979323846264338327950288419716939937510582097494", NumberStyles.Float, CultureInfo.InvariantCulture), P("3.14159265358979323846264338327950288419716939937510582097494").To()); } + + [TestMethod] + public void FromBinaryFloatingPointKeepsShortestRoundTripDigits() + { + AssertFromInAllModes(double.MaxValue, "1.7976931348623157e308"); + AssertFromInAllModes(double.MinValue, "-1.7976931348623157e308"); + AssertFromInAllModes(0.1 + 0.2, "0.30000000000000004"); + AssertFromInAllModes(float.MaxValue, "3.4028235e38"); + AssertFromInAllModes(1.0f / 3, "0.33333334"); + AssertFromInAllModes(0.3048, "0.3048"); + AssertFromInAllModes(0.1f, "0.1"); + } + + [TestMethod] + public void BinaryFloatingPointRoundTripsThroughPreciseNumber() + { + foreach (double value in new[] { double.MaxValue, double.MinValue, double.Epsilon, -double.Epsilon, 2.2250738585072014e-308, 0.1 + 0.2, 1.0 / 3 }) + { + Assert.AreEqual(value, Checked(Checked(value)), value.ToString("R", CultureInfo.InvariantCulture)); + } + + foreach (float value in new[] { float.MaxValue, float.MinValue, float.Epsilon, 1.0f / 3, 16777216f, 0.1f }) + { + Assert.AreEqual(value, Checked(Checked(value)), value.ToString("R", CultureInfo.InvariantCulture)); + } + + foreach (Half value in new[] { Half.MaxValue, Half.MinValue, Half.Epsilon, (Half)0.1 }) + { + Assert.AreEqual(value, Checked(Checked(value)), value.ToString(CultureInfo.InvariantCulture)); + } + } + + [TestMethod] + public void ToFloatingPointWithOverAThousandFractionDigitsIsCorrectlyRounded() + { + // The .NET 7 and 8 parsers clamp an exponent above 1000 while still counting every digit before + // the 'E', so rendering the whole significand ahead of the exponent turned these values into zero. + string oneWithLongTail = "1." + new string('0', 1000) + "1"; + AssertToInAllModes(oneWithLongTail, 1.0); + AssertToInAllModes(oneWithLongTail, 1.0f); + AssertToInAllModes(oneWithLongTail, Half.One); + AssertToInAllModes(oneWithLongTail, 1m); + AssertToInAllModes("0.1" + new string('0', 1100) + "1", 0.1m); + + // Halfway cases round to even, so the nonzero digit far past the halfway point has to be seen to round up. + AssertToInAllModes("9007199254740993." + new string('0', 1500) + "1", 9007199254740994.0); + AssertToInAllModes("16777217." + new string('0', 1200) + "1", 16777218f); + + string manyDigits = "1." + string.Concat(Enumerable.Repeat("2345678901", 150)); + string smallWithManyDigits = "0." + new string('0', 300) + "1" + string.Concat(Enumerable.Repeat("2345678901", 120)); + foreach (string text in new[] { manyDigits, "-" + manyDigits, smallWithManyDigits }) + { + AssertToInAllModes(text, double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture)); + AssertToInAllModes(text, float.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture)); + } + } + + [TestMethod] + public void SmallestExponentConvertsToZero() + { + PreciseNumber[] values = + [ + P("1E-2147483648"), + PreciseNumber.CreateFromComponents(int.MinValue, BigInteger.Parse("-123456789012345678901234567890", CultureInfo.InvariantCulture)), + ]; + + foreach (PreciseNumber value in values) + { + AssertToInAllModes(value, 0.0); + AssertToInAllModes(value, 0.0f); + AssertToInAllModes(value, Half.Zero); + AssertToInAllModes(value, 0m); + AssertToInAllModes(value, 0); + AssertToInAllModes(value, 0L); + AssertToInAllModes(value, (byte)0); + AssertToInAllModes(value, Int128.Zero); + AssertToInAllModes(value, UInt128.Zero); + AssertToInAllModes(value, BigInteger.Zero); + } + } } diff --git a/PreciseNumber.Test/PreciseNumberTests.cs b/PreciseNumber.Test/PreciseNumberTests.cs index a5fc5e9..ef2a039 100644 --- a/PreciseNumber.Test/PreciseNumberTests.cs +++ b/PreciseNumber.Test/PreciseNumberTests.cs @@ -403,7 +403,9 @@ public void TestStaticRound() { PreciseNumber number = 1.2345.ToPreciseNumber(); PreciseNumber result = PreciseNumber.Round(number, 2); - Assert.AreEqual(1.24.ToPreciseNumber(), result); + + // The dropped digits are 45, below half of 100, so this rounds down. + Assert.AreEqual(1.23.ToPreciseNumber(), result); } [TestMethod] @@ -963,11 +965,74 @@ public void TestReduceSignificance() { PreciseNumber number = PreciseNumber.CreateFromComponents(0, 12345); PreciseNumber result = number.ReduceSignificance(3); - Assert.AreEqual(124, result.Significand); + + // The dropped digits are 45, below half of 100, so this rounds down. + Assert.AreEqual(123, result.Significand); Assert.AreEqual(2, result.Exponent); Assert.AreEqual(number, number.ReduceSignificance(5)); } + [TestMethod] + public void TestReduceSignificanceRoundsHalfAwayFromZeroOnTheDroppedDigits() + { + static PreciseNumber P(string text) => PreciseNumber.Parse(text, CultureInfo.InvariantCulture); + + (string Input, int Digits, string Expected)[] cases = + [ + ("123.456", 3, "123"), + ("123.5", 3, "124"), + ("123.449", 3, "123"), + ("123.4999999", 3, "123"), + ("123.5000001", 3, "124"), + ("999.5", 3, "1000"), + ("-123.456", 3, "-123"), + ("-123.5", 3, "-124"), + ("-123.449", 3, "-123"), + ("-999.96", 3, "-1000"), + + // One dropped digit, where the old rounding was already right. + ("123.45", 4, "123.5"), + ("123.44", 4, "123.4"), + ("-123.45", 4, "-123.5"), + ]; + + foreach ((string input, int digits, string expected) in cases) + { + Assert.AreEqual(P(expected), P(input).ReduceSignificance(digits), $"{input} to {digits} significant digits"); + } + } + + [TestMethod] + public void TestRoundRoundsHalfAwayFromZeroOnTheDroppedDigits() + { + static PreciseNumber P(string text) => PreciseNumber.Parse(text, CultureInfo.InvariantCulture); + + (string Input, int Decimals, string Expected)[] cases = + [ + ("1.2345", 2, "1.23"), + ("1.235", 2, "1.24"), + ("1.2349", 2, "1.23"), + ("1.2349999", 2, "1.23"), + ("1.2350001", 2, "1.24"), + ("9.995", 2, "10"), + ("0.0049", 2, "0"), + ("0.005", 2, "0.01"), + ("-1.2345", 2, "-1.23"), + ("-1.235", 2, "-1.24"), + ("-1.2349", 2, "-1.23"), + + // One dropped digit, where the old rounding was already right. + ("1.234", 2, "1.23"), + ("1.225", 2, "1.23"), + ("-1.225", 2, "-1.23"), + ]; + + foreach ((string input, int decimals, string expected) in cases) + { + Assert.AreEqual(P(expected), P(input).Round(decimals), $"{input} to {decimals} decimal places"); + } + } + [TestMethod] public void TestMakeCommonizedAndGetExponent() { @@ -1606,11 +1671,12 @@ public void TestExpWithNegativePower() { PreciseNumber result = PreciseNumber.Exp(-1.ToPreciseNumber()); - // Exp routes through a double, so 1/e is taken to the precision Exp actually delivers - // rather than the full precision Divide is now capable of. - PreciseNumber expected = PreciseNumber.Divide(PreciseNumber.One, PreciseNumber.E, result.SignificantDigits); + // Exp routes through a double, and the result keeps every digit that double needs to round-trip. + // Its 17th digit comes from binary rounding, so it's 3 where 1/e continues 0.36787944117144232159. + PreciseNumber expected = PreciseNumber.Parse("0.36787944117144233", CultureInfo.InvariantCulture); Assert.AreEqual(expected, result); + Assert.AreEqual(Math.Exp(-1), result.To()); } [TestMethod] @@ -1800,6 +1866,21 @@ public void TestTryParseStringWithInvalidInput() Assert.AreEqual(default, result); } + [TestMethod] + public void TestParseRejectsExponentsOutsideIntRange() + { + foreach (string input in new[] { "1e99999999999", "1e-99999999999", "1.5E-2147483648", "10E2147483647" }) + { + Assert.ThrowsExactly(() => PreciseNumber.Parse(input, CultureInfo.InvariantCulture), input); + bool success = PreciseNumber.TryParse(input, null, out PreciseNumber result); + Assert.IsFalse(success, input); + Assert.AreEqual(PreciseNumber.Zero, result, input); + } + + Assert.AreEqual(int.MinValue, PreciseNumber.Parse("1E-2147483648", CultureInfo.InvariantCulture).Exponent); + Assert.AreEqual(int.MaxValue, PreciseNumber.Parse("1E2147483647", CultureInfo.InvariantCulture).Exponent); + } + [TestMethod] public void TestEValue() { diff --git a/PreciseNumber/PreciseNumber.Conversions.cs b/PreciseNumber/PreciseNumber.Conversions.cs index 9c26278..bd32012 100644 --- a/PreciseNumber/PreciseNumber.Conversions.cs +++ b/PreciseNumber/PreciseNumber.Conversions.cs @@ -85,8 +85,9 @@ private enum ConversionMode /// /// Integers, and convert exactly. Binary floating point /// values convert through their decimal text, so 0.3048 becomes exactly 0.3048 rather than the - /// binary fraction nearest to it. A keeps 16 significant digits and a - /// 8, which is the same rounding applies. + /// binary fraction nearest to it. The text is the shortest that round-trips, so converting the result back + /// gives the original value, even for . It's the same text + /// uses. /// public static bool TryConvertFromChecked(TOther value, out PreciseNumber result) where TOther : INumberBase @@ -382,8 +383,9 @@ private BigInteger TruncateToBigInteger() return Significand * Pow10(Exponent); } - // Every digit sits after the decimal point, so the integral part is zero. - return -Exponent >= SignificantDigits + // Every digit sits after the decimal point, so the integral part is zero. Widened, because + // negating int.MinValue wraps. + return -(long)Exponent >= SignificantDigits ? BigInteger.Zero : BigInteger.Divide(Significand, Pow10(-Exponent)); } @@ -392,7 +394,8 @@ private double ToDouble() { // Clinger's fast path: when the significand and the power of ten are both exact doubles, one // multiplication or division is correctly rounded by IEEE 754 itself. - if (int.Abs(Exponent) <= MaxExactDoublePowerOfTen && BigInteger.Abs(Significand) <= MaxExactDoubleSignificand) + // A range pattern rather than int.Abs, which throws for int.MinValue. + if (Exponent is >= -MaxExactDoublePowerOfTen and <= MaxExactDoublePowerOfTen && BigInteger.Abs(Significand) <= MaxExactDoubleSignificand) { double significand = (double)Significand; return Exponent >= 0 @@ -405,7 +408,7 @@ private double ToDouble() private float ToSingle() { - if (int.Abs(Exponent) <= MaxExactSinglePowerOfTen && BigInteger.Abs(Significand) <= MaxExactSingleSignificand) + if (Exponent is >= -MaxExactSinglePowerOfTen and <= MaxExactSinglePowerOfTen && BigInteger.Abs(Significand) <= MaxExactSingleSignificand) { float significand = (float)Significand; return Exponent >= 0 @@ -444,17 +447,30 @@ private decimal ToDecimal(ConversionMode mode) } /// - /// Renders the number as significand E exponent and parses it as . + /// Renders the number in normalized scientific notation, d.ddd…E±n, and parses it as . /// /// /// The runtime's parsers round correctly however many digits they are given, which is what makes this - /// exact where multiplying by Math.Pow(10, exponent) is not. + /// exact where multiplying by Math.Pow(10, exponent) is not. Every digit is rendered, because a + /// value just past a halfway point can differ from it only in a digit far beyond what the destination + /// holds. Only one digit goes before the point because the .NET 7 and 8 parsers clamp an exponent above + /// 1000 to 9999 and still offset it by every digit ahead of the point, so a rendered integer significand + /// with more than 1000 fraction digits parsed as zero. In normalized form, any value within the range of + /// the destination has an exponent well inside that limit. /// private TNumber ParseAs() where TNumber : INumberBase { - // The significand's digits, its sign, the 'E', and an exponent of up to eleven characters. - int length = SignificantDigits + 13; + if (Significand.IsZero) + { + return TNumber.Zero; + } + + // Widened, because an exponent near int.MinValue or int.MaxValue would otherwise wrap. + long scientificExponent = (long)Exponent + SignificantDigits - 1; + + // The significand's digits, its sign, the decimal point, the 'E', and an exponent of up to twenty characters. + int length = SignificantDigits + 23; char[]? rented = length > MaxStackAllocChars ? ArrayPool.Shared.Rent(length) : null; Span stackBuffer = stackalloc char[MaxStackAllocChars]; Span buffer = rented is null ? stackBuffer : rented.AsSpan(); @@ -466,9 +482,19 @@ private TNumber ParseAs() throw new InvalidOperationException("The significand did not fit the buffer sized for it."); } + int leadingDigit = Significand.Sign < 0 ? 1 : 0; + int trailingDigits = written - leadingDigit - 1; + if (trailingDigits > 0) + { + // CopyTo handles the overlap, shifting every digit after the leading one right by one place. + buffer.Slice(leadingDigit + 1, trailingDigits).CopyTo(buffer[(leadingDigit + 2)..]); + buffer[leadingDigit + 1] = '.'; + written++; + } + buffer[written++] = 'E'; - if (!Exponent.TryFormat(buffer[written..], out int exponentWritten, default, InvariantCulture)) + if (!scientificExponent.TryFormat(buffer[written..], out int exponentWritten, default, InvariantCulture)) { throw new InvalidOperationException("The exponent did not fit the buffer sized for it."); } diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index aab5ccb..5a925b8 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -229,7 +229,7 @@ internal PreciseNumber(int exponent, BigInteger significand, bool sanitize) if (trailingZeros > 0) { significand /= Pow10(trailingZeros); - exponent += trailingZeros; + exponent = checked(exponent + trailingZeros); significantDigits -= trailingZeros; } } @@ -370,14 +370,14 @@ public static string ToString(PreciseNumber number, string? format, IFormatProvi /// /// The number of decimal digits to round to. /// A new instance of rounded to the specified number of decimal digits. + /// Rounds half away from zero, so 1.235 becomes 1.24 and 1.2349 becomes 1.23. public PreciseNumber Round(int decimalDigits) { int currentDecimalDigits = CountDecimalDigits(); int decimalDifference = int.Abs(decimalDigits - currentDecimalDigits); if (currentDecimalDigits > decimalDigits && decimalDifference > 0) { - BigInteger roundingFactor = BigInteger.CopySign(CreateRepeatingDigits(5, decimalDifference), Significand); - BigInteger newSignificand = (Significand + roundingFactor) / Pow10(decimalDifference); + BigInteger newSignificand = DropDigitsRoundingHalfAwayFromZero(Significand, decimalDifference); int newExponent = Exponent - int.CopySign(decimalDifference, Exponent); return new PreciseNumber(newExponent, newSignificand); } @@ -510,16 +510,16 @@ private static PreciseNumber ParseRenderedFloat(ReadOnlySpan text) } } + /// + /// Gets the format that renders a floating point value as the shortest text that round-trips. + /// + /// + /// A fixed precision such as E15 rounds values that need 17 digits, which turns + /// into a number that converts back to infinity. + /// internal static string GetStringFormatForFloatType() where TFloat : INumber - { - return typeof(TFloat) switch - { - _ when typeof(TFloat) == typeof(float) => "E7", - _ when typeof(TFloat) == typeof(double) => "E15", - _ => "R", - }; - } + => "R"; /// /// Creates a from an integer value. @@ -572,6 +572,31 @@ internal static BigInteger CreateRepeatingDigits(int digit, int numberOfRepeats) return digit * (Pow10(numberOfRepeats) - BigInteger.One) / 9; } + /// + /// Removes the lowest digits of a significand, rounding half away from zero on the exact value of + /// the digits removed. + /// + /// The significand to shorten. + /// How many of the lowest digits to remove. Must be positive. + /// The rounded significand, with fewer digits. + /// + /// Adding a run of fives before truncating is only exact when one digit is dropped. With more, the + /// fives past the first carry a remainder such as 45 over the halfway mark. + /// + private static BigInteger DropDigitsRoundingHalfAwayFromZero(BigInteger significand, int droppedDigits) + { + BigInteger divisor = Pow10(droppedDigits); + + // DivRem truncates toward zero and gives the remainder the sign of the significand. + BigInteger kept = BigInteger.DivRem(significand, divisor, out BigInteger dropped); + if (BigInteger.Abs(dropped) * 2 >= divisor) + { + kept += significand.Sign; + } + + return kept; + } + /// /// Gets a value indicating whether the current instance is exactly one in canonical form. /// @@ -636,6 +661,7 @@ internal int CountDecimalDigits() => /// /// The number of significant digits to reduce to. /// A new instance of reduced to the specified number of significant digits. + /// Rounds half away from zero, so 123.5 becomes 124 and 123.456 becomes 123 at three digits. public PreciseNumber ReduceSignificance(int significantDigits) { int significantDifference = significantDigits < SignificantDigits @@ -650,8 +676,7 @@ public PreciseNumber ReduceSignificance(int significantDigits) int newExponent = Exponent == 0 ? significantDifference : Exponent + significantDifference; - BigInteger roundingFactor = BigInteger.CopySign(CreateRepeatingDigits(5, significantDifference), Significand); - BigInteger newSignificand = (Significand + roundingFactor) / Pow10(significantDifference); + BigInteger newSignificand = DropDigitsRoundingHalfAwayFromZero(Significand, significantDifference); return new(newExponent, newSignificand); } @@ -946,6 +971,7 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor if (c is 'e' or 'E') { + // An exponent outside the range of int throws OverflowException, which TryParse reports as failure. exponent = int.Parse(s[(i + 1)..], InvariantCulture); break; } @@ -970,7 +996,7 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor BigInteger significand = BigInteger.Parse(digits[..digitCount], NumberStyles.None, InvariantCulture); - exponent -= decimalDigits; + exponent = checked(exponent - decimalDigits); if (isNegative) { @@ -1008,7 +1034,7 @@ public static bool TryParse(ReadOnlySpan s, NumberStyles style, IFormatPro result = Parse(s, style, provider); return true; } - catch (FormatException) + catch (Exception ex) when (ex is FormatException or OverflowException) { result = default; return false; diff --git a/README.md b/README.md index feabb22..7ba9185 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ A high-precision numeric type for .NET that provides arbitrary precision arithme - **Full .NET Integration**: Implements `INumber`, including `CreateChecked`, `CreateSaturating`, and `CreateTruncating` in both directions, so generic math code can create and convert values. -- **Value Type**: A `readonly record struct` whose `default` value is zero. Adding, subtracting, multiplying, and comparing values whose significands fit in an `int` allocates nothing. +- **Value Type**: A `readonly record struct` whose `default` value is zero. Adding, subtracting, multiplying, and comparing allocate nothing when the operands and every intermediate and final significand fit in an `int`. Exponent alignment counts, so `1 + 0.0000000001` allocates because it scales 1 by 10^10, and `99999 * 99999` allocates because its product is 9,999,800,001. - **Comprehensive Mathematical Support**: Includes advanced mathematical functions like exponential operations (Pow, Exp, Squared, Cubed), constant values (Pi, E, Tau) with high precision, absolute value operations, and specialized numerical checks (isOdd, isEven, etc.)—all with arbitrary precision. @@ -374,9 +374,9 @@ This representation allows for: You can control precision using: -- **Round()**: Rounds to a specific number of decimal places +- **Round()**: Rounds to a specific number of decimal places, half away from zero -- **ReduceSignificance()**: Reduces to a specific number of significant digits +- **ReduceSignificance()**: Reduces to a specific number of significant digits, half away from zero - **Divide(left, right, significantDigits)**: Chooses the precision of a quotient @@ -392,7 +392,7 @@ three-argument overload when you want something other than that. - A checked conversion to an integer type or `decimal` throws `OverflowException` when the value is out of range. Conversion to `double`, `float`, or `Half` overflows to infinity instead, as it does for every built-in type -- Converting from `double` keeps 16 significant digits, and from `float` 8. A binary value that needs all 17 digits to round-trip, such as the result of `0.1 + 0.2` in `double`, arrives rounded +- Converting from `double`, `float`, or `Half` keeps the shortest digits that round-trip, so converting back gives the original value, and `0.3048` stays exactly 0.3048. The result of `0.1 + 0.2` in `double` arrives as 0.30000000000000004, because that's the value the `double` holds ## Performance diff --git a/docs/migration-guide-2.0.md b/docs/migration-guide-2.0.md index a8b2545..093df31 100644 --- a/docs/migration-guide-2.0.md +++ b/docs/migration-guide-2.0.md @@ -7,12 +7,13 @@ PreciseNumber 2.0 makes `PreciseNumber` a value type and makes generic math conv 1. Remove `null` checks and `null` assignments for `PreciseNumber` values. A `PreciseNumber?` now means `Nullable`. 2. Replace any type that derives from `PreciseNumber` with one that holds a `PreciseNumber`. 3. Replace calls to `As()` and the copy constructor. -4. Check the `TryParse` failure path, which now yields zero instead of `null`. +4. Check the `TryParse` failure path, which now yields zero instead of `null`. It also returns `false` instead of throwing `OverflowException` when the exponent is outside the range of `int`. 5. Check calls to `To()` that convert a fractional value to an integer type, which now truncate instead of returning zero. +6. Check expectations for `ToPreciseNumber()` on a `double` or `float`, which keeps the shortest digits that round-trip instead of 16 or 8 significant digits. ## Why -A `record` class allocated an object for every result, and every operator produced one. As a `readonly record struct`, the number lives inline in its variable, field, or array element, and the only heap allocation left is the `BigInteger` digit array. A significand that fits in an `int` doesn't need one, so adding, subtracting, multiplying, and comparing small values allocates nothing. Division still allocates when the quotient repeats, because it computes at least 50 digits. +A `record` class allocated an object for every result, and every operator produced one. As a `readonly record struct`, the number lives inline in its variable, field, or array element, and the only heap allocation left is the `BigInteger` digit array. A significand that fits in an `int` doesn't need one, so adding, subtracting, multiplying, and comparing allocate nothing when the operands and every intermediate and final significand fit in an `int`. Exponent alignment counts, so `1 + 0.0000000001` allocates because it scales 1 by 10^10, and `99999 * 99999` allocates because its product is 9,999,800,001. Division still allocates when the quotient repeats, because it computes at least 50 digits. It also lets `PreciseNumber` satisfy `where T : struct, INumber`, which is the constraint generic numeric libraries such as `ktsu.Semantics.Quantities` put on their storage type. Before 2.0, `CreateChecked`, `CreateSaturating`, and `CreateTruncating` threw `NotSupportedException` in both directions, so that code couldn't convert a unit factor or take a square root through `double`. @@ -57,7 +58,7 @@ These members were `protected internal` for derived types and are now `internal` | `Equals` | `Equals(PreciseNumber? other)` returned `false` for `null` | `Equals(PreciseNumber other)` | | `CompareTo` | `CompareTo(PreciseNumber? other)` returned `1` for `null` | `CompareTo(PreciseNumber other)` | | `CompareTo(object?)` | Threw `NotSupportedException` for `null` | Returns `1` for `null`, and still throws for a non-`PreciseNumber` object | -| `TryParse` (three overloads) | `out PreciseNumber? result`, `null` on failure | `out PreciseNumber result`, zero on failure | +| `TryParse` (three overloads) | `out PreciseNumber? result`, `null` on failure, and threw `OverflowException` for an exponent outside the range of `int` | `out PreciseNumber result`, zero on failure, and returns `false` for an exponent outside the range of `int` | ```csharp // Was: @@ -87,7 +88,7 @@ Converting to `PreciseNumber`: | NaN | Throws `OverflowException` | Zero | Zero | | Infinity | Throws `OverflowException` | Throws `OverflowException` | Throws `OverflowException` | -NaN and infinity follow `BigInteger`, the other built-in numeric type with neither NaN nor a largest value. A `double` keeps 16 significant digits and a `float` 8, which is the same rounding `ToPreciseNumber()` has always applied. +NaN and infinity follow `BigInteger`, the other built-in numeric type with neither NaN nor a largest value. A `double`, `float`, or `Half` converts to the shortest decimal that round-trips, so converting back gives the original value. `ToPreciseNumber()` uses the same text. Through 2.0.1 both kept 16 significant digits for a `double` and 8 for a `float`, so `double.MaxValue` converted back to infinity and `(0.1 + 0.2).ToPreciseNumber()` was 0.3, where it's now 0.30000000000000004. Converting from `PreciseNumber`: