Skip to content

[major] Make PreciseNumber a value type - #70

Merged
matt-edmondson merged 1 commit into
mainfrom
precise-number-value-type
Sep 13, 2026
Merged

matt-edmondson merged 1 commit into
mainfrom
precise-number-value-type

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Summary

PreciseNumber is now a readonly record struct, and generic math conversion works. Arithmetic results stop allocating a wrapper object, and T.CreateChecked and double.CreateChecked succeed where they used to throw NotSupportedException. That second change is what lets PreciseNumber be the storage type of a Semantics quantity.

Changes

  • Value type. default(PreciseNumber) already equals Zero, because a zero significand, zero exponent, and zero digit count is exactly what Zero holds. A test pins it.
  • Conversions. TryConvertFrom* and TryConvertTo* in checked, saturating, and truncating modes cover every BCL numeric type and BigInteger, and return false for anything else. Conversion from double keeps the shortest round-trip text, so T.CreateChecked(0.3048) is exactly 0.3048. Conversion to double, float, and Half is correctly rounded.
  • Edge cases. NaN in checked mode throws OverflowException, and in saturating and truncating modes yields zero. Infinity throws in every mode. Both follow BigInteger.
  • Docs. README.md, CLAUDE.md, and a new docs/migration-guide-2.0.md.

Breaking changes

  • The type can no longer be derived from. The copy constructor and As<T>() are removed.
  • Members that were protected internal are now internal.
  • Equals, CompareTo, and the TryParse out parameters take PreciseNumber rather than PreciseNumber?. CompareTo(object) returns 1 for null.
  • To<int>() truncates toward zero through the new conversions, so 12.9 yields 12 where it used to yield 0.

Performance

Allocation is the reliable part of the local measurements, because they ran with about 1 GB of memory free.

Operation Allocated before → after (8 / 30 / 200 digits)
Add 104 → 64 / 120 → 80 / 264 → 224 B
Multiply 72 → 32 / 96 → 56 / 232 → 192 B
Divide 192 → 152 / 240 → 200 / 568 → 528 B
Negate, sanitizing construction, FromInt32, FromDouble 40 → 0 B

At 200 digits, Add, Multiply, and Divide also got two to three times faster. At 8 digits the local timings got slower, and it isn't yet clear whether that's noise or the cost of copying a 24-byte struct. Clean runs of the Benchmarks workflow on main and on this branch will be linked in a comment before merging.

Testing

  • 253 tests pass in Release and Debug across net7.0 to net10.0, with no warnings.
  • New tests cover conversions in every mode for every BCL numeric type, zero allocation for small values, and default == Zero.
  • dotnet pack package validation fails with the same 148 errors as main, from stale CompatibilitySuppressions.xml entries. This change doesn't add any.

Follow-up

  • SignificantNumber inherits from PreciseNumber, so it needs a matching change before it moves to 2.0.
  • Semantics can ship a Semantics.Quantities.Precise alias package once this version publishes.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K5Bk9UjGdGUtC5C6qK5ZxD

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<T>()
  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<int>() truncates toward zero, so 12.9 yields 12 where it used to yield 0.

Claude-Session: https://claude.ai/code/session_01K5Bk9UjGdGUtC5C6qK5ZxD
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 7a05840 into main Sep 13, 2026
14 checks passed
@matt-edmondson
matt-edmondson deleted the precise-number-value-type branch September 13, 2026 08:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant