Skip to content
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

Preserve value and unit #389

Merged
merged 18 commits into from
Mar 5, 2018
Merged

Preserve value and unit #389

merged 18 commits into from
Mar 5, 2018

Conversation

angularsen
Copy link
Owner

@angularsen angularsen commented Jan 24, 2018

Motivation

Preserve the original value and unit and add getter properties Value and Unit to expose them.
Use Unit in ToString() instead of the base unit representation, to better match the units the user is working with.

This is a significant rewrite of how values are stored and how conversions are performed in quantity types. It is also the first baby steps of introducing some common values in base types #371 .

There should be no breaking changes in neither main lib nor JSON serialization.

Changes

Using example of Length, but this applies to all quantities:

  • Change private field _meters to _value (still double or decimal, as before)
  • Add private field LengthUnit? _unit
  • Add Value getter prop (double or decimal)
  • Add LengthUnit Unit getter prop, with fallback to base unit LengthUnit.Meter (1)
  • Ctors without unit param assumes BaseUnit, backwards compatible
  • Change ToString() to use Unit instead of DefaultToStringUnit, new test cases
  • Mark DefaultToStringUnit as obsolete
  • Add support for preserving decimal precision in QuantityValue (2)
  • Defer conversion to base unit until calling properties like .Centimeters (3)
  • Serialize Value+Unit instead of base unit and base unit value, backwards compatible, update test cases
  • Make internals of UnitsNet visible to Json to reuse reflection abstraction code
  • Rename variables in JSON serialization lib (use "quantity" term instead of "unit")
  1. Default ctor (struct) and ctors without unit param assumes base unit, to be backwards compatible. We might want to change this in the future to require explicitly specifying the unit.
  2. Needed to avoid precision issues going via double to construct decimal values.
  3. Getting the value of unit properties is now twice as slow, as it first converts from Unit to base unit, then to target unit. Before this change, the first conversion was initially made when constructing the quantity. However, there are optimizations in-place to avoid converting if Unit already matches either base unit or the target unit.

Gotchas

  • Serialization output changed (different value and unit for same quantity)
  • In Preserve value and unit #389 (comment) I discuss the choice to not go with breaking change with regards to unspecified unit, but why we may want to do so later to require explicitly specifying the unit

@angularsen angularsen force-pushed the angularsen/preserve-value-unit branch from 391be21 to 7dc80c9 Compare January 24, 2018 22:33
@angularsen angularsen changed the title Preserve value and unit Preserve value and unit (WIP) Jan 24, 2018
@angularsen
Copy link
Owner Author

Some compile errors still to fix, maybe hold off reviewing until fixed.

@angularsen angularsen force-pushed the angularsen/preserve-value-unit branch from 6c64e82 to 687c714 Compare January 24, 2018 22:58
@gojanpaolo
Copy link
Contributor

@angularsen 💯 This is an awesome feature.

Though, I'm curious to why the need to remove the constructors using just a number without the Unit? It's intuitive enough to make the BaseUnit be the default Unit if nothing is specified.

@angularsen
Copy link
Owner Author

angularsen commented Jan 24, 2018

@gojanpaolo I guess you spotted a breaking change 😇

However, I really don't like constructors taking a value and assuming the consumer just knows what the base unit is or reads it from the xmldoc. We have even changed base units for quantities at some point, so I would prefer to have the consumer explicitly state what unit to construct with - just as we do with the FromMeters() methods. It is very clear what you are creating.

Going with that thought, instead of removing the ctor it should be obsoleted and removed later.

Update: Ok, seems I already did obsolete and not remove any ctors just yet. I'm still open to keep them if people find it intuitive to specify just the numeric value.

@angularsen angularsen force-pushed the angularsen/preserve-value-unit branch 2 times, most recently from 876aed1 to 9be7770 Compare January 24, 2018 23:36
@angularsen angularsen changed the title Preserve value and unit (WIP) Preserve value and unit Jan 24, 2018
@angularsen
Copy link
Owner Author

It seems to compile now and is ready for review.

@gojanpaolo
Copy link
Contributor

gojanpaolo commented Jan 26, 2018

@angularsen Here's a couple that might seem to be an unexpected change in behavior compared to current release.

  1. GetHashCode returns False when Equals(...) is True
var length1 = Length.FromMeters(1);
var length2 = Length.FromCentimeters(100);
bool isTrue = Equals(length1, length2);
bool isFalse = length1.GetHashCode() == length2.GetHashCode(); //True in current release
  1. Default constructor of quantities produces invalid instances
double throwsException = new Length().Meters; //does not throw exception in current release

@angularsen
Copy link
Owner Author

angularsen commented Jan 30, 2018

@gojanpaolo Thanks!

GetHashCode returns False when Equals(...) is True

I don't consider this a breaking change. GetHashCode() is intended for hash sorts such as when using a quantity as the key in a dictionary entry. This should not be a behavior people depend on being fixed between versions, the main thing is that it should be reliable and consistent at runtime (not be based on mutable values for instance) - and it is.

Default constructor of quantities produces invalid instances

Sigh.. not sure exactly how to work around this. Struct don't allow us to specify the default values when using the default ctor. Another argument for class I guess.. Any ideas? We could have some fallback logic in the code that accesses the Unit value internally, so that it will always have some sane default (BaseUnit), but I'm not sure if it will give unpredictable behavior for the consumers.

Another option is to rethink this design and only store the base unit value as before, and store the constructed Unit for the purpose of using that as the default for ToString(). You do lose some precision since the values will be unnecessarily converted back and forth, but it wouldn't be any worse than it is today.

@gojanpaolo
Copy link
Contributor

@angularsen

For the default constructor, I'm also thinking of a fallback logic. something like:

//Length
private readonly LengthUnit? _unit;
public LengthUnit Unit => _unit ?? BaseUnit;

Another approach is to change the default Unit enum values to the BaseUnit instead of Undefined.

public enum LengthUnit
{
	Undefined = -1,
	Meter = 0,
	Centimeter,
	//...
}

@angularsen
Copy link
Owner Author

angularsen commented Jan 30, 2018

Right, the reason I'm on the fence of using nullables as fallback is that then we're back to storing things on the heap and adding pressure to the garbage collector. I think at that point we could just as well go for class.

Changing default from Undefined is interesting, a breaking change though. How about instead treating Undefined implicitly as BaseUnit for the purpose of conversions? It does kind of violate its design, where DID want things to complain if you incorrectly constructed stuff without specifying the unit, but right now that behavior is causing problems.

@gojanpaolo
Copy link
Contributor

gojanpaolo commented Jan 31, 2018

treating Undefined implicitly as BaseUnit for the purpose of conversions

Should an exception be thrown if Undefined is passed on the constructor parameter because it might be weird to get something like this:

var length = new Length(1, LengthUnit.Undefined);
double cm = length.Centimeters; //100

Looking back at the suggestions, I think this (below) is the better solution.

only store the base unit value as before, and store the constructed Unit for the purpose of using that as the default for ToString()

...that is until/unless we move from struct to class which may have better solutions.

@dmf07
Copy link

dmf07 commented Jan 31, 2018

Great work on this library! I have a need for maintaining the initial value and not necessarily the unit, although it makes sense to do that as well.

I don't think it makes sense for using Undefined as BaseUnit for conversions. Undefined is just that, Undefined, and should not guarantee a conversion. If you are going to use Undefined as the BaseUnit you might as well get rid of Undefined.

I think it would be OK to throw an error for Undefined Units, but currently that seems like a breaking change if a User was using the default behavior of the struct. I personally think you should construct your Unit of measure explicitly.

@angularsen
Copy link
Owner Author

angularsen commented Feb 3, 2018

@dmf07 I fully agree with all you said. I would not want anyone explicitly setting Undefined, that should throw.

However, the key problem is that we cannot implement code for the default ctor of structs. So, what should happen here in these examles? My intuitive answers in comments beside each, since Meter is the base unit of Length.

new Length().Value; // 0
new Length().Unit; // LengthUnit.Meter
new Length().ToString(); // "0 m"
new Length().ToString(LengthUnit.Centimeter); // "0 cm"

Assuming my intuition is valid here, we can achieve this by making the backend field of Unit property LengthUnit? as @gojanpaolo proposed earlier. See more about Nullable<T> below as I learned something new today.

Then Unit property becomes

public LengthUnit Unit => _unit.GetValueOrDefault(BaseUnit);

since we know it is ONLY the default struct ctor that could ever result in unit not being set. We also know it will never be Undefined and cause problems with conversions, since that is no longer the default and we throw for that value in other ctors.

Thoughts?

Nullable

So I assumed Nullable<T> would be a reference type and live on the heap and add GC pressure, but today I learned it is actually a struct and implemented pretty much this way; a value T and a bool for whether it has been set or not. Whether it lives on the stack or the heap is up to the CLR and depends on whether T is a value or reference type and if a value type it depends on the scope/lifetime of the value, as with any other value type.

Good answer from Eric Lippert himself
Source of Nullable.cs

@angularsen
Copy link
Owner Author

@dmf07 Just curious, what is your motivation for preserving the original value?

@dmf07
Copy link

dmf07 commented Feb 3, 2018

My motivation for preserving the original value is primarily for user input without having a separate value to keep track of.

As far as using Nullable for the backing value of the Unit, that could fix the issue of initializing the struct without context, but I am under the opinion that you shouldn't use a measurement without knowing explicitly what unit of measure it is. The Length example 0 length typically works in all cases.
But what do you pick for a base unit in temperature? 0 Kelvin? And what happens when you calculate the difference in temperature between one set to the default and one explicitly set? Granted users of the library have to due their due diligence. It's the difference between a bad calculation and no calculation, I would rather error on the side of caution and not give false information.

@angularsen
Copy link
Owner Author

angularsen commented Feb 3, 2018

Good point about temperature, where 0 Kelvins does not equal 0 Fahrenheit. In that case, yes, I would intuitively assume 0 Kelvins. If other users share that intuition in all scenarios? Probably not.

I get your point about staying on the conservative path and throw instead of trying to assume some meaningful unit when none was specified.

I think our options regarding default ctor boil down to this:

  1. Treat default ctor as Value = 0 and Unit = BaseUnit and allow to use the quantity as normal
    OR
  2. Throw exception on pretty much all methods and properties when Unit == Undefined, except
    2.1 Value returns 0
    2.2 Unit returns Undefined
    2.3 Parameterless ToString() either returns "0 undefined" or throws. If we throw, then we should add DebuggerDisplay attribute to aid in debugging.

I agree with you that option 2 is probably the better choice. This PR is currently implemented pretty much like this already, but not the ToString() special case. It is still a breaking change, for those who rely on the behavior of the default ctor.

Thoughts? Any other options?

@angularsen
Copy link
Owner Author

Pushed more commits that update serialization to serialize using the constructed value+unit pair, as well as supporting the new QuantityValueDecimal type introduced in this PR.
All tests are now green.

@angularsen angularsen force-pushed the angularsen/preserve-value-unit branch 4 times, most recently from 88e91cf to fd391d1 Compare February 11, 2018 12:41
@angularsen
Copy link
Owner Author

Some cleanup

@angularsen
Copy link
Owner Author

Thanks for the heads up @JKSnd, no problem!

@eriove
Copy link
Contributor

eriove commented Mar 5, 2018

I'm terribly sorry for not checking this in detail at an earlier stage.

Things I think are worth mentioning (so that they aren't overlooked but conscious choices):

  • The behavior of ToString() is very different when values are created with the operator overloads. Since almost all of these use the SI constructors anything that passes through an overload will be printed in SI. I don't have any good solution to this, it is mainly an observation.
  • The size of the struct has increased by an int. That means more memory copying every time a quantity is passed as an argument. It is probably still small enough for it not to matter.
  • The code for creating a new instance of a quantity is significant longer. Again I have no idea what this means in practice. Before it was very likely to be inlined. I don't think this will be the case after this change.

I will try to run some of our performance test/benchmarks with this code and see what the difference is.

I think that my use case is quite far from the typical so I'm not sure if that really matters for the change.

Impressed by the code quality for such a large commit.

@angularsen
Copy link
Owner Author

Thanks for taking a look, your insight is always appreciated especially in light of any performance/memory impact. Benchmarks would be awesome, I have done very little of that at least to make an informed decision.

  • SI units with operator overloads: I'm fine with this, I don't see any other behavior that would be more natural at this point.
  • Size of struct: Right, this PR increases from 64 bits (double) to 64+32 bits (double + int). It might be an idea to go with byte (8 bit) or ushort (16 bit) backing type for the enum instead? We are nowhere near 256 units for any quantity yet, and can always bump it when we reach that.
  • Longer code to create quantity: Could you please elaborate? Are you referring to the method call stack of Length.FromMeters(1) or are you referring to the use of QuantityValue type with implicit casts and it now having both a double? and decimal? field? In the former case, creation should be more light-weight now (no initial conversion to base unit). In the latter case, yes the QuantityValue type is getting a lot heavier now (128 bit decimal+ 2 HasValue bits). I don't like that type at all, but it does solve a real problem with binary size...

@eriove
Copy link
Contributor

eriove commented Mar 5, 2018

After running the code with our internal projects I have the following notes:

  • Performance seems to be decreased by 10% in our current common use cases which seems reasonable. I would like to run some more test for some other pieces of code. But then I have to write the tests and I do not think I have that time.
  • The assembly is no longer CLS compliant. I believe this is an old regression. I can make a pull request and add a unit test for that if you'd like
  • The code size is twice of 3.69.0 (which was the one I used before, the breaking change to 3.70.0 will hold us from updating until we really need more units). It matters less to me personally but since we wanted to keep it small before it seems large. I haven't checked the size change since the previous version though.

I believe this is on the limit of what could be seen as a minor version, the underlying changes are large.

Again I want to stress that this is a great effort and that I'm sorry that I'm not contributing more.

@eriove
Copy link
Contributor

eriove commented Mar 5, 2018

Based on the performance tests I don't think we have to worry about most of my points. After discussing in Gitter we agreed to merge.

angularsen and others added 18 commits March 5, 2018 22:28
* Store Value/Unit upon construction with From() methods or ctor()
* Move conversion functions into As() and AsBaseUnit() methods
* Add QuantityValueDecimal to avoid precision-loss going via double
As fallback if no culture is specified. This allows
the user to specify a culture other than CurrentUICulture
and fixes tests where rely on that to test default
behavior.
This is to avoid a breaking change for this PR. We can later change this behavior on major version bump.
Access unit property instead of base unit value field.
Except in WinRTC, which does not support IFormatProvider type.
Store decimal values internally and use that when explicitly casting to decimal, to preserve its precision
when constructing quantities such as Power, Information and BitRate.
Test cases that covers the new behavior for using value/unit by default and
how the ToString() overloads with additional parameters affect the output.
Some of these tests probably overlap with existing tests in UnitSystemTests, but
will address that when moving those tests here.
Serialize with constructed Unit and _value instead of base unit and base unit value.
By using _value we serialize using the original value type (decimal vs double),
since UnitsNet public API is primarily using double.
Cast to QuantityValueDecimal for types that use decimal internally.

NOTE: This mix of double and decimal representations is a bit messy right
now and needs a better fix. We already discuss this topic in #285 about either
moving everything to decimal or to better support multiple numeric types
(float, double, long, decimal etc).
Fixes broken test due to previous change
As per @gojanpaolo 's comment: #389 (comment)
This will not throw when converting to double, since double has a big enough range to contain all other numeric types in .NET.
@angularsen angularsen force-pushed the angularsen/preserve-value-unit branch from d469707 to 13bb546 Compare March 5, 2018 21:29
@angularsen angularsen merged commit 5f2b075 into master Mar 5, 2018
@angularsen angularsen deleted the angularsen/preserve-value-unit branch March 5, 2018 22:21
@angularsen
Copy link
Owner Author

Nuget 3.92 on the way out.

@angularsen angularsen mentioned this pull request Sep 27, 2018
25 tasks
angularsen added a commit that referenced this pull request Dec 16, 2018
# 4.0.0 Release
This PR will serve as the list of items to complete and it will be updated to show the progress before finally merged into `master` when completed. We all have busy schedules, so **if you want to help move this work forward then that is much appreciated!** 

## The main theme is to reduce binary size
In two years it has grown from 280 kB to 1.4 MB and a lot of it is due to unnecessary syntactic sugar with many method overloads for various number types and nullable types - for every of our 800+ units! It simply adds up to a big total.

These items are chosen from #180, trying to include as many of the low hanging fruits as possible, while still keeping the list short and realistic to complete in a reasonably short time - we are all busy in our daily lives. We can always have more major version bumps later than trying to perfect it all now.

## Feature complete before November, merged before mid-December
I would like to aim for a "beta" pre-release nuget sometime in October with all the items completed, so we can have some time to test it before releasing the final, stable 4.0.0 version before Christmas holidays.
We should have the work items list more or less final before Monday, October 8th.

## Changes
#### Added
- [x] UnitSystem with a different meaning, now defines the base units for a unit system (#524)

#### Removed
- [x] Replace netstandard1.0 target with netstandard2.0, to avoid the extra dependencies (#477)
- [x] Remove code marked as `[Obsolete]`, such as `VolumeUnit.Teaspoon`  (#490)
- [x] Remove nullable `From` factory methods (#483)
- [x] Remove extension methods on _nullable_ number types (#483)
- [x] Remove all number extension methods (#497)
- [x] Remove `Length2d`, replaced by `Area` (#501)
- [x] Remove static methods on `UnitSystem`, should use `UnitSystem.Default` instead (#496)
- [x] Remove unit parameter from `ToString()` methods (#546)

#### Changed
- [x] Throw exception on NaN values in static constructor methods, like `FromMeters()` (#502, see #176 (comment))
- [x] Throw if unit was not specified when constructing quantities, (#499 - #389 (comment))
- [x] Stricter parsing (#343 and #180 (comment))
- [x] Remove unit parameter from `ToString()` methods (#546)
- [x] Change Temperature arithmetic back to use base unit Kelvin (#550, see #518)

#### Renamed
- [x] Correct SingularName for some Flow unit definitions (#494, see #360)
- [x] Split/rename UnitSystem into UnitParser, GlobalConfiguration, UnitAbbreviationsCache (#511)

#### Fixed
- [x] Search for any TODO comments in the code to address, remove comment and either fix or create issue (5d24432)
- [x] Update README with v4 changes #498
- [x] Do not try/catch in UnitConverter.Try-methods (#506)
- [x] Do not try/catch in `Length.TryParse()` and for other quantities (#507)
- [x] Add/update changelog in wiki for v4 with some summary copied from this issue, some v3 to v4 migration instructions and briefly explain why some stuff were removed

## Milestones
- [x] Finalize work items list (Monday, October 8)
- [x] Release 4.0.0-beta1, feature complete (Wednesday, October 31)
- [ ] ~Show release notes and upgrade guide when upgrading to 4.x nuget, if possible~
- [x] Release 4.0.0 (Monday, December 17)
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.

5 participants