From d060de52fdf6781d5623f1328b3c35668e1ed675 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Sun, 13 Sep 2026 18:26:57 +1000 Subject: [PATCH] [major] Make PreciseNumber a value type PreciseNumber is now a readonly record struct. Every arithmetic result used to allocate a 40-byte object on top of its BigInteger digits, and that object is gone. A value whose significand fits in an int now allocates nothing for addition, subtraction, multiplication, or comparison, and default(PreciseNumber) equals Zero with no special case. Generic math conversion now works. TryConvertFrom and TryConvertTo in all three modes (checked, saturating, and truncating) cover every BCL numeric type and BigInteger, where they used to throw NotSupportedException. That is what generic code such as T.CreateChecked(0.3048) calls, so PreciseNumber can now be the storage type of a Semantics quantity. Conversion from double keeps the shortest round-trip text, and conversion to double is correctly rounded. Breaking changes, all described in docs/migration-guide-2.0.md: - The type can no longer be derived from, and the copy constructor and As() are removed. - Members that were protected internal are now internal. - Equals, CompareTo, and the TryParse out parameters take PreciseNumber rather than PreciseNumber?, and CompareTo(object) returns 1 for null. - To() truncates toward zero, so 12.9 yields 12 where it used to yield 0. Claude-Session: https://claude.ai/code/session_01K5Bk9UjGdGUtC5C6qK5ZxD --- CLAUDE.md | 15 +- .../PreciseNumberConversionTests.cs | 298 +++++++++++ PreciseNumber.Test/PreciseNumberTests.cs | 88 +--- .../PreciseNumberValueTypeTests.cs | 143 ++++++ PreciseNumber/PreciseNumber.Conversions.cs | 486 ++++++++++++++++++ PreciseNumber/PreciseNumber.cs | 267 +++------- PreciseNumber/PreciseNumberExtensions.cs | 10 +- README.md | 45 +- docs/migration-guide-2.0.md | 105 ++++ 9 files changed, 1181 insertions(+), 276 deletions(-) create mode 100644 PreciseNumber.Test/PreciseNumberConversionTests.cs create mode 100644 PreciseNumber.Test/PreciseNumberValueTypeTests.cs create mode 100644 PreciseNumber/PreciseNumber.Conversions.cs create mode 100644 docs/migration-guide-2.0.md diff --git a/CLAUDE.md b/CLAUDE.md index 141b003..68cdb2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,11 +23,13 @@ dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' --job s ### Core Types -- **PreciseNumber** (`PreciseNumber/PreciseNumber.cs`): The main numeric type implementing `INumber`. Stores values using: +- **PreciseNumber** (`PreciseNumber/PreciseNumber.cs`): The main numeric type, a `readonly partial record struct` implementing `INumber`. Its `default` is `Zero`, which `PreciseNumberValueTypeTests` pins. Stores values using: - `Significand`: A `BigInteger` containing all significant digits - `Exponent`: An `int` determining the decimal place - `SignificantDigits`: Count of significant digits +- **Generic conversions** (`PreciseNumber/PreciseNumber.Conversions.cs`): The `TryConvertFrom*` and `TryConvertTo*` members behind `CreateChecked`, `CreateSaturating`, and `CreateTruncating`, for every built-in numeric type and `BigInteger`. `To()` uses them too + - **PreciseNumberExtensions** (`PreciseNumber/PreciseNumberExtensions.cs`): Extension methods providing `ToPreciseNumber()` for converting any `INumber` to PreciseNumber ### Key Design Patterns @@ -37,10 +39,12 @@ dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' --job s - `Divide` is exact when the quotient terminates, and otherwise rounds to a precision that never falls below the wider operand or `MinimumDivisionPrecision`. `Exp` and non-integer `Pow` still route through `double` - 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 ### Test Structure -Tests use MSTest framework in `PreciseNumber.Test/PreciseNumberTests.cs`. The test project targets only .NET 10.0 while the main library multi-targets net7.0, net8.0, net9.0, and net10.0. +Tests use MSTest. `PreciseNumber.Test/PreciseNumberTests.cs` covers arithmetic, parsing, and formatting, `PreciseNumberConversionTests.cs` covers generic math conversion in every mode, and `PreciseNumberValueTypeTests.cs` pins `default` as zero and asserts that small-value addition, subtraction, multiplication, and comparison allocate nothing. The test project targets only .NET 10.0 while the main library multi-targets net7.0, net8.0, net9.0, and net10.0. ### Benchmarks @@ -52,9 +56,10 @@ Most classes are parameterised by `Digits` (8, 30, 200). That axis is the point: `BigInteger`, so anything that touches them one at a time looks fine at 8 digits and collapses at 200. Read results across the `Digits` column, not down one value of it. -Allocation is reported alongside time and matters just as much — every operation returns a new -instance, so avoiding an intermediate shows up in `Allocated` before it shows up in `Mean`. -Comparisons should allocate nothing at all. +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. 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 new file mode 100644 index 0000000..01871cd --- /dev/null +++ b/PreciseNumber.Test/PreciseNumberConversionTests.cs @@ -0,0 +1,298 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Test; + +using System.Globalization; +using System.Numerics; + +/// +/// Covers conversion through generic math, which is how code written against +/// reaches . +/// +[TestClass] +public class PreciseNumberConversionTests +{ + private static PreciseNumber P(string text) => PreciseNumber.Parse(text, CultureInfo.InvariantCulture); + + private static TTo Checked(TFrom value) + where TTo : INumberBase + where TFrom : INumberBase + => TTo.CreateChecked(value); + + private static TTo Saturating(TFrom value) + where TTo : INumberBase + where TFrom : INumberBase + => TTo.CreateSaturating(value); + + private static TTo Truncating(TFrom value) + where TTo : INumberBase + where TFrom : INumberBase + => TTo.CreateTruncating(value); + + /// The shape of a unit conversion in generic quantity code. + private static T ToBase(T value, double factor) + where T : INumber + => value * T.CreateChecked(factor); + + private static void AssertFromInAllModes(TFrom value, string expected) + where TFrom : INumberBase + { + PreciseNumber expectedNumber = P(expected); + Assert.AreEqual(expectedNumber, Checked(value), $"Checked from {typeof(TFrom).Name}"); + Assert.AreEqual(expectedNumber, Saturating(value), $"Saturating from {typeof(TFrom).Name}"); + Assert.AreEqual(expectedNumber, Truncating(value), $"Truncating from {typeof(TFrom).Name}"); + } + + private static void AssertToInAllModes(string value, 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}"); + } + + [TestMethod] + public void FromEveryIntegerTypeIsExact() + { + AssertFromInAllModes(sbyte.MinValue, "-128"); + AssertFromInAllModes(byte.MaxValue, "255"); + AssertFromInAllModes(short.MinValue, "-32768"); + AssertFromInAllModes(ushort.MaxValue, "65535"); + AssertFromInAllModes(int.MinValue, "-2147483648"); + AssertFromInAllModes(uint.MaxValue, "4294967295"); + AssertFromInAllModes(long.MinValue, "-9223372036854775808"); + AssertFromInAllModes(ulong.MaxValue, "18446744073709551615"); + AssertFromInAllModes(Int128.MinValue, "-170141183460469231731687303715884105728"); + AssertFromInAllModes(UInt128.MaxValue, "340282366920938463463374607431768211455"); + AssertFromInAllModes((nint)(-42), "-42"); + AssertFromInAllModes((nuint)42, "42"); + AssertFromInAllModes('A', "65"); + AssertFromInAllModes(BigInteger.Pow(10, 60) + 1, "1" + new string('0', 59) + "1"); + AssertFromInAllModes(1000, "1000"); + } + + [TestMethod] + public void FromDecimalIsExact() + { + AssertFromInAllModes(1234.5678m, "1234.5678"); + AssertFromInAllModes(decimal.MaxValue, "79228162514264337593543950335"); + AssertFromInAllModes(decimal.MinValue, "-79228162514264337593543950335"); + AssertFromInAllModes(0.0000000000000000000000000001m, "0.0000000000000000000000000001"); + } + + [TestMethod] + public void FromBinaryFloatingPointUsesDecimalText() + { + AssertFromInAllModes(0.3048, "0.3048"); + AssertFromInAllModes(-1.5e-10, "-0.00000000015"); + AssertFromInAllModes(1.25f, "1.25"); + AssertFromInAllModes((Half)1.5, "1.5"); + AssertFromInAllModes(0.0, "0"); + } + + [TestMethod] + public void FromPreciseNumberIsIdentity() + { + AssertFromInAllModes(P("-12.5"), "-12.5"); + } + + [TestMethod] + public void GenericUnitConversionIsExact() + { + Assert.AreEqual(P("0.3048"), ToBase(PreciseNumber.One, 0.3048)); + Assert.AreEqual(P("3.048"), ToBase(10.ToPreciseNumber(), 0.3048)); + } + + [TestMethod] + public void FromNaNMatchesBigInteger() + { + // BigInteger is the other built-in numeric type with no NaN and no bound, so it sets the convention. + Assert.ThrowsExactly(() => Checked(double.NaN)); + Assert.AreEqual(BigInteger.Zero, Saturating(double.NaN)); + Assert.AreEqual(BigInteger.Zero, Truncating(double.NaN)); + + Assert.ThrowsExactly(() => Checked(double.NaN)); + Assert.AreEqual(PreciseNumber.Zero, Saturating(double.NaN)); + Assert.AreEqual(PreciseNumber.Zero, Truncating(float.NaN)); + Assert.AreEqual(PreciseNumber.Zero, Saturating(Half.NaN)); + } + + [TestMethod] + public void FromInfinityMatchesBigInteger() + { + Assert.ThrowsExactly(() => Checked(double.PositiveInfinity)); + Assert.ThrowsExactly(() => Saturating(double.PositiveInfinity)); + Assert.ThrowsExactly(() => Truncating(double.NegativeInfinity)); + + Assert.ThrowsExactly(() => Checked(double.PositiveInfinity)); + Assert.ThrowsExactly(() => Saturating(double.PositiveInfinity)); + Assert.ThrowsExactly(() => Truncating(float.NegativeInfinity)); + } + + [TestMethod] + public void TryConvertReportsUnsupportedTypes() + { + Assert.IsFalse(PreciseNumber.TryConvertFromChecked(new Complex(1, 0), out PreciseNumber _), "Complex is not a supported source"); + Assert.IsFalse(PreciseNumber.TryConvertToChecked(PreciseNumber.One, out Complex _), "Complex is not a supported destination"); + } + + [TestMethod] + public void ToIntegerTruncatesTowardZero() + { + AssertToInAllModes("12.9", 12); + AssertToInAllModes("-12.9", -12); + AssertToInAllModes("0.999", 0L); + AssertToInAllModes("-0.5", (short)0); + AssertToInAllModes("12345e3", 12345000); + AssertToInAllModes("65.7", 'A'); + AssertToInAllModes("-170141183460469231731687303715884105728", Int128.MinValue); + AssertToInAllModes("340282366920938463463374607431768211455", UInt128.MaxValue); + AssertToInAllModes("-42.1", (nint)(-42)); + AssertToInAllModes("42.1", (nuint)42); + AssertToInAllModes("255", byte.MaxValue); + AssertToInAllModes("-128", sbyte.MinValue); + AssertToInAllModes("65535", ushort.MaxValue); + AssertToInAllModes("4294967295", uint.MaxValue); + AssertToInAllModes("18446744073709551615.5", ulong.MaxValue); + } + + [TestMethod] + public void ToIntegerOutOfRange() + { + PreciseNumber tooLargeForByte = P("300"); + Assert.ThrowsExactly(() => Checked(tooLargeForByte)); + Assert.AreEqual(byte.MaxValue, Saturating(tooLargeForByte)); + Assert.AreEqual((byte)44, Truncating(tooLargeForByte)); + + PreciseNumber negative = P("-1"); + Assert.ThrowsExactly(() => Checked(negative)); + Assert.AreEqual(uint.MinValue, Saturating(negative)); + Assert.AreEqual(uint.MaxValue, Truncating(negative)); + + PreciseNumber pastInt = P("2147483648"); + Assert.ThrowsExactly(() => Checked(pastInt)); + Assert.AreEqual(int.MaxValue, Saturating(pastInt)); + Assert.AreEqual(int.MinValue, Truncating(pastInt)); + + Assert.ThrowsExactly(() => Checked(P("70000"))); + } + + [TestMethod] + public void ToIntegerFromHugeValuesMatchesBigInteger() + { + foreach (string text in new[] { "7e5000", "-7e5000", "123456789e50", "-98765e45", "3e39", "1e38" }) + { + PreciseNumber number = P(text); + BigInteger integer = number.To(); + + Assert.AreEqual(Truncating(integer), Truncating(number), $"int truncating {text}"); + Assert.AreEqual(Truncating(integer), Truncating(number), $"ulong truncating {text}"); + Assert.AreEqual(Truncating(integer), Truncating(number), $"Int128 truncating {text}"); + Assert.AreEqual(Truncating(integer), Truncating(number), $"UInt128 truncating {text}"); + Assert.AreEqual(Saturating(integer), Saturating(number), $"long saturating {text}"); + Assert.AreEqual(Saturating(integer), Saturating(number), $"UInt128 saturating {text}"); + } + + Assert.ThrowsExactly(() => Checked(P("7e5000"))); + } + + [TestMethod] + public void ToBigIntegerTruncatesTowardZero() + { + AssertToInAllModes("12345e5", BigInteger.Parse("1234500000", CultureInfo.InvariantCulture)); + AssertToInAllModes("-12.9", new BigInteger(-12)); + AssertToInAllModes("0.0001", BigInteger.Zero); + } + + [TestMethod] + public void ToDoubleIsCorrectlyRounded() + { + string[] texts = + [ + "0.1", + "0.3048", + "-123.456", + "3.14159265358979323846264338327950288419716939937510582097494", + "9007199254740993", + "9007199254740995", + "2.2250738585072011e-308", + "4.9406564584124654e-324", + "1.7976931348623157e308", + "123456789012345678901234567890e-45", + "0.000000000000000000000000000000000000000001", + ]; + + foreach (string text in texts) + { + double expected = double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture); + AssertToInAllModes(text, expected); + } + } + + [TestMethod] + public void ToDoubleOverflowsToInfinity() + { + AssertToInAllModes("1e400", double.PositiveInfinity); + AssertToInAllModes("-1e400", double.NegativeInfinity); + AssertToInAllModes("1e-400", 0.0); + } + + [TestMethod] + public void ToSingleAndHalfAreCorrectlyRounded() + { + foreach (string text in new[] { "0.1", "16777217", "3.4028235e38", "1.401298464324817e-45", "2.718281828459045235360287471352662497757" }) + { + AssertToInAllModes(text, float.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture)); + } + + foreach (string text in new[] { "0.1", "65504", "65520", "1.5", "-3.14159" }) + { + AssertToInAllModes(text, Half.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture)); + } + } + + [TestMethod] + public void ToDecimalRoundsExcessDigits() + { + AssertToInAllModes("1234.5678", 1234.5678m); + AssertToInAllModes("79228162514264337593543950335", decimal.MaxValue); + + const string longFraction = "0.12345678901234567890123456789012345678901234"; + AssertToInAllModes(longFraction, decimal.Parse(longFraction, NumberStyles.Float, CultureInfo.InvariantCulture)); + } + + [TestMethod] + public void ToDecimalOutOfRange() + { + foreach (string text in new[] { "1e30", "79228162514264337593543950336" }) + { + PreciseNumber number = P(text); + Assert.ThrowsExactly(() => Checked(number), text); + Assert.AreEqual(decimal.MaxValue, Saturating(number), text); + Assert.AreEqual(decimal.MaxValue, Truncating(number), text); + Assert.AreEqual(decimal.MinValue, Saturating(-number), text); + } + + // BigInteger clamps rather than wraps when truncating to decimal, and so does PreciseNumber. + Assert.AreEqual(decimal.MaxValue, Truncating(BigInteger.Pow(10, 30))); + } + + [TestMethod] + public void BuiltInCreateCheckedReachesPreciseNumber() + { + Assert.AreEqual(1.5, double.CreateChecked(P("1.5"))); + Assert.AreEqual(12, int.CreateChecked(P("12.75"))); + Assert.AreEqual(1234.5678m, decimal.CreateSaturating(P("1234.5678"))); + Assert.AreEqual(BigInteger.One, BigInteger.CreateTruncating(P("1.9"))); + } + + [TestMethod] + public void ToUsesTheSameConversions() + { + Assert.AreEqual(12, P("12.9").To()); + Assert.AreEqual( + double.Parse("3.14159265358979323846264338327950288419716939937510582097494", NumberStyles.Float, CultureInfo.InvariantCulture), + P("3.14159265358979323846264338327950288419716939937510582097494").To()); + } +} diff --git a/PreciseNumber.Test/PreciseNumberTests.cs b/PreciseNumber.Test/PreciseNumberTests.cs index 4ef448c..a5fc5e9 100644 --- a/PreciseNumber.Test/PreciseNumberTests.cs +++ b/PreciseNumber.Test/PreciseNumberTests.cs @@ -409,43 +409,43 @@ public void TestStaticRound() [TestMethod] public void TestTryConvertFromChecked() { - PreciseNumber one = PreciseNumber.One; - Assert.ThrowsExactly(() => PreciseNumber.TryConvertFromChecked(one, out PreciseNumber? result)); + Assert.IsTrue(PreciseNumber.TryConvertFromChecked(42, out PreciseNumber result), "int should be a supported source"); + Assert.AreEqual(42.ToPreciseNumber(), result); } [TestMethod] public void TestTryConvertFromSaturating() { - PreciseNumber one = PreciseNumber.One; - Assert.ThrowsExactly(() => PreciseNumber.TryConvertFromSaturating(one, out PreciseNumber? result)); + Assert.IsTrue(PreciseNumber.TryConvertFromSaturating(2.5, out PreciseNumber result), "double should be a supported source"); + Assert.AreEqual(2.5.ToPreciseNumber(), result); } [TestMethod] public void TestTryConvertFromTruncating() { - PreciseNumber one = PreciseNumber.One; - Assert.ThrowsExactly(() => PreciseNumber.TryConvertFromTruncating(one, out PreciseNumber? result)); + Assert.IsTrue(PreciseNumber.TryConvertFromTruncating(2.5m, out PreciseNumber result), "decimal should be a supported source"); + Assert.AreEqual(2.5m.ToPreciseNumber(), result); } [TestMethod] public void TestTryConvertToChecked() { - PreciseNumber one = PreciseNumber.One; - Assert.ThrowsExactly(() => PreciseNumber.TryConvertToChecked(one, out PreciseNumber result)); + Assert.IsTrue(PreciseNumber.TryConvertToChecked(PreciseNumber.One, out int result), "int should be a supported destination"); + Assert.AreEqual(1, result); } [TestMethod] public void TestTryConvertToSaturating() { - PreciseNumber one = PreciseNumber.One; - Assert.ThrowsExactly(() => PreciseNumber.TryConvertToSaturating(one, out PreciseNumber result)); + Assert.IsTrue(PreciseNumber.TryConvertToSaturating(PreciseNumber.One, out double result), "double should be a supported destination"); + Assert.AreEqual(1.0, result); } [TestMethod] public void TestTryConvertToTruncating() { - PreciseNumber one = PreciseNumber.One; - Assert.ThrowsExactly(() => PreciseNumber.TryConvertToTruncating(one, out PreciseNumber result)); + Assert.IsTrue(PreciseNumber.TryConvertToTruncating(PreciseNumber.One, out PreciseNumber result), "PreciseNumber should convert to itself"); + Assert.AreEqual(PreciseNumber.One, result); } [TestMethod] @@ -1739,7 +1739,7 @@ public void TestTryParseWithValidInput() { ReadOnlySpan input = "1.23E4".AsSpan(); PreciseNumber expected = 1.23e4.ToPreciseNumber(); - bool success = PreciseNumber.TryParse(input, NumberStyles.Any, null, out PreciseNumber? result); + bool success = PreciseNumber.TryParse(input, NumberStyles.Any, null, out PreciseNumber result); Assert.IsTrue(success, "TryParse should succeed with valid input"); Assert.AreEqual(expected, result); @@ -1753,7 +1753,7 @@ public void TestTryParseWithNegativeInput() { ReadOnlySpan input = "-5.67E-2".AsSpan(); PreciseNumber expected = -5.67e-2.ToPreciseNumber(); - bool success = PreciseNumber.TryParse(input, NumberStyles.Any, null, out PreciseNumber? result); + bool success = PreciseNumber.TryParse(input, NumberStyles.Any, null, out PreciseNumber result); Assert.IsTrue(success, "TryParse should succeed with negative input"); Assert.AreEqual(expected, result); } @@ -1762,7 +1762,7 @@ public void TestTryParseWithNegativeInput() public void TestTryParseWithInvalidInput() { ReadOnlySpan input = "invalid".AsSpan(); - bool success = PreciseNumber.TryParse(input, NumberStyles.Any, null, out PreciseNumber? result); + bool success = PreciseNumber.TryParse(input, NumberStyles.Any, null, out PreciseNumber result); Assert.IsFalse(success, "TryParse should fail with invalid input"); Assert.AreEqual(default, result); } @@ -1772,7 +1772,7 @@ public void TestTryParseStringWithValidInput() { string input = "1.23E4"; PreciseNumber expected = 1.23e4.ToPreciseNumber(); - bool success = PreciseNumber.TryParse(input, NumberStyles.Any, null, out PreciseNumber? result); + bool success = PreciseNumber.TryParse(input, NumberStyles.Any, null, out PreciseNumber result); Assert.IsTrue(success, "TryParse should succeed with valid string input"); Assert.AreEqual(expected, result); @@ -1786,7 +1786,7 @@ public void TestTryParseStringWithNegativeInput() { string input = "-5.67E-2"; PreciseNumber expected = -5.67e-2.ToPreciseNumber(); - bool success = PreciseNumber.TryParse(input, NumberStyles.Any, null, out PreciseNumber? result); + bool success = PreciseNumber.TryParse(input, NumberStyles.Any, null, out PreciseNumber result); Assert.IsTrue(success, "TryParse should succeed with negative string input"); Assert.AreEqual(expected, result); } @@ -1795,7 +1795,7 @@ public void TestTryParseStringWithNegativeInput() public void TestTryParseStringWithInvalidInput() { string input = "invalid"; - bool success = PreciseNumber.TryParse(input, NumberStyles.Any, null, out PreciseNumber? result); + bool success = PreciseNumber.TryParse(input, NumberStyles.Any, null, out PreciseNumber result); Assert.IsFalse(success, "TryParse should fail with invalid string input"); Assert.AreEqual(default, result); } @@ -1880,11 +1880,10 @@ public void TryCreate_WithIntegerInput_ReturnsTrueAndCreatesPreciseNumber() int input = 42; // Act - bool result = PreciseNumberExtensions.TryCreate(input, out PreciseNumber? preciseNumber); + bool result = PreciseNumberExtensions.TryCreate(input, out PreciseNumber preciseNumber); // Assert Assert.IsTrue(result, "TryCreate should succeed with integer input"); - Assert.IsNotNull(preciseNumber); Assert.AreEqual(input, preciseNumber.To()); } @@ -1895,11 +1894,10 @@ public void TryCreate_WithFloatingPointInput_ReturnsTrueAndCreatesPreciseNumber( double input = 42.42; // Act - bool result = PreciseNumberExtensions.TryCreate(input, out PreciseNumber? preciseNumber); + bool result = PreciseNumberExtensions.TryCreate(input, out PreciseNumber preciseNumber); // Assert Assert.IsTrue(result, "TryCreate should succeed with floating point input"); - Assert.IsNotNull(preciseNumber); Assert.AreEqual(input, preciseNumber.To()); } @@ -1910,11 +1908,10 @@ public void TryCreate_InputIsPreciseNumber_ReturnsTrue() PreciseNumber input = new(2, new BigInteger(12345)); // Act - bool result = PreciseNumberExtensions.TryCreate(input, out PreciseNumber? preciseNumber); + bool result = PreciseNumberExtensions.TryCreate(input, out PreciseNumber preciseNumber); // Assert Assert.IsTrue(result, "TryCreate should succeed when input is PreciseNumber"); - Assert.IsNotNull(preciseNumber); Assert.AreEqual(input, preciseNumber); } @@ -1925,7 +1922,7 @@ public void TryCreate_WithPreciseNumber_ReturnsTrue() PreciseNumber input = PreciseNumber.One; // Act - bool result = PreciseNumberExtensions.TryCreate(input, out PreciseNumber? preciseNumber); + bool result = PreciseNumberExtensions.TryCreate(input, out PreciseNumber preciseNumber); // Assert Assert.IsTrue(result, "TryCreate should succeed with PreciseNumber input"); @@ -1939,11 +1936,10 @@ public void TryCreate_WithBinaryInteger_ReturnsTrue() int input = 42; // Act - bool result = PreciseNumberExtensions.TryCreate(input, out PreciseNumber? preciseNumber); + bool result = PreciseNumberExtensions.TryCreate(input, out PreciseNumber preciseNumber); // Assert Assert.IsTrue(result, "TryCreate should succeed with binary integer input"); - Assert.IsNotNull(preciseNumber); Assert.AreEqual(PreciseNumber.CreateFromInteger(input), preciseNumber); } @@ -1954,42 +1950,13 @@ public void TryCreate_WithFloatingPoint_ReturnsTrue() double input = 3.14; // Act - bool result = PreciseNumberExtensions.TryCreate(input, out PreciseNumber? preciseNumber); + bool result = PreciseNumberExtensions.TryCreate(input, out PreciseNumber preciseNumber); // Assert Assert.IsTrue(result, "TryCreate should succeed with floating point input"); - Assert.IsNotNull(preciseNumber); Assert.AreEqual(PreciseNumber.CreateFromFloatingPoint(input), preciseNumber); } - [TestMethod] - public void As_WithSameInputAndOutputType_ReturnsInput() - { - // Arrange - PreciseNumber input = new(2, new BigInteger(123)); - - // Act - PreciseNumber result = input.As(); - - // Assert - Assert.AreSame(input, result); - } - - [TestMethod] - public void As_WithConvertibleInputAndOutputType_ReturnsConvertedInstance() - { - // Arrange - PreciseNumber input = new(2, new BigInteger(123)); - - // Act - DerivedPreciseNumber result = input.As(); - - // Assert - Assert.IsNotNull(result); - Assert.AreEqual(input.Exponent, result.Exponent); - Assert.AreEqual(input.Significand, result.Significand); - } - [TestMethod] public void TestCountDigitsMatchesDecimalText() { @@ -2252,11 +2219,4 @@ public void TestParseRoundTripsLongDecimals() Assert.AreEqual(text, parsed.ToString(CultureInfo.InvariantCulture)); } - - public record DerivedPreciseNumber : PreciseNumber - { - public DerivedPreciseNumber(PreciseNumber original) : base(original) - { - } - } } diff --git a/PreciseNumber.Test/PreciseNumberValueTypeTests.cs b/PreciseNumber.Test/PreciseNumberValueTypeTests.cs new file mode 100644 index 0000000..baade66 --- /dev/null +++ b/PreciseNumber.Test/PreciseNumberValueTypeTests.cs @@ -0,0 +1,143 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Test; + +using System.Globalization; + +[TestClass] +public class PreciseNumberValueTypeTests +{ + private const int Iterations = 1000; + + [TestMethod] + public void PreciseNumberIsAValueType() + { + Assert.IsTrue(typeof(PreciseNumber).IsValueType, "PreciseNumber should be a value type"); + } + + [TestMethod] + public void DefaultEqualsZero() + { + PreciseNumber value = default; + + Assert.AreEqual(PreciseNumber.Zero, value); + Assert.AreEqual(PreciseNumber.Zero.GetHashCode(), value.GetHashCode()); + Assert.AreEqual(PreciseNumber.Zero.Exponent, value.Exponent); + Assert.AreEqual(PreciseNumber.Zero.Significand, value.Significand); + Assert.AreEqual(PreciseNumber.Zero.SignificantDigits, value.SignificantDigits); + } + + [TestMethod] + public void DefaultBehavesAsZero() + { + PreciseNumber value = default; + + Assert.IsTrue(PreciseNumber.IsZero(value), "default should be zero"); + Assert.AreEqual("0", value.ToString()); + Assert.AreEqual(PreciseNumber.One, value + PreciseNumber.One); + Assert.AreEqual(PreciseNumber.Zero, value * PreciseNumber.One); + Assert.AreEqual(0, value.CompareTo(PreciseNumber.Zero)); + } + + [TestMethod] + public void CompareToNullObjectIsGreater() + { + Assert.IsGreaterThan(0, PreciseNumber.One.CompareTo(null), "Any value should sort after null"); + } + + [TestMethod] + public void SmallValueAdditionDoesNotAllocate() + { + PreciseNumber left = PreciseNumber.Parse("12.5", CultureInfo.InvariantCulture); + PreciseNumber right = PreciseNumber.Parse("3.25", CultureInfo.InvariantCulture); + + long allocated = MeasureAllocations(() => + { + PreciseNumber sum = PreciseNumber.Zero; + for (int i = 0; i < Iterations; i++) + { + sum = left + right; + } + + return sum; + }); + + Assert.AreEqual(0L, allocated); + } + + [TestMethod] + public void SmallValueSubtractionDoesNotAllocate() + { + PreciseNumber left = PreciseNumber.Parse("12.5", CultureInfo.InvariantCulture); + PreciseNumber right = PreciseNumber.Parse("3.25", CultureInfo.InvariantCulture); + + long allocated = MeasureAllocations(() => + { + PreciseNumber difference = PreciseNumber.Zero; + for (int i = 0; i < Iterations; i++) + { + difference = left - right; + } + + return difference; + }); + + Assert.AreEqual(0L, allocated); + } + + [TestMethod] + public void SmallValueMultiplicationDoesNotAllocate() + { + PreciseNumber left = PreciseNumber.Parse("12.5", CultureInfo.InvariantCulture); + PreciseNumber right = PreciseNumber.Parse("3.25", CultureInfo.InvariantCulture); + + long allocated = MeasureAllocations(() => + { + PreciseNumber product = PreciseNumber.Zero; + for (int i = 0; i < Iterations; i++) + { + product = left * right; + } + + return product; + }); + + Assert.AreEqual(0L, allocated); + } + + [TestMethod] + public void SmallValueComparisonDoesNotAllocate() + { + PreciseNumber left = PreciseNumber.Parse("12.5", CultureInfo.InvariantCulture); + PreciseNumber right = PreciseNumber.Parse("12.25", CultureInfo.InvariantCulture); + + long allocated = MeasureAllocations(() => + { + int greater = 0; + for (int i = 0; i < Iterations; i++) + { + if (left > right && left != right && left.CompareTo(right) > 0) + { + greater++; + } + } + + return greater; + }); + + Assert.AreEqual(0L, allocated); + } + + /// + /// Runs an operation once to warm it up, so that JIT compilation and one-off static + /// initialization are not counted, then reports what a second run allocates. + /// + private static long MeasureAllocations(Func operation) + { + _ = operation(); + + long before = GC.GetAllocatedBytesForCurrentThread(); + _ = operation(); + return GC.GetAllocatedBytesForCurrentThread() - before; + } +} diff --git a/PreciseNumber/PreciseNumber.Conversions.cs b/PreciseNumber/PreciseNumber.Conversions.cs new file mode 100644 index 0000000..9c26278 --- /dev/null +++ b/PreciseNumber/PreciseNumber.Conversions.cs @@ -0,0 +1,486 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber; + +using System; +using System.Buffers; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Numerics; + +/// +/// Conversions between and the other numeric types, as generic math +/// reaches them through CreateChecked, CreateSaturating and CreateTruncating. +/// +public readonly partial record struct PreciseNumber +{ + /// + /// The widest built-in integer type is 128 bits, and 2^128 has 39 digits, so a value with more + /// integral digits than this is out of range for every one of them. + /// + private const int MaxPrimitiveIntegerDigits = 39; + + /// + /// Integral digits a can hold. Its largest value is about 7.9 × 10^28. + /// + private const int MaxDecimalIntegralDigits = 29; + + /// + /// The largest power of ten a holds exactly. + /// + private const int MaxExactDoublePowerOfTen = 22; + + /// + /// The largest power of ten a holds exactly. + /// + private const int MaxExactSinglePowerOfTen = 10; + + /// + /// 2^128, one past the range of every built-in integer type. + /// + private static readonly BigInteger PrimitiveIntegerModulus = BigInteger.One << 128; + + /// + /// 2^53, the largest integer below which every integer is exactly representable as a . + /// + private static readonly BigInteger MaxExactDoubleSignificand = BigInteger.One << 53; + + /// + /// 2^24, the largest integer below which every integer is exactly representable as a . + /// + private static readonly BigInteger MaxExactSingleSignificand = BigInteger.One << 24; + + private static readonly double[] ExactDoublePowersOfTen = + [ + 1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, + 1e12, 1e13, 1e14, 1e15, 1e16, 1e17, 1e18, 1e19, 1e20, 1e21, 1e22, + ]; + + private static readonly float[] ExactSinglePowersOfTen = + [ + 1e0f, 1e1f, 1e2f, 1e3f, 1e4f, 1e5f, 1e6f, 1e7f, 1e8f, 1e9f, 1e10f, + ]; + + /// + /// How a conversion treats a value the destination type cannot hold. + /// + private enum ConversionMode + { + Checked, + Saturating, + Truncating, + } + + /// + /// Converts a value of another numeric type, throwing when the value has no equivalent. + /// + /// The type to convert from. + /// The value to convert. + /// The converted value, when the conversion is supported. + /// + /// true if is a built-in numeric type or ; + /// false for any other type. + /// + /// is NaN or an infinity. + /// + /// 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. + /// + public static bool TryConvertFromChecked(TOther value, out PreciseNumber result) + where TOther : INumberBase + => TryConvertFrom(value, ConversionMode.Checked, out result); + + /// + /// Converts a value of another numeric type, replacing a value with no equivalent by the nearest one. + /// + /// The type to convert from. + /// The value to convert. + /// The converted value, when the conversion is supported. + /// + /// true if is a built-in numeric type or ; + /// false for any other type. + /// + /// is an infinity. + /// + /// NaN becomes zero. An infinity still throws, because a type with no largest value has nothing to + /// saturate to. Both choices match , the other built-in numeric type with no + /// NaN and no bound. Every finite value converts exactly as it does for + /// . + /// + public static bool TryConvertFromSaturating(TOther value, out PreciseNumber result) + where TOther : INumberBase + => TryConvertFrom(value, ConversionMode.Saturating, out result); + + /// + /// Converts a value of another numeric type, discarding what the destination cannot represent. + /// + /// The type to convert from. + /// The value to convert. + /// The converted value, when the conversion is supported. + /// + /// true if is a built-in numeric type or ; + /// false for any other type. + /// + /// is an infinity. + /// + /// Nothing finite needs truncating, since every finite value has an exact equivalent. NaN becomes zero + /// and an infinity throws, as they do for . + /// + public static bool TryConvertFromTruncating(TOther value, out PreciseNumber result) + where TOther : INumberBase + => TryConvertFrom(value, ConversionMode.Truncating, out result); + + /// + /// Converts a to another numeric type, throwing when the value is out of range. + /// + /// The type to convert to. + /// The value to convert. + /// The converted value, when the conversion is supported. + /// + /// true if is a built-in numeric type or ; + /// false for any other type. + /// + /// + /// is an integer type or , and the value is outside its range. + /// + /// + /// Integer types keep the integral part, truncated toward zero. , + /// and receive the nearest representable value, correctly rounded however many digits + /// the number has, and overflow to an infinity rather than throwing, as every built-in conversion to a + /// binary floating point type does. keeps as many digits as it can hold, rounding + /// the rest the way does. + /// + public static bool TryConvertToChecked(PreciseNumber value, [MaybeNullWhen(false)] out TOther result) + where TOther : INumberBase + => TryConvertTo(value, ConversionMode.Checked, out result); + + /// + /// Converts a to another numeric type, clamping a value that is out of range. + /// + /// The type to convert to. + /// The value to convert. + /// The converted value, when the conversion is supported. + /// + /// true if is a built-in numeric type or ; + /// false for any other type. + /// + /// + /// A value beyond the range of an integer type or becomes its minimum or maximum. + /// Everything else converts as it does for . + /// + public static bool TryConvertToSaturating(PreciseNumber value, [MaybeNullWhen(false)] out TOther result) + where TOther : INumberBase + => TryConvertTo(value, ConversionMode.Saturating, out result); + + /// + /// Converts a to another numeric type, keeping only what the destination can represent. + /// + /// The type to convert to. + /// The value to convert. + /// The converted value, when the conversion is supported. + /// + /// true if is a built-in numeric type or ; + /// false for any other type. + /// + /// + /// An integer type receives the low bits of the integral part, wrapping exactly as a truncating + /// conversion from does. clamps instead, which is also + /// what does for it. Everything else converts as it does for + /// . + /// + public static bool TryConvertToTruncating(PreciseNumber value, [MaybeNullWhen(false)] out TOther result) + where TOther : INumberBase + => TryConvertTo(value, ConversionMode.Truncating, out result); + + private static bool TryConvertFrom(TOther value, ConversionMode mode, out PreciseNumber result) + where TOther : INumberBase + { + if (typeof(TOther) == typeof(PreciseNumber)) + { + result = (PreciseNumber)(object)value; + return true; + } + + if (typeof(TOther) == typeof(double)) + { + return TryConvertFromBinaryFloatingPoint((double)(object)value, mode, out result); + } + + if (typeof(TOther) == typeof(float)) + { + return TryConvertFromBinaryFloatingPoint((float)(object)value, mode, out result); + } + + if (typeof(TOther) == typeof(Half)) + { + return TryConvertFromBinaryFloatingPoint((Half)(object)value, mode, out result); + } + + if (typeof(TOther) == typeof(decimal)) + { + result = CreateFromFloatingPoint((decimal)(object)value); + return true; + } + + if (typeof(TOther) == typeof(BigInteger)) + { + result = new(0, (BigInteger)(object)value); + return true; + } + + if (IsPrimitiveInteger()) + { + // Every built-in integer fits in a BigInteger, so this conversion can never fail. + result = new(0, BigInteger.CreateChecked(value)); + return true; + } + + result = default; + return false; + } + + private static bool TryConvertFromBinaryFloatingPoint(TFloat value, ConversionMode mode, out PreciseNumber result) + where TFloat : IFloatingPointIeee754 + { + if (TFloat.IsNaN(value)) + { + if (mode == ConversionMode.Checked) + { + throw new OverflowException("PreciseNumber cannot represent NaN."); + } + + result = Zero; + return true; + } + + if (TFloat.IsInfinity(value)) + { + throw new OverflowException("PreciseNumber cannot represent infinity."); + } + + result = CreateFromFloatingPoint(value); + return true; + } + + private static bool TryConvertTo(PreciseNumber value, ConversionMode mode, [MaybeNullWhen(false)] out TOther result) + where TOther : INumberBase + { + if (typeof(TOther) == typeof(PreciseNumber)) + { + result = (TOther)(object)value; + return true; + } + + if (typeof(TOther) == typeof(double)) + { + result = (TOther)(object)value.ToDouble(); + return true; + } + + if (typeof(TOther) == typeof(float)) + { + result = (TOther)(object)value.ToSingle(); + return true; + } + + if (typeof(TOther) == typeof(Half)) + { + result = (TOther)(object)value.ParseAs(); + return true; + } + + if (typeof(TOther) == typeof(decimal)) + { + result = (TOther)(object)value.ToDecimal(mode); + return true; + } + + if (typeof(TOther) == typeof(BigInteger)) + { + result = (TOther)(object)value.TruncateToBigInteger(); + return true; + } + + if (IsPrimitiveInteger()) + { + result = ToPrimitiveInteger(value, mode); + return true; + } + + result = default; + return false; + } + + private static bool IsPrimitiveInteger() => + typeof(T) == typeof(int) + || typeof(T) == typeof(long) + || typeof(T) == typeof(short) + || typeof(T) == typeof(sbyte) + || typeof(T) == typeof(uint) + || typeof(T) == typeof(ulong) + || typeof(T) == typeof(ushort) + || typeof(T) == typeof(byte) + || typeof(T) == typeof(Int128) + || typeof(T) == typeof(UInt128) + || typeof(T) == typeof(nint) + || typeof(T) == typeof(nuint) + || typeof(T) == typeof(char); + + /// + /// Converts to a built-in integer type by way of , so that range checks, + /// clamping and wrapping all follow its conventions exactly. + /// + private static TOther ToPrimitiveInteger(PreciseNumber value, ConversionMode mode) + where TOther : INumberBase + { + // A large positive exponent would otherwise build a BigInteger with that many digits only to + // discard nearly all of them. + if (value.Exponent > 0 && (long)value.Exponent + value.SignificantDigits > MaxPrimitiveIntegerDigits) + { + return mode switch + { + ConversionMode.Checked => throw new OverflowException($"Value was either too large or too small for {typeof(TOther).Name}."), + ConversionMode.Saturating => TOther.CreateSaturating(value.Significand.Sign < 0 ? -PrimitiveIntegerModulus : PrimitiveIntegerModulus), + _ => TOther.CreateTruncating(value.LowIntegerBits()), + }; + } + + BigInteger integral = value.TruncateToBigInteger(); + return mode switch + { + ConversionMode.Checked => TOther.CreateChecked(integral), + ConversionMode.Saturating => TOther.CreateSaturating(integral), + _ => TOther.CreateTruncating(integral), + }; + } + + /// + /// Computes the integral value modulo 2^128, which is every bit a truncating conversion to a + /// built-in integer type keeps, without materializing the full integer. + /// + /// Only valid for a non-negative exponent. + private BigInteger LowIntegerBits() + { + BigInteger low = BigInteger.Abs(Significand) % PrimitiveIntegerModulus + * BigInteger.ModPow(Base10, Exponent, PrimitiveIntegerModulus) + % PrimitiveIntegerModulus; + + return Significand.Sign < 0 && !low.IsZero + ? PrimitiveIntegerModulus - low + : low; + } + + /// + /// Gets the integral part, truncated toward zero. + /// + private BigInteger TruncateToBigInteger() + { + if (Exponent >= 0) + { + return Significand * Pow10(Exponent); + } + + // Every digit sits after the decimal point, so the integral part is zero. + return -Exponent >= SignificantDigits + ? BigInteger.Zero + : BigInteger.Divide(Significand, Pow10(-Exponent)); + } + + 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) + { + double significand = (double)Significand; + return Exponent >= 0 + ? significand * ExactDoublePowersOfTen[Exponent] + : significand / ExactDoublePowersOfTen[-Exponent]; + } + + return ParseAs(); + } + + private float ToSingle() + { + if (int.Abs(Exponent) <= MaxExactSinglePowerOfTen && BigInteger.Abs(Significand) <= MaxExactSingleSignificand) + { + float significand = (float)Significand; + return Exponent >= 0 + ? significand * ExactSinglePowersOfTen[Exponent] + : significand / ExactSinglePowersOfTen[-Exponent]; + } + + return ParseAs(); + } + + private decimal ToDecimal(ConversionMode mode) + { + long integralDigits = (long)Exponent + SignificantDigits; + + // Below 10^28 the value is in range even after rounding its last kept digit up. + if (Significand.IsZero || integralDigits < MaxDecimalIntegralDigits) + { + return ParseAs(); + } + + if (integralDigits == MaxDecimalIntegralDigits) + { + try + { + return ParseAs(); + } + catch (OverflowException) when (mode != ConversionMode.Checked) + { + return Significand.Sign < 0 ? decimal.MinValue : decimal.MaxValue; + } + } + + return mode == ConversionMode.Checked + ? throw new OverflowException("Value was either too large or too small for a Decimal.") + : Significand.Sign < 0 ? decimal.MinValue : decimal.MaxValue; + } + + /// + /// Renders the number as significand E exponent 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. + /// + 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; + char[]? rented = length > MaxStackAllocChars ? ArrayPool.Shared.Rent(length) : null; + Span stackBuffer = stackalloc char[MaxStackAllocChars]; + Span buffer = rented is null ? stackBuffer : rented.AsSpan(); + + try + { + if (!Significand.TryFormat(buffer, out int written, default, InvariantCulture)) + { + throw new InvalidOperationException("The significand did not fit the buffer sized for it."); + } + + buffer[written++] = 'E'; + + if (!Exponent.TryFormat(buffer[written..], out int exponentWritten, default, InvariantCulture)) + { + throw new InvalidOperationException("The exponent did not fit the buffer sized for it."); + } + + return TNumber.Parse(buffer[..(written + exponentWritten)], NumberStyles.Float, InvariantCulture); + } + finally + { + if (rented is not null) + { + ArrayPool.Shared.Return(rented); + } + } + } +} diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index f7512fb..aab5ccb 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -10,10 +10,14 @@ namespace ktsu.PreciseNumber; using System.Numerics; /// -/// Represents a precise number. +/// Represents a decimal number of arbitrary precision as significand × 10^exponent. /// +/// +/// A value type. Its value is , so an uninitialized field or +/// array element is a valid number rather than a hazard. +/// [DebuggerDisplay("{Significand}e{Exponent}")] -public record PreciseNumber +public readonly partial record struct PreciseNumber : INumber { private const int Base10 = 10; @@ -192,34 +196,21 @@ private static int CountTrailingZeros(BigInteger value, int maxZeros) } /// - /// Initializes a new instance of the record by copying the values from an existing instance. - /// - /// The instance to copy. - /// Thrown when the is null. - public PreciseNumber(PreciseNumber original) - { - Ensure.NotNull(original); - Exponent = original.Exponent; - Significand = original.Significand; - SignificantDigits = original.SignificantDigits; - } - - /// - /// Initializes a new instance of the record. + /// Initializes a new instance of the struct. /// /// The exponent of the number. /// The significand of the number. - protected internal PreciseNumber(int exponent, BigInteger significand) + internal PreciseNumber(int exponent, BigInteger significand) : this(exponent, significand, true) { } /// - /// Initializes a new instance of the record. + /// Initializes a new instance of the struct. /// /// The exponent of the number. /// The significand of the number. /// If true, trailing zeros in the significand will be removed. - protected internal PreciseNumber(int exponent, BigInteger significand, bool sanitize) + internal PreciseNumber(int exponent, BigInteger significand, bool sanitize) { if (significand.IsZero) { @@ -298,7 +289,7 @@ protected internal PreciseNumber(int exponent, BigInteger significand, bool sani /// /// Gets the invariant culture information. /// - protected internal static CultureInfo InvariantCulture { get; } = CultureInfo.InvariantCulture; + internal static CultureInfo InvariantCulture { get; } = CultureInfo.InvariantCulture; private const int BinaryRadix = 2; @@ -312,8 +303,8 @@ protected internal PreciseNumber(int exponent, BigInteger significand, bool sani public static PreciseNumber MultiplicativeIdentity => One; /// - public virtual bool Equals(PreciseNumber? other) => - other is not null && Equal(this, other); + public bool Equals(PreciseNumber other) => + Equal(this, other); /// public override int GetHashCode() => HashCode.Combine(Exponent, Significand); @@ -336,8 +327,6 @@ public virtual bool Equals(PreciseNumber? other) => /// A string representation of the current instance. public static string ToString(PreciseNumber number, string? format, IFormatProvider? formatProvider) { - Ensure.NotNull(number); - NumberFormatInfo numberFormat = NumberFormatInfo.GetInstance(formatProvider ?? InvariantCulture); // Digits, plus the padding zeros implied by the exponent, plus the sign, the decimal @@ -601,11 +590,8 @@ internal static BigInteger CreateRepeatingDigits(int digit, int numberOfRepeats) /// The first number. /// The second number. /// The lower of the decimal digit counts of the two numbers. - protected internal static int LowestDecimalDigits(PreciseNumber left, PreciseNumber right) + internal static int LowestDecimalDigits(PreciseNumber left, PreciseNumber right) { - Ensure.NotNull(left); - Ensure.NotNull(right); - int leftDecimalDigits = left.CountDecimalDigits(); int rightDecimalDigits = right.CountDecimalDigits(); @@ -623,11 +609,8 @@ protected internal static int LowestDecimalDigits(PreciseNumber left, PreciseNum /// The first number. /// The second number. /// The lower of the significant digit counts of the two numbers. - protected internal static int LowestSignificantDigits(PreciseNumber left, PreciseNumber right) + internal static int LowestSignificantDigits(PreciseNumber left, PreciseNumber right) { - Ensure.NotNull(left); - Ensure.NotNull(right); - int leftSignificantDigits = left.SignificantDigits; int rightSignificantDigits = right.SignificantDigits; @@ -643,7 +626,7 @@ protected internal static int LowestSignificantDigits(PreciseNumber left, Precis /// Counts the number of decimal digits in the current instance. /// /// The number of decimal digits in the current instance. - protected internal int CountDecimalDigits() => + internal int CountDecimalDigits() => Exponent > 0 ? 0 : int.Abs(Exponent); @@ -678,7 +661,7 @@ public PreciseNumber ReduceSignificance(int significantDigits) /// The left instance. /// The right instance. /// A tuple containing the commonized instances. - protected internal static (PreciseNumber, PreciseNumber) MakeCommonized(PreciseNumber left, PreciseNumber right) + internal static (PreciseNumber, PreciseNumber) MakeCommonized(PreciseNumber left, PreciseNumber right) { (PreciseNumber commonLeft, PreciseNumber commonRight, int _) = MakeCommonizedWithExponent(left, right); return (commonLeft, commonRight); @@ -692,11 +675,8 @@ protected internal static (PreciseNumber, PreciseNumber) MakeCommonized(PreciseN /// /// A tuple containing the commonized instances and the common exponent. /// - protected internal static (PreciseNumber, PreciseNumber, int) MakeCommonizedWithExponent(PreciseNumber left, PreciseNumber right) + internal static (PreciseNumber, PreciseNumber, int) MakeCommonizedWithExponent(PreciseNumber left, PreciseNumber right) { - Ensure.NotNull(left); - Ensure.NotNull(right); - int smallestExponent = left.Exponent < right.Exponent ? left.Exponent : right.Exponent; int exponentDifferenceLeft = Math.Abs(left.Exponent - smallestExponent); int exponentDifferenceRight = Math.Abs(right.Exponent - smallestExponent); @@ -717,9 +697,6 @@ protected internal static (PreciseNumber, PreciseNumber, int) MakeCommonizedWith /// The scaled significands and the exponent they share. private static (BigInteger Left, BigInteger Right, int Exponent) CommonizeSignificands(PreciseNumber left, PreciseNumber right) { - Ensure.NotNull(left); - Ensure.NotNull(right); - int leftExponent = left.Exponent; int rightExponent = right.Exponent; @@ -746,9 +723,6 @@ private static (BigInteger Left, BigInteger Right, int Exponent) CommonizeSignif /// private static int Compare(PreciseNumber left, PreciseNumber right) { - Ensure.NotNull(left); - Ensure.NotNull(right); - int leftSign = left.Significand.Sign; int rightSign = right.Significand.Sign; @@ -778,8 +752,8 @@ private static int Compare(PreciseNumber left, PreciseNumber right) } /// - public int CompareTo(PreciseNumber? other) => - other is null ? 1 : Compare(this, other); + public int CompareTo(PreciseNumber other) => + Compare(this, other); /// /// Compares the current instance with another number of a specified type. @@ -804,7 +778,7 @@ public int CompareTo(PreciseNumber? other) => /// /// /// - /// Thrown if is null. + /// Every value is greater than . public int CompareTo(INumber? obj) where TNumber : INumber { @@ -817,13 +791,22 @@ public int CompareTo(INumber? obj) return CompareTo(other); } - /// - public int CompareTo(object? obj) - { - return obj is PreciseNumber preciseNumber - ? CompareTo(preciseNumber) - : throw new NotSupportedException(); - } + /// + /// Compares the current instance with an object. + /// + /// The object to compare with, which must be a or . + /// + /// A negative value, zero, or a positive value as the current instance is less than, equal to, or greater + /// than . Every value is greater than . + /// + /// is not a . + public int CompareTo(object? obj) => + obj switch + { + null => 1, + PreciseNumber preciseNumber => Compare(this, preciseNumber), + _ => throw new NotSupportedException(), + }; /// /// Compares the current instance with another number. @@ -843,11 +826,8 @@ public int CompareTo(TInput other) } /// - public static PreciseNumber Abs(PreciseNumber value) - { - Ensure.NotNull(value); - return value.Significand.Sign < 0 ? -value : value; - } + public static PreciseNumber Abs(PreciseNumber value) => + value.Significand.Sign < 0 ? -value : value; /// public static bool IsCanonical(PreciseNumber value) => true; @@ -868,11 +848,8 @@ public static PreciseNumber Abs(PreciseNumber value) public static bool IsInfinity(PreciseNumber value) => !IsFinite(value); /// - public static bool IsInteger(PreciseNumber value) - { - Ensure.NotNull(value); - return value.Exponent >= 0; - } + public static bool IsInteger(PreciseNumber value) => + value.Exponent >= 0; /// public static bool IsNaN(PreciseNumber value) => false; @@ -894,11 +871,8 @@ public static bool IsInteger(PreciseNumber value) public static bool IsOddInteger(PreciseNumber value) => IsInteger(value) && !value.Significand.IsEven; /// - public static bool IsPositive(PreciseNumber value) - { - Ensure.NotNull(value); - return value.Significand >= 0; - } + public static bool IsPositive(PreciseNumber value) => + value.Significand >= 0; /// public static bool IsPositiveInfinity(PreciseNumber value) => IsInfinity(value) && IsPositive(value); @@ -910,32 +884,19 @@ public static bool IsPositive(PreciseNumber value) public static bool IsSubnormal(PreciseNumber value) => !IsNormal(value); /// - public static bool IsZero(PreciseNumber value) - { - Ensure.NotNull(value); - return value.Significand == 0; - } + public static bool IsZero(PreciseNumber value) => + value.Significand == 0; /// - public static PreciseNumber MaxMagnitude(PreciseNumber x, PreciseNumber y) - { - Ensure.NotNull(x); - Ensure.NotNull(y); - - return x.Abs() >= y.Abs() ? x : y; - } + public static PreciseNumber MaxMagnitude(PreciseNumber x, PreciseNumber y) => + x.Abs() >= y.Abs() ? x : y; /// public static PreciseNumber MaxMagnitudeNumber(PreciseNumber x, PreciseNumber y) => MaxMagnitude(x, y); /// - public static PreciseNumber MinMagnitude(PreciseNumber x, PreciseNumber y) - { - Ensure.NotNull(x); - Ensure.NotNull(y); - - return x.Abs() <= y.Abs() ? x : y; - } + public static PreciseNumber MinMagnitude(PreciseNumber x, PreciseNumber y) => + x.Abs() <= y.Abs() ? x : y; /// public static PreciseNumber MinMagnitudeNumber(PreciseNumber x, PreciseNumber y) => MinMagnitude(x, y); @@ -1040,7 +1001,7 @@ public static PreciseNumber Parse(ReadOnlySpan s, IFormatProvider? provide Parse(s, NumberStyles.Any, provider); /// - public static bool TryParse(ReadOnlySpan s, NumberStyles style, IFormatProvider? provider, [MaybeNullWhen(false)][NotNullWhen(true)] out PreciseNumber result) + public static bool TryParse(ReadOnlySpan s, NumberStyles style, IFormatProvider? provider, out PreciseNumber result) { try { @@ -1055,15 +1016,15 @@ public static bool TryParse(ReadOnlySpan s, NumberStyles style, IFormatPro } /// - public static bool TryParse([NotNullWhen(true)] string? s, NumberStyles style, IFormatProvider? provider, [NotNullWhen(true)] out PreciseNumber? result) => + public static bool TryParse([NotNullWhen(true)] string? s, NumberStyles style, IFormatProvider? provider, out PreciseNumber result) => TryParse(s.AsSpan(), style, provider, out result); /// - public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [NotNullWhen(true)] out PreciseNumber? result) => + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out PreciseNumber result) => TryParse(s.AsSpan(), NumberStyles.Any, provider, out result); /// - public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [NotNullWhen(true)] out PreciseNumber? result) => + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out PreciseNumber result) => TryParse(s, NumberStyles.Any, provider, out result); /// @@ -1182,48 +1143,13 @@ private bool TryWriteDigits(Span destination, ReadOnlySpan digits, N return true; } - /// - public static bool TryConvertFromChecked(TOther value, out PreciseNumber result) - where TOther : INumberBase - => throw new NotSupportedException(); - - /// - public static bool TryConvertFromSaturating(TOther value, out PreciseNumber result) - where TOther : INumberBase - => throw new NotSupportedException(); - - /// - public static bool TryConvertFromTruncating(TOther value, out PreciseNumber result) - where TOther : INumberBase - => throw new NotSupportedException(); - - /// - public static bool TryConvertToChecked(PreciseNumber value, out TOther result) - where TOther : INumberBase - => throw new NotSupportedException(); - - /// - public static bool TryConvertToSaturating(PreciseNumber value, out TOther result) - where TOther : INumberBase - => throw new NotSupportedException(); - - /// - public static bool TryConvertToTruncating(PreciseNumber value, out TOther result) - where TOther : INumberBase - => throw new NotSupportedException(); - /// /// Asserts that the exponents of two numbers match. /// /// The first number. /// The second number. - protected internal static void AssertExponentsMatch(PreciseNumber left, PreciseNumber right) - { - Ensure.NotNull(left); - Ensure.NotNull(right); - + internal static void AssertExponentsMatch(PreciseNumber left, PreciseNumber right) => Debug.Assert(left.Exponent == right.Exponent, $"{nameof(AssertExponentsMatch)}: {left.Exponent} == {right.Exponent}"); - } /// /// Negates a number. @@ -1232,7 +1158,6 @@ protected internal static void AssertExponentsMatch(PreciseNumber left, PreciseN /// The negated number. public static PreciseNumber Negate(PreciseNumber value) { - Ensure.NotNull(value); return value.Significand.IsZero ? value : new(value.Exponent, -value.Significand); @@ -1270,9 +1195,6 @@ public static PreciseNumber Add(PreciseNumber left, PreciseNumber right) /// The result of the multiplication. public static PreciseNumber Multiply(PreciseNumber left, PreciseNumber right) { - Ensure.NotNull(left); - Ensure.NotNull(right); - if (left.Significand.IsZero || right.Significand.IsZero) { return Zero; @@ -1307,9 +1229,6 @@ public static PreciseNumber Multiply(PreciseNumber left, PreciseNumber right) /// public static PreciseNumber Divide(PreciseNumber left, PreciseNumber right) { - Ensure.NotNull(left); - Ensure.NotNull(right); - // Dividing must not silently discard precision the operands already carry. int significantDigits = Math.Max( Math.Max(left.SignificantDigits, right.SignificantDigits), @@ -1332,9 +1251,6 @@ public static PreciseNumber Divide(PreciseNumber left, PreciseNumber right) /// Thrown when is less than one. public static PreciseNumber Divide(PreciseNumber left, PreciseNumber right, int significantDigits) { - Ensure.NotNull(left); - Ensure.NotNull(right); - if (significantDigits < 1) { throw new ArgumentOutOfRangeException(nameof(significantDigits), significantDigits, "At least one significant digit is required."); @@ -1361,7 +1277,7 @@ public static PreciseNumber Divide(PreciseNumber left, PreciseNumber right, int denominator = -denominator; } - return TryDivideExactly(numerator, denominator, exponent, out PreciseNumber? exact) + return TryDivideExactly(numerator, denominator, exponent, out PreciseNumber exact) ? exact : DivideToPrecision(numerator, denominator, exponent, significantDigits); } @@ -1374,7 +1290,7 @@ public static PreciseNumber Divide(PreciseNumber left, PreciseNumber right, int /// The exponent the quotient's significand sits at. /// The exact quotient, when there is one. /// true if the quotient terminates and is exact; otherwise false. - private static bool TryDivideExactly(BigInteger numerator, BigInteger denominator, int exponent, [NotNullWhen(true)] out PreciseNumber? result) + private static bool TryDivideExactly(BigInteger numerator, BigInteger denominator, int exponent, out PreciseNumber result) { // A fraction terminates in base ten exactly when its denominator is 2^twos * 5^fives. Most // denominators are rejected by the first remainder test, which is why this is worth trying @@ -1391,7 +1307,7 @@ private static bool TryDivideExactly(BigInteger numerator, BigInteger denominato if (!remaining.IsOne) { - result = null; + result = default; return false; } @@ -1450,9 +1366,6 @@ private static PreciseNumber DivideToPrecision(BigInteger numerator, BigInteger /// The modulus of the two numbers. public static PreciseNumber Mod(PreciseNumber left, PreciseNumber right) { - Ensure.NotNull(left); - Ensure.NotNull(right); - if (right.Significand.IsZero) { throw new DivideByZeroException(); @@ -1569,11 +1482,8 @@ public static bool NotEqual(PreciseNumber left, PreciseNumber right) => /// The minimum value. /// The maximum value. /// The clamped number. - public static PreciseNumber Clamp(PreciseNumber value, PreciseNumber min, PreciseNumber max) - { - Ensure.NotNull(value); - return value.Clamp(min, max); - } + public static PreciseNumber Clamp(PreciseNumber value, PreciseNumber min, PreciseNumber max) => + value.Clamp(min, max); /// /// Rounds a number to the specified number of decimal digits. @@ -1581,11 +1491,8 @@ public static PreciseNumber Clamp(PreciseNumber value, PreciseNumber min, Precis /// The number to round. /// The number of decimal digits to round to. /// The rounded number. - public static PreciseNumber Round(PreciseNumber value, int decimalDigits) - { - Ensure.NotNull(value); - return value.Round(decimalDigits); - } + public static PreciseNumber Round(PreciseNumber value, int decimalDigits) => + value.Round(decimalDigits); /// /// Returns the square of the current number. @@ -1606,8 +1513,6 @@ public static PreciseNumber Round(PreciseNumber value, int decimalDigits) /// A new instance of that is the result of raising the current instance to the specified power. public PreciseNumber Pow(PreciseNumber power) { - Ensure.NotNull(power); - if (power.Significand.IsZero) { return One; @@ -1655,8 +1560,6 @@ public PreciseNumber Pow(PreciseNumber power) /// A new instance of that is the result of raising e to the specified power. public static PreciseNumber Exp(PreciseNumber power) { - Ensure.NotNull(power); - if (power.Significand.IsZero) { return One; @@ -1721,17 +1624,6 @@ public static PreciseNumber Exp(PreciseNumber power) public static PreciseNumber operator ++(PreciseNumber value) => Increment(value); - /// - /// Caches the copy constructor of a derived type so that - /// only reflects over each type once. - /// - private static class CopyConstructorOf - where TOutput : PreciseNumber - { - internal static readonly System.Reflection.ConstructorInfo? Constructor = - typeof(TOutput).GetConstructor([typeof(PreciseNumber)]); - } - /// /// Asserts that a type implements a specified generic interface. /// @@ -1763,36 +1655,17 @@ internal static bool DoesImplementGenericInterface(Type type, Type genericInterf /// The type to convert to. Must implement . /// The converted value of the number as type . /// - /// Thrown if the conversion cannot be performed. This may occur if the target type cannot represent - /// the value of the number. + /// Thrown if the target type is an integer type or and cannot represent the value. /// + /// + /// Built-in numeric types and convert as + /// describes: integer types truncate + /// toward zero, and binary floating point types are correctly rounded. Any other type is built from + /// the significand and a power of ten, and is limited to that precision. + /// public TOutput To() where TOutput : INumber => - typeof(TOutput) == typeof(PreciseNumber) - ? (TOutput)(object)this + TryConvertTo(this, ConversionMode.Checked, out TOutput? result) + ? result : TOutput.CreateChecked(Significand) * TOutput.CreateChecked(Math.Pow(Base10, Exponent)); - - /// - /// Converts the current instance to the specified derived type of . - /// - /// The type to convert to. Must derive from . - /// - /// An instance of type representing the current instance. - /// - /// - /// Thrown if the conversion cannot be performed. This may occur if the target type does not have a constructor - /// that accepts a as a parameter. - /// - public TOutput As() - where TOutput : PreciseNumber - { - if (typeof(TOutput) == typeof(PreciseNumber)) - { - return (TOutput)(object)this; - } - - System.Reflection.ConstructorInfo? constructor = CopyConstructorOf.Constructor; - return (TOutput)(constructor?.Invoke([this]) ?? - throw new NotSupportedException($"Cannot convert {GetType()} to {typeof(TOutput)}")); - } } diff --git a/PreciseNumber/PreciseNumberExtensions.cs b/PreciseNumber/PreciseNumberExtensions.cs index c974439..2753353 100644 --- a/PreciseNumber/PreciseNumberExtensions.cs +++ b/PreciseNumber/PreciseNumberExtensions.cs @@ -40,7 +40,7 @@ private static class KindOf private static NumberKind ClassifyType(Type type) { - if (type == typeof(PreciseNumber) || type.IsSubclassOf(typeof(PreciseNumber))) + if (type == typeof(PreciseNumber)) { return NumberKind.PreciseNumber; } @@ -85,7 +85,7 @@ public static PreciseNumber ToPreciseNumber(this TInput input) return alreadyPrecise; } - return TryCreate(input, out PreciseNumber? preciseNumber) + return TryCreate(input, out PreciseNumber preciseNumber) ? preciseNumber : throw new NotSupportedException(); } @@ -95,9 +95,9 @@ public static PreciseNumber ToPreciseNumber(this TInput input) /// /// The type of the input number. /// The input number to create a from. - /// The created if successful, otherwise null. + /// The created if successful, otherwise zero. /// True if the creation was successful, otherwise false. - internal static bool TryCreate([NotNullWhen(true)] TInput input, [MaybeNullWhen(false)][NotNullWhen(true)] out PreciseNumber? preciseNumber) + internal static bool TryCreate([NotNullWhen(true)] TInput input, out PreciseNumber preciseNumber) where TInput : INumber { if (input is PreciseNumber alreadyPrecise) @@ -117,7 +117,7 @@ internal static bool TryCreate([NotNullWhen(true)] TInput input, [MaybeN return true; default: - preciseNumber = null; + preciseNumber = default; return false; } } diff --git a/README.md b/README.md index f2d1abf..feabb22 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,9 @@ A high-precision numeric type for .NET that provides arbitrary precision arithme - **Lossless Arithmetic**: Preserves precision during calculations with no rounding errors. -- **Full .NET Integration**: Implements `INumber` interface for seamless integration with .NET's numeric ecosystem. +- **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. - **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. @@ -197,6 +199,29 @@ Console.WriteLine(originalBigInt == roundTripBigInt); // True ``` +### Generic math conversions + +Code written against `INumber` reaches PreciseNumber through `CreateChecked`, `CreateSaturating`, and `CreateTruncating`. They work in both directions for every built-in numeric type and `BigInteger`: + +```csharp +using System.Numerics; +using ktsu.PreciseNumber; + +static T ToMeters(T feet) where T : INumber => feet * T.CreateChecked(0.3048); + +PreciseNumber meters = ToMeters(10.ToPreciseNumber()); // exactly 3.048 +double asDouble = double.CreateChecked(meters); // 3.048 +int whole = int.CreateChecked(meters); // 3, truncated toward zero +``` + +- Integers, `BigInteger`, and `decimal` convert in exactly. `double`, `float`, and `Half` convert through their decimal text, so `0.3048` arrives as exactly 0.3048. +- NaN throws in a checked conversion and becomes zero otherwise. An infinity always throws. Both match `BigInteger`. +- Integer destinations keep the integral part. Checked throws when it's out of range, saturating clamps, and truncating wraps the way `BigInteger` does. +- `double`, `float`, and `Half` destinations are correctly rounded however many digits the number has. +- `decimal` destinations round to the digits `decimal` holds. Checked throws when the value is out of range, and saturating and truncating clamp. + +`To()` uses the same conversions. + ### Mathematical Functions PreciseNumber supports a wide range of mathematical operations: @@ -343,6 +368,8 @@ This representation allows for: - Accurate arithmetic without floating-point errors +- A `default` value that is exactly zero, since PreciseNumber is a value type + ## Precision Control You can control precision using: @@ -363,13 +390,17 @@ three-argument overload when you want something other than that. - `Exp()`, and `Pow()` with a non-integer power, are computed through `double` and are therefore limited to its precision. Addition, subtraction, multiplication and division are not -- Conversion to standard types may throw `OverflowException` if the value is too large +- 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 ## Performance -Values are immutable, so every operation returns a new instance, and every instance holds its -digits in a `BigInteger`. Cost therefore tracks the number of significant digits rather than the -magnitude of the value, and allocation matters as much as raw speed. +Values are immutable value types. Every operation returns a new value, but that value lives inline +in its variable, field, or array element, so the only heap allocation is the `BigInteger` digit +array, and a significand that fits in an `int` doesn't need one. Cost therefore tracks the number +of significant digits rather than the magnitude of the value, and allocation matters as much as +raw speed. The repository carries a [BenchmarkDotNet suite](PreciseNumber.Benchmarks/README.md) covering construction, comparison, arithmetic, rounding, text conversion and primitive conversion, each @@ -397,6 +428,10 @@ that produced them. - **Utility**: `ToString()`, `Parse()`, `TryParse()`, `To()` +- **Generic Conversion**: `TryConvertFromChecked`, `TryConvertFromSaturating`, `TryConvertFromTruncating`, `TryConvertToChecked`, `TryConvertToSaturating`, and `TryConvertToTruncating`, reached through `CreateChecked`, `CreateSaturating`, and `CreateTruncating` + +Upgrading from 1.x? See the [2.0 migration guide](docs/migration-guide-2.0.md). + ### PreciseNumberExtensions Class - **Conversion**: `ToPreciseNumber()`extension method for any`INumber` diff --git a/docs/migration-guide-2.0.md b/docs/migration-guide-2.0.md new file mode 100644 index 0000000..a8b2545 --- /dev/null +++ b/docs/migration-guide-2.0.md @@ -0,0 +1,105 @@ +# Migrating from PreciseNumber 1.x to 2.0 + +PreciseNumber 2.0 makes `PreciseNumber` a value type and makes generic math conversions work. Arithmetic, parsing, formatting, equality, and hashing produce the same results as before. What changes is how the type behaves around `null`, inheritance, and conversion. + +## Quick checklist + +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`. +5. Check calls to `To()` that convert a fractional value to an integer type, which now truncate instead of returning zero. + +## 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. + +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`. + +## 1. PreciseNumber is a value type + +`default(PreciseNumber)` is zero. It has the same `Exponent`, `Significand`, `SignificantDigits`, and hash code as `PreciseNumber.Zero`, so an uninitialized field or array element is a valid number. + +```csharp +// Was: +PreciseNumber? total = null; +if (total is null) { total = PreciseNumber.Zero; } + +// Now: +PreciseNumber total = default; // zero +``` + +A `PreciseNumber?` still compiles, but it's now a `Nullable`. Code that used `?` to mean "might be null" should either drop the `?` or use `Nullable` deliberately. + +## 2. No inheritance + +A struct can't be inherited, so these are gone: + +| Removed | Replacement | +|---|---| +| Deriving from `PreciseNumber` | Hold a `PreciseNumber` field and convert to it | +| `PreciseNumber(PreciseNumber original)` copy constructor | Assign the value. Copying a struct copies it. | +| `As()` | Construct the target type from the value directly | + +These members were `protected internal` for derived types and are now `internal`: + +- `PreciseNumber(int exponent, BigInteger significand)` and `PreciseNumber(int exponent, BigInteger significand, bool sanitize)` +- `LowestDecimalDigits`, `LowestSignificantDigits`, and `CountDecimalDigits` +- `MakeCommonized` and `MakeCommonizedWithExponent` +- `AssertExponentsMatch` and `InvariantCulture` + +`ktsu.SignificantNumber` derives from `PreciseNumber` 1.x and uses several of them. It keeps working against 1.x until it moves to 2.0, which means holding a `PreciseNumber` instead of deriving from one. + +## 3. Signatures that accepted null + +| Member | 1.x | 2.0 | +|---|---|---| +| `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 | + +```csharp +// Was: +if (PreciseNumber.TryParse(text, CultureInfo.InvariantCulture, out PreciseNumber? parsed)) { Use(parsed); } + +// Now: +if (PreciseNumber.TryParse(text, CultureInfo.InvariantCulture, out PreciseNumber parsed)) { Use(parsed); } +``` + +## 4. Generic math conversions work + +The six `TryConvertFrom*` and `TryConvertTo*` methods are implemented for every built-in numeric type (`sbyte`, `byte`, `short`, `ushort`, `int`, `uint`, `long`, `ulong`, `Int128`, `UInt128`, `nint`, `nuint`, `char`, `Half`, `float`, `double`, and `decimal`) and for `BigInteger`. They return `false` for any other type instead of throwing. + +```csharp +static T ToMeters(T feet) where T : INumber => feet * T.CreateChecked(0.3048); + +PreciseNumber meters = ToMeters(10.ToPreciseNumber()); // exactly 3.048 +double asDouble = double.CreateChecked(meters); // 3.048 +``` + +Converting to `PreciseNumber`: + +| Source | Checked | Saturating | Truncating | +|---|---|---|---| +| Integers, `BigInteger`, and `decimal` | Exact | Exact | Exact | +| `double`, `float`, and `Half` | Through decimal text, so `0.3048` is exactly 0.3048 | Same | Same | +| 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. + +Converting from `PreciseNumber`: + +| Destination | Checked | Saturating | Truncating | +|---|---|---|---| +| Integer types | Integral part, truncated toward zero. Throws `OverflowException` when out of range. | Clamps to the minimum or maximum | Wraps, keeping the low bits, as `BigInteger` does | +| `BigInteger` | Integral part, truncated toward zero | Same | Same | +| `double`, `float`, and `Half` | Correctly rounded, overflowing to infinity | Same | Same | +| `decimal` | Rounded to the digits `decimal` holds. Throws `OverflowException` when out of range. | Clamps to `decimal.MinValue` or `decimal.MaxValue` | Clamps, as `BigInteger` does | + +## 5. To() uses the same conversions + +`To()` used to multiply the significand by `Math.Pow(10, exponent)` converted to the target type. For an integer target and a negative exponent, the power of ten converted to zero, so `12.9` became `0`. For `double`, the result could miss the nearest representable value. + +It now uses the checked conversion described earlier. `12.9.ToPreciseNumber().To()` is `12`, and `To()` is correctly rounded however many digits the number has. A type that isn't built in still goes through the old calculation.