Skip to content

Commit 7a05840

Browse files
Merge pull request #70 from ktsu-dev/precise-number-value-type
[major] Make PreciseNumber a value type
2 parents cd9a822 + d060de5 commit 7a05840

9 files changed

Lines changed: 1181 additions & 276 deletions

‎CLAUDE.md‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' --job s
2323

2424
### Core Types
2525

26-
- **PreciseNumber** (`PreciseNumber/PreciseNumber.cs`): The main numeric type implementing `INumber<PreciseNumber>`. Stores values using:
26+
- **PreciseNumber** (`PreciseNumber/PreciseNumber.cs`): The main numeric type, a `readonly partial record struct` implementing `INumber<PreciseNumber>`. Its `default` is `Zero`, which `PreciseNumberValueTypeTests` pins. Stores values using:
2727
- `Significand`: A `BigInteger` containing all significant digits
2828
- `Exponent`: An `int` determining the decimal place
2929
- `SignificantDigits`: Count of significant digits
3030

31+
- **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<T>()` uses them too
32+
3133
- **PreciseNumberExtensions** (`PreciseNumber/PreciseNumberExtensions.cs`): Extension methods providing `ToPreciseNumber<T>()` for converting any `INumber<T>` to PreciseNumber
3234

3335
### Key Design Patterns
@@ -37,10 +39,12 @@ dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' --job s
3739
- `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`
3840
- The `sanitize` constructor parameter controls whether trailing zeros are removed (default: true)
3941
- Constants (`Zero`, `One`, `Pi`, `E`, `Tau`) are pre-computed static instances
42+
- 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
43+
- 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
4044

4145
### Test Structure
4246

43-
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.
47+
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.
4448

4549
### Benchmarks
4650

@@ -52,9 +56,10 @@ Most classes are parameterised by `Digits` (8, 30, 200). That axis is the point:
5256
`BigInteger`, so anything that touches them one at a time looks fine at 8 digits and collapses at
5357
200. Read results across the `Digits` column, not down one value of it.
5458

55-
Allocation is reported alongside time and matters just as much — every operation returns a new
56-
instance, so avoiding an intermediate shows up in `Allocated` before it shows up in `Mean`.
57-
Comparisons should allocate nothing at all.
59+
Allocation is reported alongside time and matters just as much. The number is a value type, so the
60+
only allocations are `BigInteger` digit arrays, and avoiding an intermediate shows up in `Allocated`
61+
before it shows up in `Mean`. Comparisons, and addition, subtraction, and multiplication of
62+
significands that fit in an `int`, should allocate nothing at all.
5863

5964
Run the relevant benchmarks before and after any change to the library's internals. See
6065
`PreciseNumber.Benchmarks/README.md` for details.
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
// Copyright (c) 2023-2026 ktsu-dev contributors
2+
3+
namespace ktsu.PreciseNumber.Test;
4+
5+
using System.Globalization;
6+
using System.Numerics;
7+
8+
/// <summary>
9+
/// Covers conversion through generic math, which is how code written against <see cref="INumber{TSelf}"/>
10+
/// reaches <see cref="PreciseNumber"/>.
11+
/// </summary>
12+
[TestClass]
13+
public class PreciseNumberConversionTests
14+
{
15+
private static PreciseNumber P(string text) => PreciseNumber.Parse(text, CultureInfo.InvariantCulture);
16+
17+
private static TTo Checked<TTo, TFrom>(TFrom value)
18+
where TTo : INumberBase<TTo>
19+
where TFrom : INumberBase<TFrom>
20+
=> TTo.CreateChecked(value);
21+
22+
private static TTo Saturating<TTo, TFrom>(TFrom value)
23+
where TTo : INumberBase<TTo>
24+
where TFrom : INumberBase<TFrom>
25+
=> TTo.CreateSaturating(value);
26+
27+
private static TTo Truncating<TTo, TFrom>(TFrom value)
28+
where TTo : INumberBase<TTo>
29+
where TFrom : INumberBase<TFrom>
30+
=> TTo.CreateTruncating(value);
31+
32+
/// <summary>The shape of a unit conversion in generic quantity code.</summary>
33+
private static T ToBase<T>(T value, double factor)
34+
where T : INumber<T>
35+
=> value * T.CreateChecked(factor);
36+
37+
private static void AssertFromInAllModes<TFrom>(TFrom value, string expected)
38+
where TFrom : INumberBase<TFrom>
39+
{
40+
PreciseNumber expectedNumber = P(expected);
41+
Assert.AreEqual(expectedNumber, Checked<PreciseNumber, TFrom>(value), $"Checked from {typeof(TFrom).Name}");
42+
Assert.AreEqual(expectedNumber, Saturating<PreciseNumber, TFrom>(value), $"Saturating from {typeof(TFrom).Name}");
43+
Assert.AreEqual(expectedNumber, Truncating<PreciseNumber, TFrom>(value), $"Truncating from {typeof(TFrom).Name}");
44+
}
45+
46+
private static void AssertToInAllModes<TTo>(string value, TTo expected)
47+
where TTo : INumberBase<TTo>
48+
{
49+
PreciseNumber number = P(value);
50+
Assert.AreEqual(expected, Checked<TTo, PreciseNumber>(number), $"Checked to {typeof(TTo).Name}");
51+
Assert.AreEqual(expected, Saturating<TTo, PreciseNumber>(number), $"Saturating to {typeof(TTo).Name}");
52+
Assert.AreEqual(expected, Truncating<TTo, PreciseNumber>(number), $"Truncating to {typeof(TTo).Name}");
53+
}
54+
55+
[TestMethod]
56+
public void FromEveryIntegerTypeIsExact()
57+
{
58+
AssertFromInAllModes(sbyte.MinValue, "-128");
59+
AssertFromInAllModes(byte.MaxValue, "255");
60+
AssertFromInAllModes(short.MinValue, "-32768");
61+
AssertFromInAllModes(ushort.MaxValue, "65535");
62+
AssertFromInAllModes(int.MinValue, "-2147483648");
63+
AssertFromInAllModes(uint.MaxValue, "4294967295");
64+
AssertFromInAllModes(long.MinValue, "-9223372036854775808");
65+
AssertFromInAllModes(ulong.MaxValue, "18446744073709551615");
66+
AssertFromInAllModes(Int128.MinValue, "-170141183460469231731687303715884105728");
67+
AssertFromInAllModes(UInt128.MaxValue, "340282366920938463463374607431768211455");
68+
AssertFromInAllModes((nint)(-42), "-42");
69+
AssertFromInAllModes((nuint)42, "42");
70+
AssertFromInAllModes('A', "65");
71+
AssertFromInAllModes(BigInteger.Pow(10, 60) + 1, "1" + new string('0', 59) + "1");
72+
AssertFromInAllModes(1000, "1000");
73+
}
74+
75+
[TestMethod]
76+
public void FromDecimalIsExact()
77+
{
78+
AssertFromInAllModes(1234.5678m, "1234.5678");
79+
AssertFromInAllModes(decimal.MaxValue, "79228162514264337593543950335");
80+
AssertFromInAllModes(decimal.MinValue, "-79228162514264337593543950335");
81+
AssertFromInAllModes(0.0000000000000000000000000001m, "0.0000000000000000000000000001");
82+
}
83+
84+
[TestMethod]
85+
public void FromBinaryFloatingPointUsesDecimalText()
86+
{
87+
AssertFromInAllModes(0.3048, "0.3048");
88+
AssertFromInAllModes(-1.5e-10, "-0.00000000015");
89+
AssertFromInAllModes(1.25f, "1.25");
90+
AssertFromInAllModes((Half)1.5, "1.5");
91+
AssertFromInAllModes(0.0, "0");
92+
}
93+
94+
[TestMethod]
95+
public void FromPreciseNumberIsIdentity()
96+
{
97+
AssertFromInAllModes(P("-12.5"), "-12.5");
98+
}
99+
100+
[TestMethod]
101+
public void GenericUnitConversionIsExact()
102+
{
103+
Assert.AreEqual(P("0.3048"), ToBase(PreciseNumber.One, 0.3048));
104+
Assert.AreEqual(P("3.048"), ToBase(10.ToPreciseNumber(), 0.3048));
105+
}
106+
107+
[TestMethod]
108+
public void FromNaNMatchesBigInteger()
109+
{
110+
// BigInteger is the other built-in numeric type with no NaN and no bound, so it sets the convention.
111+
Assert.ThrowsExactly<OverflowException>(() => Checked<BigInteger, double>(double.NaN));
112+
Assert.AreEqual(BigInteger.Zero, Saturating<BigInteger, double>(double.NaN));
113+
Assert.AreEqual(BigInteger.Zero, Truncating<BigInteger, double>(double.NaN));
114+
115+
Assert.ThrowsExactly<OverflowException>(() => Checked<PreciseNumber, double>(double.NaN));
116+
Assert.AreEqual(PreciseNumber.Zero, Saturating<PreciseNumber, double>(double.NaN));
117+
Assert.AreEqual(PreciseNumber.Zero, Truncating<PreciseNumber, float>(float.NaN));
118+
Assert.AreEqual(PreciseNumber.Zero, Saturating<PreciseNumber, Half>(Half.NaN));
119+
}
120+
121+
[TestMethod]
122+
public void FromInfinityMatchesBigInteger()
123+
{
124+
Assert.ThrowsExactly<OverflowException>(() => Checked<BigInteger, double>(double.PositiveInfinity));
125+
Assert.ThrowsExactly<OverflowException>(() => Saturating<BigInteger, double>(double.PositiveInfinity));
126+
Assert.ThrowsExactly<OverflowException>(() => Truncating<BigInteger, double>(double.NegativeInfinity));
127+
128+
Assert.ThrowsExactly<OverflowException>(() => Checked<PreciseNumber, double>(double.PositiveInfinity));
129+
Assert.ThrowsExactly<OverflowException>(() => Saturating<PreciseNumber, double>(double.PositiveInfinity));
130+
Assert.ThrowsExactly<OverflowException>(() => Truncating<PreciseNumber, float>(float.NegativeInfinity));
131+
}
132+
133+
[TestMethod]
134+
public void TryConvertReportsUnsupportedTypes()
135+
{
136+
Assert.IsFalse(PreciseNumber.TryConvertFromChecked(new Complex(1, 0), out PreciseNumber _), "Complex is not a supported source");
137+
Assert.IsFalse(PreciseNumber.TryConvertToChecked(PreciseNumber.One, out Complex _), "Complex is not a supported destination");
138+
}
139+
140+
[TestMethod]
141+
public void ToIntegerTruncatesTowardZero()
142+
{
143+
AssertToInAllModes("12.9", 12);
144+
AssertToInAllModes("-12.9", -12);
145+
AssertToInAllModes("0.999", 0L);
146+
AssertToInAllModes("-0.5", (short)0);
147+
AssertToInAllModes("12345e3", 12345000);
148+
AssertToInAllModes("65.7", 'A');
149+
AssertToInAllModes("-170141183460469231731687303715884105728", Int128.MinValue);
150+
AssertToInAllModes("340282366920938463463374607431768211455", UInt128.MaxValue);
151+
AssertToInAllModes("-42.1", (nint)(-42));
152+
AssertToInAllModes("42.1", (nuint)42);
153+
AssertToInAllModes("255", byte.MaxValue);
154+
AssertToInAllModes("-128", sbyte.MinValue);
155+
AssertToInAllModes("65535", ushort.MaxValue);
156+
AssertToInAllModes("4294967295", uint.MaxValue);
157+
AssertToInAllModes("18446744073709551615.5", ulong.MaxValue);
158+
}
159+
160+
[TestMethod]
161+
public void ToIntegerOutOfRange()
162+
{
163+
PreciseNumber tooLargeForByte = P("300");
164+
Assert.ThrowsExactly<OverflowException>(() => Checked<byte, PreciseNumber>(tooLargeForByte));
165+
Assert.AreEqual(byte.MaxValue, Saturating<byte, PreciseNumber>(tooLargeForByte));
166+
Assert.AreEqual((byte)44, Truncating<byte, PreciseNumber>(tooLargeForByte));
167+
168+
PreciseNumber negative = P("-1");
169+
Assert.ThrowsExactly<OverflowException>(() => Checked<uint, PreciseNumber>(negative));
170+
Assert.AreEqual(uint.MinValue, Saturating<uint, PreciseNumber>(negative));
171+
Assert.AreEqual(uint.MaxValue, Truncating<uint, PreciseNumber>(negative));
172+
173+
PreciseNumber pastInt = P("2147483648");
174+
Assert.ThrowsExactly<OverflowException>(() => Checked<int, PreciseNumber>(pastInt));
175+
Assert.AreEqual(int.MaxValue, Saturating<int, PreciseNumber>(pastInt));
176+
Assert.AreEqual(int.MinValue, Truncating<int, PreciseNumber>(pastInt));
177+
178+
Assert.ThrowsExactly<OverflowException>(() => Checked<char, PreciseNumber>(P("70000")));
179+
}
180+
181+
[TestMethod]
182+
public void ToIntegerFromHugeValuesMatchesBigInteger()
183+
{
184+
foreach (string text in new[] { "7e5000", "-7e5000", "123456789e50", "-98765e45", "3e39", "1e38" })
185+
{
186+
PreciseNumber number = P(text);
187+
BigInteger integer = number.To<BigInteger>();
188+
189+
Assert.AreEqual(Truncating<int, BigInteger>(integer), Truncating<int, PreciseNumber>(number), $"int truncating {text}");
190+
Assert.AreEqual(Truncating<ulong, BigInteger>(integer), Truncating<ulong, PreciseNumber>(number), $"ulong truncating {text}");
191+
Assert.AreEqual(Truncating<Int128, BigInteger>(integer), Truncating<Int128, PreciseNumber>(number), $"Int128 truncating {text}");
192+
Assert.AreEqual(Truncating<UInt128, BigInteger>(integer), Truncating<UInt128, PreciseNumber>(number), $"UInt128 truncating {text}");
193+
Assert.AreEqual(Saturating<long, BigInteger>(integer), Saturating<long, PreciseNumber>(number), $"long saturating {text}");
194+
Assert.AreEqual(Saturating<UInt128, BigInteger>(integer), Saturating<UInt128, PreciseNumber>(number), $"UInt128 saturating {text}");
195+
}
196+
197+
Assert.ThrowsExactly<OverflowException>(() => Checked<Int128, PreciseNumber>(P("7e5000")));
198+
}
199+
200+
[TestMethod]
201+
public void ToBigIntegerTruncatesTowardZero()
202+
{
203+
AssertToInAllModes("12345e5", BigInteger.Parse("1234500000", CultureInfo.InvariantCulture));
204+
AssertToInAllModes("-12.9", new BigInteger(-12));
205+
AssertToInAllModes("0.0001", BigInteger.Zero);
206+
}
207+
208+
[TestMethod]
209+
public void ToDoubleIsCorrectlyRounded()
210+
{
211+
string[] texts =
212+
[
213+
"0.1",
214+
"0.3048",
215+
"-123.456",
216+
"3.14159265358979323846264338327950288419716939937510582097494",
217+
"9007199254740993",
218+
"9007199254740995",
219+
"2.2250738585072011e-308",
220+
"4.9406564584124654e-324",
221+
"1.7976931348623157e308",
222+
"123456789012345678901234567890e-45",
223+
"0.000000000000000000000000000000000000000001",
224+
];
225+
226+
foreach (string text in texts)
227+
{
228+
double expected = double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture);
229+
AssertToInAllModes(text, expected);
230+
}
231+
}
232+
233+
[TestMethod]
234+
public void ToDoubleOverflowsToInfinity()
235+
{
236+
AssertToInAllModes("1e400", double.PositiveInfinity);
237+
AssertToInAllModes("-1e400", double.NegativeInfinity);
238+
AssertToInAllModes("1e-400", 0.0);
239+
}
240+
241+
[TestMethod]
242+
public void ToSingleAndHalfAreCorrectlyRounded()
243+
{
244+
foreach (string text in new[] { "0.1", "16777217", "3.4028235e38", "1.401298464324817e-45", "2.718281828459045235360287471352662497757" })
245+
{
246+
AssertToInAllModes(text, float.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture));
247+
}
248+
249+
foreach (string text in new[] { "0.1", "65504", "65520", "1.5", "-3.14159" })
250+
{
251+
AssertToInAllModes(text, Half.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture));
252+
}
253+
}
254+
255+
[TestMethod]
256+
public void ToDecimalRoundsExcessDigits()
257+
{
258+
AssertToInAllModes("1234.5678", 1234.5678m);
259+
AssertToInAllModes("79228162514264337593543950335", decimal.MaxValue);
260+
261+
const string longFraction = "0.12345678901234567890123456789012345678901234";
262+
AssertToInAllModes(longFraction, decimal.Parse(longFraction, NumberStyles.Float, CultureInfo.InvariantCulture));
263+
}
264+
265+
[TestMethod]
266+
public void ToDecimalOutOfRange()
267+
{
268+
foreach (string text in new[] { "1e30", "79228162514264337593543950336" })
269+
{
270+
PreciseNumber number = P(text);
271+
Assert.ThrowsExactly<OverflowException>(() => Checked<decimal, PreciseNumber>(number), text);
272+
Assert.AreEqual(decimal.MaxValue, Saturating<decimal, PreciseNumber>(number), text);
273+
Assert.AreEqual(decimal.MaxValue, Truncating<decimal, PreciseNumber>(number), text);
274+
Assert.AreEqual(decimal.MinValue, Saturating<decimal, PreciseNumber>(-number), text);
275+
}
276+
277+
// BigInteger clamps rather than wraps when truncating to decimal, and so does PreciseNumber.
278+
Assert.AreEqual(decimal.MaxValue, Truncating<decimal, BigInteger>(BigInteger.Pow(10, 30)));
279+
}
280+
281+
[TestMethod]
282+
public void BuiltInCreateCheckedReachesPreciseNumber()
283+
{
284+
Assert.AreEqual(1.5, double.CreateChecked(P("1.5")));
285+
Assert.AreEqual(12, int.CreateChecked(P("12.75")));
286+
Assert.AreEqual(1234.5678m, decimal.CreateSaturating(P("1234.5678")));
287+
Assert.AreEqual(BigInteger.One, BigInteger.CreateTruncating(P("1.9")));
288+
}
289+
290+
[TestMethod]
291+
public void ToUsesTheSameConversions()
292+
{
293+
Assert.AreEqual(12, P("12.9").To<int>());
294+
Assert.AreEqual(
295+
double.Parse("3.14159265358979323846264338327950288419716939937510582097494", NumberStyles.Float, CultureInfo.InvariantCulture),
296+
P("3.14159265358979323846264338327950288419716939937510582097494").To<double>());
297+
}
298+
}

0 commit comments

Comments
 (0)