Skip to content

Add static abstract Info and obsolete instance QuantityInfo on .NET 5+#1658

Open
angularsen wants to merge 1 commit intomasterfrom
claude/static-abstract-info-and-obsolete-quantityinfo
Open

Add static abstract Info and obsolete instance QuantityInfo on .NET 5+#1658
angularsen wants to merge 1 commit intomasterfrom
claude/static-abstract-info-and-obsolete-quantityinfo

Conversation

@angularsen
Copy link
Copy Markdown
Owner

Summary

Builds on top of #1657 (revert of the IQuantity.UnitInfo DIM) with the bigger ergonomics + perf shift @lipchev called for in his review. Base this PR on #1657 and rebase once it merges.

  • Adds a static abstract Info member on IQuantityOfType<TQuantity> and IQuantity<TSelf, TUnitType> (under #if NET).
  • Marks the existing instance QuantityInfo property as [Obsolete] on .NET 5+ across all three interfaces (IQuantity, IQuantity<TUnitType>, IQuantity<TSelf, TUnitType>). Kept un-obsoleted on netstandard2.0 to preserve back-compat there.
  • Adds GetQuantityInfo() extension methods (non-generic + typed) on QuantityExtensions so callers have a single discoverable API on every TFM.
  • Migrates internal callers of the obsolete instance member to either TSelf.Info (where the generic constraint allows) or quantity.GetQuantityInfo() (where it doesn't).

Why

The instance QuantityInfo property invariably returns a per-type static value. Exposing it as an instance member:

  1. Implies it can vary per instance, which it cannot.
  2. Costs interface dispatch and boxes the receiver on struct quantities for every call.
  3. Encourages custom IQuantity implementors to override it in ways that silently diverge from the rest of the library, which only ever consults QuantityInfo indirectly through the static TSelf.Info (via UnitsNetSetup, etc.).

The static abstract member lets generic algorithms reach the info with TSelf.Info directly — no boxing, no virtual call. The extension method covers the common IQuantity / IQuantity<TUnit> reference case via a UnitsNetSetup.Default.Quantities lookup.

Implementation notes

  • Generated quantities already expose public static QuantityInfo<TSelf, TUnitType> Info { get; }. This directly satisfies the typed static abstract — no codegen change required.
  • The non-generic IQuantityOfType<TSelf>.Info is satisfied by a default static implementation in the typed interface:
    static QuantityInfo IQuantityOfType<TSelf>.Info => TSelf.Info;
  • Bridges like QuantityInfo IQuantity.QuantityInfo => QuantityInfo; inside the interface itself are wrapped in #pragma warning disable CS0618 so the bridge implementation does not warn about consuming the obsolete member it bridges.
  • Callers that must work for custom quantities not registered in UnitsNetSetup.Default (JsonNet serialization, the debugger proxy, QuantityTypeConverter) keep using the instance member with a #pragma warning disable CS0618 and a comment explaining why. The lookup-based extension method would throw QuantityNotFoundException for these scenarios.
  • HowMuch test custom quantity had public static readonly QuantityInfo Info = ... (a field). Static abstract property requires a property — changed to public static QuantityInfo Info { get; }.

Migration path

Caller has .NET 5+ netstandard2.0
IQuantity q.GetQuantityInfo() q.GetQuantityInfo() (delegates to instance)
IQuantity<TUnit> q.GetQuantityInfo() (typed return) q.GetQuantityInfo()
TQuantity : IQuantityOfType<T> TQuantity.Info (no boxing) q.GetQuantityInfo()
TSelf : IQuantity<TSelf, TUnit> TSelf.Info (typed, no boxing) q.QuantityInfo

The instance member stays warning-only this release. Promote to error / remove once netstandard2.0 is dropped — possibly backport the obsolete to v5 if we decide to fully remove in v6.

Tests

  • GetQuantityInfo_NonGeneric_ReturnsRegisteredQuantityInfoSame(Mass.Info, ((IQuantity)mass).GetQuantityInfo())
  • GetQuantityInfo_Typed_ReturnsRegisteredQuantityInfo — typed variant returns QuantityInfo<MassUnit>
  • StaticAbstract_Info_ReturnsSameAsTypedInfoTSelf.Info via IQuantity<TSelf, TUnit> static abstract
  • StaticAbstract_Info_NonGeneric_ReturnsSameAsTypedInfoTQuantity.Info via IQuantityOfType<TQuantity> static abstract (covariant return through default impl bridge)

Test plan

  • dotnet build UnitsNet.slnx — 0 errors across all TFMs
  • dotnet test UnitsNet.Tests -f net8.0 — 42303 passed (4 new), 0 failed
  • dotnet test UnitsNet.Serialization.JsonNet.Tests -f net8.0 — 113 passed, 0 failed
  • No IQuantity.QuantityInfo CS0618 warnings remain in UnitsNet/, UnitsNet.Serialization.JsonNet/, UnitsNet.Benchmark/, Samples/ (suppressions are explicit + commented)

Out of scope

  • Migrating the test project .QuantityInfo call sites (suppressed at csproj level with <NoWarn>CS0618</NoWarn>).
  • A struct-friendly GetQuantityInfo<TSelf, TUnit>(this TSelf q) where TSelf : IQuantity<TSelf, TUnit> overload that would JIT-specialize per struct. Easy to add later if a perf-sensitive use case shows up.

🤖 Generated with Claude Code

@lipchev
Copy link
Copy Markdown
Collaborator

lipchev commented May 1, 2026

I'm not saying this is the "right path forward" (I honestly don't know) - but back when I was "rationalizing the existence" of the QuantityInfo.From methods (other than for the netstandard2.0 use-case) I was toying with the idea of actually storing the QuantityInfo as an instance member of the quantity itself (I'm sure you've thought of it as well).

This is similar in spirit to the MoneyContext used by Node.Money.

Obviously, such a property couldn't go into the standard json format (the two sides would need to use compatible quantity-configurations).

@angularsen angularsen requested a review from lipchev May 1, 2026 21:50
@angularsen
Copy link
Copy Markdown
Owner Author

These changes are straight from the bot, but I just wanted to get your eyes on the direction.

I am also pondering the removal of the IQuantity.QuantityInfo instance member.

On one hand, it feels more correct to enforce a single QuantityInfo instance per quantity type. I don't see a good use case for two Mass values having different QuantityInfo instances. It's just simpler.

On the other hand, instance members work with netstandard and a reference pointer is cheap enough.

What are the benefits you see of keeping it an instance member?

@lipchev
Copy link
Copy Markdown
Collaborator

lipchev commented May 1, 2026

  1. No breaking changes (that was main motivator at the time)
  2. Adding custom units / customizing existing ones - right now this is still in the hacky stage, but technically possible. The downside is that unless you do it globally (on the default config), you will get in trouble when using something like myCustomMass + Mass.Zero (when myCustomMass is using a UnitKey that isn't in the default config).

@claude
Copy link
Copy Markdown

claude Bot commented May 2, 2026

Code Review

Overview

Adds static abstract Info members to IQuantityOfType<T> and IQuantity<TSelf, TUnitType> on .NET 5+, obsoletes the instance QuantityInfo property, and provides GetQuantityInfo() extension methods as the migration path.

Breaking Changes

  • Soft break on .NET 5+: IQuantity.QuantityInfo and related overrides are now [Obsolete] on .NET 5+. CS0618 warnings will appear for callers.
  • Custom quantity implementations: A public static readonly QuantityInfo Info field no longer satisfies the static abstract property constraint. External custom quantities will get a compilation error and must change to public static QuantityInfo Info { get; }, as shown in HowMuch.cs. Worth calling out in release notes.

Design

Good overall. A few observations:

  1. GetQuantityInfo() throws for unregistered quantities - the extension method routes through UnitsNetSetup.Default.Quantities.GetQuantityInfo() and throws QuantityNotFoundException for custom types not registered with the setup. The PR correctly keeps the instance property (with #pragma suppression + comment) in the three places that must handle unregistered quantities: AbbreviatedUnitsConverter, UnitsNetBaseJsonConverter, and QuantityDisplay.

  2. Potential regression in QuantityExtensions - As(UnitSystem) and ArithmeticMean switch to GetQuantityInfo() under #if NET, which will throw for unregistered custom quantities where the old instance-property path silently worked. Consider keeping those on the instance property with #pragma suppression, consistent with the serialization paths.

  3. LinearQuantityExtensions / LogarithmicQuantityExtensions - switching Sum, ArithmeticMean, GeometricMean to TQuantity.From(...) under #if NET is the right call for performance (no boxing, static dispatch). Same unregistered-custom-quantity concern applies, but less likely to matter since these are constrained generic methods.

  4. Bridge implementation - static QuantityInfo IQuantityOfType<TSelf>.Info => TSelf.Info; inside IQuantity<TSelf, TUnitType> is clean. The covariant return works because out TQuantity makes IQuantityOfType<T> covariant.

Tests

Four new tests in IQuantityTests.cs are well structured. Minor suggestion: add a test that verifies GetQuantityInfo() throws QuantityNotFoundException for an unregistered custom quantity, to document and pin that behavior.

Summary

Solid improvement that eliminates per-instance boxing and virtual dispatch on QuantityInfo for .NET 5+ callers. The main concern is whether QuantityExtensions.As(UnitSystem) and ArithmeticMean should guard against unregistered custom quantities the same way the serialization paths do.

angularsen added a commit that referenced this pull request May 2, 2026
…TFMs (#1657)

## Summary

Reverts the default interface members added in #1649 and exposes
`GetUnitInfo()` as extension methods on all target frameworks instead.
The typed overload returns the most specific `UnitInfo<TQuantity,
TUnit>` when overload resolution can infer both type parameters from the
receiver.

## Rationale

Per @lipchev's [review feedback on
#1649](#1649 (comment)):

- **Lean interface.** The DIM is just a getter over
`QuantityInfo[UnitKey]` — no new state, sets a precedent for further
interface bloat (`Dimensions` etc.).
- **Semantic trap.** A custom `IQuantity` implementor overriding
`UnitInfo` would silently diverge from internal lookups
(`UnitConverter`, `UnitAbbreviationsCache`, `QuantityFormatter`) which
all go through `QuantityInfo`, not the new property.
- **Boxing on structs.** Calling `Mass.Zero.UnitInfo` through
`IQuantity` on a struct quantity boxes the receiver per call; concrete
quantities don't override the property so the DIM path is the only one
available.

The extension method gives the same call-site ergonomics
(`quantity.GetUnitInfo()`) on every TFM with none of these issues.

## Overload resolution

| Caller has         | Resolved overload | Return type |
|--------------------|-------------------|-------------|
| `Length q;` | `GetUnitInfo<TQuantity, TUnit>(IQuantity<TQuantity,
TUnit>)` | `UnitInfo<Length, LengthUnit>` |
| `IQuantity q;`        | `GetUnitInfo(IQuantity)` | `UnitInfo` |
| `IQuantity<TUnit> q;` | `GetUnitInfo(IQuantity)` (fallback) |
`UnitInfo` |

The `IQuantity<TUnit>` reference case loses its typed return —
`IQuantity<TUnit>` does not satisfy the `IQuantity<TQuantity, TUnit>`
constraint, so resolution falls back to the non-generic overload. That
scenario is uncommon compared to the concretely-typed receiver case,
which is now strictly better than before (returns `UnitInfo<TQuantity,
TUnit>` instead of `UnitInfo<TUnit>`).

## Follow-up

PR #1658 stacks on top of this branch: adds `static abstract Info` on
`IQuantityOfType<T>` / `IQuantity<TSelf, TUnitType>`, obsoletes the
instance `QuantityInfo` member, and provides matching
`GetQuantityInfo()` extensions. Out of scope for this PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Base automatically changed from claude/revert-iquantity-unitinfo-dim to master May 2, 2026 17:38
Per discussion on #1657, this introduces a static abstract `Info` member
on the IQuantityOfType<TQuantity> and IQuantity<TSelf,TUnitType>
interfaces under #if NET, marks the existing instance QuantityInfo
property as [Obsolete] on .NET 5+, and adds GetQuantityInfo() extension
methods on QuantityExtensions so callers have a single discoverable API
that works on every TFM.

Why
- The instance QuantityInfo property invariably returns a per-type
  static value. Exposing it as an instance member implies it can vary
  per instance, which it cannot, and incurs interface dispatch (boxing
  on structs) for every call.
- The static abstract member lets generic algorithms reach the info
  with `TSelf.Info` directly, no boxing, no virtual call.
- The extension method pair (`GetQuantityInfo()` /
  `GetQuantityInfo<TUnit>()`) is the discoverable replacement for
  callers that only have an `IQuantity` reference. It looks the
  quantity up via `UnitsNetSetup.Default.Quantities`.
- Keeping the instance property obsolete (warning) instead of removing
  it preserves source compatibility for existing callers and the
  netstandard2.0 contract. We can promote to error / remove once
  netstandard2.0 is dropped.

Implementation notes
- Generated quantities already expose `public static QuantityInfo<TSelf,
  TUnitType> Info { get; }`, which directly satisfies the typed static
  abstract. The non-generic `IQuantityOfType<TSelf>.Info` is satisfied
  by a default static implementation in IQuantity<TSelf,TUnitType>:
  `static QuantityInfo IQuantityOfType<TSelf>.Info => TSelf.Info;`.
  No codegen change required.
- The IQuantity bridge `QuantityInfo IQuantity.QuantityInfo =>
  QuantityInfo;` chain inside the interfaces uses #pragma to suppress
  the obsolete warning on the bridge itself.
- Internal callers in UnitsNet were migrated to either `TSelf.Info` /
  `TSelf.From` (where the generic constraint allows) or
  `quantity.GetQuantityInfo()` (where it doesn't). Callers that must
  keep working for custom quantities not registered in
  `UnitsNetSetup.Default` (JsonNet serialization, debugger proxy,
  QuantityTypeConverter) keep using the instance member with a
  `#pragma warning disable CS0618` and a comment explaining why.
- HowMuch test custom quantity changed `public static readonly` field
  to `public static QuantityInfo Info { get; }` property to satisfy the
  static abstract.
- Tests added for round-trip equivalence between `Mass.Info`,
  `mass.GetQuantityInfo()`, `TQuantity.Info` (via static abstract on
  IQuantityOfType<T>), and `TSelf.Info` (via static abstract on
  IQuantity<TSelf,TUnit>).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@angularsen angularsen force-pushed the claude/static-abstract-info-and-obsolete-quantityinfo branch from e3aee59 to 1067c30 Compare May 2, 2026 18:38
@claude
Copy link
Copy Markdown

claude Bot commented May 2, 2026

PR Review: Add static abstract Info and obsolete instance QuantityInfo on .NET 5+

Overview: Adds static abstract Info to IQuantityOfType<T> and IQuantity<TSelf, TUnit>, marks the instance QuantityInfo property as [Obsolete] on .NET 5+, and adds GetQuantityInfo() extension methods as the idiomatic replacement. Solid ergonomics and performance improvement.

Note: The PR description says it's based on #1657 — that must merge and this PR rebase before landing.


Positive

  • The static abstract design is correct: TSelf.Info on .NET avoids boxing and virtual dispatch on struct quantities
  • #pragma warning disable CS0618 suppressions are all explained with a comment — makes the intent clear
  • The #if NET / #else guards are consistent throughout and the netstandard2.0 path is preserved unchanged
  • Test coverage is good: all four GetQuantityInfo / static-abstract paths have a dedicated test
  • HowMuch.Info field → property change is the right fix for satisfying the static abstract contract

Concerns

Potential breaking change in GetUnitInfo(this IQuantity) extension:

// Before:
return quantity.QuantityInfo[quantity.UnitKey];

// After:
return quantity.GetQuantityInfo()[quantity.UnitKey];

GetQuantityInfo() performs a UnitsNetSetup.Default.Quantities.GetQuantityInfo(quantity.GetType()) lookup and throws QuantityNotFoundException for custom quantities not registered with UnitsNetSetup. The PR correctly keeps the instance property in serializers and the debug proxy, but GetUnitInfo is a public extension method that may be called on unregistered custom quantities. This is a behavioral breaking change worth calling out in the PR description or release notes.

Similarly, As<TQuantity>(this TQuantity, UnitSystem) now uses quantity.GetQuantityInfo() on .NET, which has the same issue.

GetQuantityInfo<TUnit>(this IQuantity<TUnit>) cast:

return (QuantityInfo<TUnit>)UnitsNetSetup.Default.Quantities.GetQuantityInfo(quantity.GetType());

The cast will throw InvalidCastException if a registered QuantityInfo for that type isn't QuantityInfo<TUnit>. Unlikely in practice but worth an as + descriptive throw, or a comment noting it's guaranteed safe for generated quantities.

Missing test: GetUnitInfo still works for unregistered custom quantities (or documents it doesn't):
The existing HowMuch custom quantity is registered in tests, so the new behavior for unregistered custom quantities calling GetUnitInfo / As(UnitSystem) isn't exercised.


Minor

  • IQuantity<TUnitType>.QuantityInfo obsolete attribute is #if NET — on netstandard2.0 there's no warning and callers won't be nudged to migrate. That's intentional per the PR description but worth a comment near the attribute saying "intentionally omitted on netstandard2.0."
  • The extension method XML docs are clear; the <remarks> note about using TSelf.Info directly for best performance is good practice.

Overall: The design is sound and the implementation is clean. The main concern is the subtle behavioral breaking change in the GetUnitInfo and As(UnitSystem) extensions for custom quantities. Either add pragma suppressions like the other safe-callers, or document the change in release notes.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 2, 2026

Codecov Report

❌ Patch coverage is 80.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 96%. Comparing base (60a875a) to head (1067c30).

Files with missing lines Patch % Lines
UnitsNet/QuantityDisplay.cs 0% 3 Missing ⚠️
Additional details and impacted files
@@          Coverage Diff           @@
##           master   #1658   +/-   ##
======================================
- Coverage      96%     96%   -1%     
======================================
  Files         450     450           
  Lines       29150   29154    +4     
======================================
+ Hits        28109   28112    +3     
- Misses       1041    1042    +1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.

2 participants