Skip to content

UnitAbbreviationsCache.CreateEmpty should use the default QuantityInfoLookup #1548

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged

Conversation

lipchev
Copy link
Collaborator

@lipchev lipchev commented Apr 18, 2025

  • UnitAbbreviationsCache: refactored the constructors / Create factory methods, with the Map/Get methods throwing an exception for missing units / unmapped abbreviations
  • UnitAbbreviationsCache: replaced the string[] return types with IReadOnlyList (breaking change for .Length which now becomes .Count)
  • UnitAbbreviationsCache: removed the overload accepting a UnitInfo
  • UnitParser: optimized the unit-parsing-with-fallback-culture methods, making the FromUnitAbbreviation behavior consistent with that of ParseUnit
  • UnitParser: UnitInfo GetUnitFromAbbreviation(string unitAbbreviation, IFormatProvider? formatProvider) : flipped the parameters and made the method public (this is optional)
  • optimized the Mass.ParseUnit calls (avoiding the UnitInfo lookups)
  • completed the coverage of the UnitParser and UnitAbbreviationsCache

fixes #1509

…ory methods, with the Map/Get now throwing an exception for missing units / unmapped abbreviations

- `UnitParser`: optimized the unit-parsing-with-fallback-culture methods, making the `FromUnitAbbreviation` behavior consistent with that of `ParseUnit`
- optimzed the  `Mass.ParseUnit` calls (avoiding the `UnitInfo` lookups)
@lipchev
Copy link
Collaborator Author

lipchev commented Apr 18, 2025

@angularsen There are still some tests missing but I'm on my way out- will try to add them when I get back. Please have a look at the remaining TODOs and tell me if you agree with my comments.

Copy link
Owner

@angularsen angularsen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this looks very good, just a few suggestions. Just merge when ready.

}

[Benchmark]
public string WithSpecificQuantityWithoutLookup()
{
var cache = new UnitAbbreviationsCache([Mass.Info]);
return cache.GetAbbreviations(Mass.Info.BaseUnitInfo)[0];
return Mass.GetAbbreviation(Mass.BaseUnit);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand what this is testing? cache is never used, so we test both constructing a new local UnitAbbreviationsCache instance + a lookup of the global default UnitAbbreviationsCache?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Originally I was using this overload for testing the initialization of the UnitAbbreviationsCache, as it avoids the call to the QuantityInfoLookup but now that the Mass.GetAbbreviation call does it instead (using the internal overload)- I could switch it over and hopefully remove the method (see the TODO - we shouldn't accept a UnitInfo as an argument, as it might not be the same UnitInfo that was registered earlier).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the overload (and it's one test) have been removed in f9a2766

Copy link
Collaborator Author

@lipchev lipchev Apr 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cache is never used

Oh I see, this isn't doing anything anymore.. I'll just remove them.

public UnitAbbreviationsCache()
: this(new QuantityInfoLookup([]))
:this(UnitsNetSetup.Default.QuantityInfoLookup)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of liked how the previous default ctor created an empty instance, and in order to create a new instance with defaults loaded you'd have to call CreateDefault(). Most people will use the singleton instance anyway, but it just seems more conventional to me.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal here is to have the list of QuantityInfos provided via the constructor, unlike before where a new UnitInfo was created when trying to get / map an abbreviation to a TUnit that doesn't exist in the QuantityInfoLookup.

The natural meaning IMO is that this creates an empty cache (meaning nothing is cached) using the default quantities (for mapping). In the 🐲 version I've got a few more constructor overloads, which should make it easier to construct / customize the default list.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay maybe I just don't understand it, to me it seems like we're getting a cache with all built-in unit abbreviations when calling new UnitAbbreviationsCache() ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but it's an empty cache, as one would expect from a call to new Cache()

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course it has always been an empty cache, it's just that before it was an empty cache and an empty list of quantities to map (which was problematic).

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and this is more a theoretical exercise to challenge the design, but it seems nice to be able to use the pieces on their own without involving all the built-in unit definitions.

// For some reason, we don't want to initialize with HowMuch at this point
var cache = new UnitAbbreviationsCache();

// Instead add it later, in one of two ways
cache.AddQuantity(HowMuch.Info);
cache.Quantities.Add(HowMuch.Info);

// Then map the abbreviations
cache.MapUnitToDefaultAbbreviation(HowMuchUnit.Some, "sum");

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess it comes down to that it feels more intuitive to get this behavior from the default ctor, than having to call CreateEmpty().

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, that would be very difficult to accomplish.. Unless you want to add a message bus or something 😄. Of course we could have an assumption that the user would call AddQuantity before attempting to Map or Get it but again- that would make it impossible to use the FrozenDictionary, not to mention the concurrency issues once again, please let's not.. (insert meme here).

The UnitParser is now really lightweight, the user should be able to create one on the fly, but for the initial mapping (of the DefaultAbrreviations) I really hope that we could use something like UnitsNet.Configure(c=> ... add custom stuff here) at the app start.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, this is not an important point for me. I get that the new design forces you to either use the default ctor, with built-in quantities, or pass your own quantities, and that it becomes immutable at that point. That's fine.

We'll still need to accomodate adding custom quantities at runtime for the default singleton though, but this should be doable either by calling something early on startup as you say or swapping the immutable instance with a new one.

internal static bool HasFallbackCulture(CultureInfo culture)
{
// accounting for the fact that we're using the same abbreviations for both "en-US" and the "Invariant" culture (Name == string.Empty)
return culture.Name != string.Empty && culture.Name != FallbackCulture.Name;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, !Equals(culture, CultureInfo.InvariantCulture) is more explicit.

The culture.Name != FallbackCulture.Name must stay though, since the name is what matters and not the culture info configuration.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing I don't quite get, FallbackCulture is InvariantCulture. So doesn't this really just check twice that the given culture is not invariant culture?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see there's some quirks regarding this here:

        private static AbbreviationMapKey GetAbbreviationMapKey(UnitInfo unitInfo, CultureInfo culture)
        {
            // In order to support running in "invariant mode" (DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1) the FallbackCulture is set to the InvariantCulture.
            // However, if we want to avoid having two entries in the cache ("", "en-US"), we need to map the invariant culture name to the primary localization language.
            return new AbbreviationMapKey(unitInfo.UnitKey, culture.Name == string.Empty ? "en-US" : culture.Name);
        }

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion:

private const string DefaultCultureName = "en-US";

internal static bool HasFallbackCulture(CultureInfo culture)
{
    return !culture.Equals(CultureInfo.InvariantCulture) && culture.Name != DefaultCultureName;
}

And reuse DefaultCultureName in GetAbbreviationMapKey.

It still doesn't feel clean, there's something weird about this whole FallbackCulture being InvariantCulture, and then mapped to en-US, but I don't have a better suggestion right now.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, forgot about this one: culture.Equals is a bit more expensive than simply checking the name:

  public override bool Equals([NotNullWhen(true)] object? value)
  {
    if (this == value)
      return true;
    return value is CultureInfo cultureInfo && this.Name.Equals(cultureInfo.Name) && this.CompareInfo.Equals((object) cultureInfo.CompareInfo);
  }

and I think there is a potential risk for key-duplication if the user uses a custom culture (derived from CultureInfo.InvariantCulture).

As for the constant- I tried it, but it looked weird without the accompanying comment (which got even more vague when the string value got hidden by the constant name 😄 ).

PS This looks weird to me too, but I don't see how to improve it..

lipchev added 3 commits April 19, 2025 00:06
…e return types to IReadOnlyList

- UnitParser: refactored the common parts of the GetUnitFromAbbreviation code
- completed the test coverage for the UnitParser and the UnitAbbreviationsCache
if (abbreviationIndex >= abbreviations.Length)
if (abbreviationIndex >= abbreviations.Count)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the unfortunate side effect of moving away from the arrays, but there is no way around it..

I'm merging it..

@lipchev lipchev merged commit 7244e04 into angularsen:master Apr 19, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

No fall-back culture when using UnitAbbreviationsCache.CreateEmpty
2 participants