5.3 numeric and decimal: Exact Arithmetic and Scale
Right, let’s talk about numbers that don’t lie. You’ve probably bumped into the weirdness of floating-point arithmetic already—the infamous 0.1 + 0.2 != 0.3 nonsense. That’s because float and double are for speed and approximation, not for accounting. They’re for when you’re simulating a galaxy and a picometer here or there doesn’t matter. When you’re counting money, or doing anything that requires exact decimal representation, you need a different tool. You need decimal.
Why decimal is your financial savior
The decimal type is a high-precision, base-10 floating-point data type. I know, “floating-point” sounds scary, but stick with me. The key is base-10. We humans think in base-10. Our currency, our metrics, our everything is built on it. The decimal type stores its value as an integer, along with a scaling factor that says where the decimal point goes. This means it can exactly represent decimal fractions like 0.1, 0.2, and 0.3. No approximations. No rounding errors (unless you tell it to round, of course).
Think of it like this: a float is a scientist making a super precise guess. A decimal is an accountant with a ledger, meticulously writing down every single penny. The accountant is slower, but they are never, ever wrong.
The anatomy of a decimal
When you define a decimal, you’re working with a 128-bit structure that’s defined by three things:
- A 96-bit integer value: This is the significant digits.
- A scaling factor: A value between 0 and 28 that specifies how many decimal places the integer value should be divided by.
- A sign: A single bit indicating positive or negative.
So, the number 123.4567 is stored as the integer 1234567 with a scale factor of 4. It’s brilliantly simple and avoids the base-2 conversion nonsense that plagues binary floating-point types.
How to not shoot yourself in the foot: using decimal correctly
You have to use the m suffix. Always. The compiler will not guess for you, and if you forget, it will silently convert your beautiful, precise decimal number to a double and then back again, potentially introducing errors before you even start. Don’t let it.
decimal preciseMoney = 123.50m; // Correct. Do this.
decimal willThisBePrecise = 123.50; // NOPE. This is a double being cast to decimal. Avoid!
Taming the scale: precision and rounding
This is where decimal gets interesting. Because it’s exact, operations maintain precision. Multiply 10.0m * 20.0m and you get 200.00m—it remembers the two decimal places. This is great until you chain operations and end up with a number like 123.456000000000000000000000000m with 28 zeros trailing behind it. The decimal type will happily carry all that precision, which is often overkill.
This is why you must be proactive about rounding. The Math.Round method is your best friend here. You decide the number of decimal places you actually need for your use case.
decimal result = 10m / 3m; // result is 3.3333333333333333333333333333m
decimal usefulResult = Math.Round(result, 2); // usefulResult is now 3.33m
// Always specify MidpointRounding to avoid banker's rounding surprises.
// Do you want 4.5 to round to 4 or 5? Be explicit!
decimal midPoint = 4.5m;
decimal roundUp = Math.Round(midPoint, 0, MidpointRounding.AwayFromZero); // 5m
decimal roundToEven = Math.Round(midPoint, 0, MidpointRounding.ToEven); // 4m (the default)
Performance: the trade-off for perfection
Let’s be direct: decimal is slower and consumes more memory than double. It’s a 128-bit type versus a 64-bit one. The arithmetic operations are more complex because they’re handling that scale factor on every operation. You wouldn’t use it for a real-time physics engine calculating a million collisions a second. You use it for the end-of-level screen where you tally the exact score and prize money. Choose the right tool for the job.
The gotchas: overflow and division
The range of decimal is huge (±~10²⁸), but it’s not infinite. If your calculations exceed this, you’ll get an OverflowException. Also, division by zero is still division by zero. decimal won’t save you from that; it throws the same DivideByZeroException as any other number type.
decimal huge = decimal.MaxValue;
decimal tooHuge = huge + 1; // OverflowException. It's exact, not approximate, so it can't wrap.
decimal disaster = 10m / 0m; // DivideByZeroException. Some things are universally illegal.
In summary, reach for decimal whenever exactitude matters—finance, exact conversions, and any calculation where the rule is “what you see is what you get.” For everything else—scientific data, graphics, high-performance simulations—the raw speed of double is still your go-to. Just don’t come crying to me when 0.1 + 0.2 gives you 0.30000000000000004. I told you so.