|
| 1 | +--- |
| 2 | +title: ".Net Decimals are Weird" |
| 3 | +date: 2023-11-14 09:00:00 +1200 |
| 4 | +tags: [json-node, oddity] |
| 5 | +toc: true |
| 6 | +pin: false |
| 7 | +--- |
| 8 | + |
| 9 | +I've discovered another odd consequence of what is probably fully intentional code: `4m != 4.0m`. |
| 10 | + |
| 11 | +Okay, that's not strictly true, but it does seem so if you're comparing the values in JSON. |
| 12 | + |
| 13 | +```C# |
| 14 | +var a = 4m; |
| 15 | +var b = 4.0m; |
| 16 | + |
| 17 | +JsonNode jsonA = a; |
| 18 | +JsonNOde jsonB = b; |
| 19 | + |
| 20 | +// use .IsEquivalentTo() from Json.More.Net |
| 21 | +Assert.True(jsonA.IsEquivalentTo(jsonB)); // fails! |
| 22 | +``` |
| 23 | + |
| 24 | +What?! |
| 25 | + |
| 26 | +_This took me so long to find..._ |
| 27 | + |
| 28 | +## What's happening ([brother](https://www.youtube.com/watch?v=tvjrSU9RaPs)) |
| 29 | + |
| 30 | +The main insight is contained in [this StackOverflow answer](https://stackoverflow.com/a/13770183/878701). `decimal` has the ability to retain significant digits! Even if those digits are expressed in code!! |
| 31 | + |
| 32 | +So when we type `4.0m` in C# code, the compiler tells `System.Decimal` that the `.0` is important. When the value is printed (e.g. via `.ToString()`), even without specifying a format, you get `4.0` back. And this includes when serializing to JSON. If you debug the code above, you'll see that `a` has a value of `4` while `b` has a value of `4.0`. Even before it gets to the `JsonNode` assignments. |
| 33 | + |
| 34 | +While this doesn't affect _numeric_ equality, it could affect equality that relies on the string representation of the number (like in JSON). |
| 35 | + |
| 36 | +## How this bit me |
| 37 | + |
| 38 | +In developing a new library for [JSON-e](https://json-e.js.org/) support (spoiler, I guess), I found a test that was failing, and I couldn't understand why. |
| 39 | + |
| 40 | +I won't go into the full details here, but JSON-e supports expressions, and one of the tests has the expression `4 == 3.2 + 0.8`. Simple enough, right? So why was I failing this? |
| 41 | + |
| 42 | +When getting numbers from JSON throughout all of my libraries, I chose to use `decimal` because I felt it was more important to support JSON's arbitrary precision with `decimal`'s higher precision rather than using `double` for a bit more range. So when parsing the above expression, I get a tree that looks like this: |
| 43 | + |
| 44 | +``` |
| 45 | + == |
| 46 | + / \ |
| 47 | + 4 + |
| 48 | + / \ |
| 49 | + 3.2 0.8 |
| 50 | +``` |
| 51 | + |
| 52 | +where each of the numbers are represented as `JsonNode`s with `decimals` underneath. |
| 53 | + |
| 54 | +When the system processes `3.2 + 0.8`, it gives me `4.0`. As I said before, numeric comparisons between `decimal`s work fine. But in these expressions, `==` doesn't compare just numbers; it compares `JsonNode`s. And it does so using my `.IsEquivalentTo()` extension method. |
| 55 | + |
| 56 | +## What's wrong with the extension? |
| 57 | + |
| 58 | +When I built the extension method, I already had one for `JsonElement`. (It handles everything correctly, too.) However `JsonNode` doesn't always store `JsonElement` underneath. It can also store the raw value. |
| 59 | + |
| 60 | +This has an interesting nuance to the problem in that if the `JsonNode`s are parsed: |
| 61 | + |
| 62 | +```C# |
| 63 | +var jsonA = JsonNode.Parse("4"); |
| 64 | +var jsonB = JsonNode.Parse("4.0"); |
| 65 | + |
| 66 | +Assert.True(jsonA.IsEquivalentTo(jsonB)); |
| 67 | +``` |
| 68 | + |
| 69 | +the assertion passes because parsing into `JsonNode` just stores `JsonElement`, and the comparison works for that. |
| 70 | + |
| 71 | +So instead of rehashing all of the possibilities of checking strings, booleans, and all of the various numeric types, I figured it'd be simple enough to just `.ToString()` the node and compare the output. |
| 72 | + |
| 73 | +And it worked... until I tried the expression above. For **18 months** it's worked without any problems. Such is software development, I suppose. |
| 74 | + |
| 75 | +## It's fixed now |
| 76 | + |
| 77 | +So now I check explicitly for numeric equality by calling `.GetNumber()`, which checks all of the various .Net number types returns a `decimal?` (null if it's not a number). |
| 78 | + |
| 79 | +There's a new package available for those impacted by this (I didn't receive any reports). |
| 80 | + |
| 81 | +And that's the story of how creating a new package to support a new JSON functionality showed me how 4 is not always 4. |
0 commit comments