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.
- Remove
nullchecks andnullassignments forPreciseNumbervalues. APreciseNumber?now meansNullable<PreciseNumber>. - Replace any type that derives from
PreciseNumberwith one that holds aPreciseNumber. - Replace calls to
As<TOutput>()and the copy constructor. - Check the
TryParsefailure path, which now yields zero instead ofnull. It also returnsfalseinstead of throwingOverflowExceptionwhen the exponent is outside the range ofint. - Check calls to
To<T>()that convert a fractional value to an integer type, which now truncate instead of returning zero. - Check expectations for
ToPreciseNumber()on adoubleorfloat, which keeps the shortest digits that round-trip instead of 16 or 8 significant digits.
A record class allocated an object for every result, and every operator produced one. As a readonly record struct, the number lives inline in its variable, field, or array element, and the only heap allocation left is the BigInteger digit array. A significand that fits in an int doesn't need one, so adding, subtracting, multiplying, and comparing allocate nothing when the operands and every intermediate and final significand fit in an int. Exponent alignment counts, so 1 + 0.0000000001 allocates because it scales 1 by 10^10, and 99999 * 99999 allocates because its product is 9,999,800,001. Division still allocates when the quotient repeats, because it computes at least 50 digits.
It also lets PreciseNumber satisfy where T : struct, INumber<T>, 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.
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.
// Was:
PreciseNumber? total = null;
if (total is null) { total = PreciseNumber.Zero; }
// Now:
PreciseNumber total = default; // zeroA PreciseNumber? still compiles, but it's now a Nullable<PreciseNumber>. Code that used ? to mean "might be null" should either drop the ? or use Nullable<PreciseNumber> deliberately.
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<TOutput>() |
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)andPreciseNumber(int exponent, BigInteger significand, bool sanitize)LowestDecimalDigits,LowestSignificantDigits, andCountDecimalDigitsMakeCommonizedandMakeCommonizedWithExponentAssertExponentsMatchandInvariantCulture
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.
| 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, and threw OverflowException for an exponent outside the range of int |
out PreciseNumber result, zero on failure, and returns false for an exponent outside the range of int |
// Was:
if (PreciseNumber.TryParse(text, CultureInfo.InvariantCulture, out PreciseNumber? parsed)) { Use(parsed); }
// Now:
if (PreciseNumber.TryParse(text, CultureInfo.InvariantCulture, out PreciseNumber parsed)) { Use(parsed); }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.
static T ToMeters<T>(T feet) where T : INumber<T> => feet * T.CreateChecked(0.3048);
PreciseNumber meters = ToMeters(10.ToPreciseNumber()); // exactly 3.048
double asDouble = double.CreateChecked(meters); // 3.048Converting 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, float, or Half converts to the shortest decimal that round-trips, so converting back gives the original value. ToPreciseNumber() uses the same text. Through 2.0.1 both kept 16 significant digits for a double and 8 for a float, so double.MaxValue converted back to infinity and (0.1 + 0.2).ToPreciseNumber() was 0.3, where it's now 0.30000000000000004.
Converting from PreciseNumber:
| 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 |
To<T>() 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<int>() is 12, and To<double>() is correctly rounded however many digits the number has. A type that isn't built in still goes through the old calculation.