Why is using a NON-decimal data type bad for money

2020-06-03 07:41发布

问题:

tl;dr: What's wrong with my Cur (currency) structure?

tl;dr 2: Read the rest of the question please, before giving an example with float or double. :-)


I'm aware that this question has come up numerous times before all around the internet, but I have not yet seen a convincing answer, so I thought I'd ask again.

I fail to understand why using a non-decimal data type is bad for handling money. (That refers to data types that store binary digits instead of decimal digits.)

True, it's not wise to compare two doubles with a == b. But you can easily say a - b <= EPSILON or something like that.

What is wrong with this approach?

For instance, I just made a struct in C# that I believe handles money correctly, without using any decimal-based data formats:

struct Cur
{
  private const double EPS = 0.00005;
  private double val;
  Cur(double val) { this.val = Math.Round(val, 4); }
  static Cur operator +(Cur a, Cur b) { return new Cur(a.val + b.val); }
  static Cur operator -(Cur a, Cur b) { return new Cur(a.val - b.val); }
  static Cur operator *(Cur a, double factor) { return new Cur(a.val * factor); }
  static Cur operator *(double factor, Cur a) { return new Cur(a.val * factor); }
  static Cur operator /(Cur a, double factor) { return new Cur(a.val / factor); }
  static explicit operator double(Cur c) { return Math.Round(c.val, 4); }
  static implicit operator Cur(double d) { return new Cur(d); }
  static bool operator <(Cur a, Cur b) { return (a.val - b.val) < -EPS; }
  static bool operator >(Cur a, Cur b) { return (a.val - b.val) > +EPS; }
  static bool operator <=(Cur a, Cur b) { return (a.val - b.val) <= +EPS; }
  static bool operator >=(Cur a, Cur b) { return (a.val - b.val) >= -EPS; }
  static bool operator !=(Cur a, Cur b) { return Math.Abs(a.val - b.val) < EPS; }
  static bool operator ==(Cur a, Cur b) { return Math.Abs(a.val - b.val) > EPS; }
  bool Equals(Cur other) { return this == other; }
  override int GetHashCode() { return ((double)this).GetHashCode(); }
  override bool Equals(object o) { return o is Cur && this.Equals((Cur)o); }
  override string ToString() { return this.val.ToString("C4"); }
}

(Sorry for changing the name Currency to Cur, for the poor variable names, for omitting the public, and for the bad layout; I tried to fit it all onto the screen so that you could read it without scrolling.) :)

You can use it like:

Currency a = 2.50;
Console.WriteLine(a * 2);

Of course, C# has the decimal data type, but that's beside the point here -- the question is about why the above is dangerous, not why we shouldn't use decimal.

So would someone mind providing me with a real-world counterexample of a dangerous statement that would fail for this in C#? I can't think of any.

Thanks!


Note: I am not debating whether decimal is a good choice. I'm asking why a binary-based system is said to be inappropriate.

回答1:

Floats aren't stable for accumulating and decrementing funds. Here's your actual example:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace BadFloat
{
    class Program
    {
        static void Main(string[] args)
        {
            Currency yourMoneyAccumulator = 0.0d;
            int count = 200000;
            double increment = 20000.01d; //1 cent
            for (int i = 0; i < count; i++)
                yourMoneyAccumulator += increment;
            Console.WriteLine(yourMoneyAccumulator + " accumulated vs. " + increment * count + " expected");
        }
    }

    struct Currency
    {
        private const double EPSILON = 0.00005;
        public Currency(double value) { this.value = value; }
        private double value;
        public static Currency operator +(Currency a, Currency b) { return new Currency(a.value + b.value); }
        public static Currency operator -(Currency a, Currency b) { return new Currency(a.value - b.value); }
        public static Currency operator *(Currency a, double factor) { return new Currency(a.value * factor); }
        public static Currency operator *(double factor, Currency a) { return new Currency(a.value * factor); }
        public static Currency operator /(Currency a, double factor) { return new Currency(a.value / factor); }
        public static Currency operator /(double factor, Currency a) { return new Currency(a.value / factor); }
        public static explicit operator double(Currency c) { return System.Math.Round(c.value, 4); }
        public static implicit operator Currency(double d) { return new Currency(d); }
        public static bool operator <(Currency a, Currency b) { return (a.value - b.value) < -EPSILON; }
        public static bool operator >(Currency a, Currency b) { return (a.value - b.value) > +EPSILON; }
        public static bool operator <=(Currency a, Currency b) { return (a.value - b.value) <= +EPSILON; }
        public static bool operator >=(Currency a, Currency b) { return (a.value - b.value) >= -EPSILON; }
        public static bool operator !=(Currency a, Currency b) { return Math.Abs(a.value - b.value) <= EPSILON; }
        public static bool operator ==(Currency a, Currency b) { return Math.Abs(a.value - b.value) > EPSILON; }
        public bool Equals(Currency other) { return this == other; }
        public override int GetHashCode() { return ((double)this).GetHashCode(); }
        public override bool Equals(object other) { return other is Currency && this.Equals((Currency)other); }
        public override string ToString() { return this.value.ToString("C4"); }
    }

}

On my box this gives $4,000,002,000.0203 accumulated vs. 4000002000 expected in C#. It's a bad deal if this gets lost over many transactions in a bank - it doesn't have to be large ones, just many. Does that help?



回答2:

Usually monetary calculations require exact results, not just accurate results. float and double types cannot accurately represent the whole range of base 10 real numbers. For instance, 0.1 cannot be represented by a floating-point variable. What will be stored is the nearest representable value, which may be a number such as 0.0999999999999999996. Try it out for yourself by unit testing your struct - for example, attempt 2.00 - 1.10.



回答3:

I'm not sure why you're shrugging off J Trana's answer as irrelevant. Why don't you try it yourself? The same example works with your struct too. You just need to add a couple extra iterations because you're using a double instead of a float, which gives you a bit more precision. Just delays the problem, doesn't get rid of it.

Proof:

class Program
{
    static void Main(string[] args)
    {
        Currency currencyAccumulator = new Currency(0.00);
        double doubleAccumulator = 0.00f;
        float floatAccumulator = 0.01f;
        Currency currencyIncrement = new Currency(0.01);
        double doubleIncrement = 0.01;
        float floatIncrement = 0.01f;

        for(int i=0; i<100000000; ++i)
        {
            currencyAccumulator += currencyIncrement;
            doubleAccumulator += doubleIncrement;
            floatAccumulator += floatIncrement;
        }
        Console.WriteLine("Currency: {0}", currencyAccumulator);
        Console.WriteLine("Double: {0}", doubleAccumulator);
        Console.WriteLine("Float: {0}", floatAccumulator);
        Console.ReadLine();
    }
}

struct Currency
{
    private const double EPSILON = 0.00005;
    public Currency(double value) { this.value = value; }
    private double value;
    public static Currency operator +(Currency a, Currency b) { return new Currency(a.value + b.value); }
    public static Currency operator -(Currency a, Currency b) { return new Currency(a.value - b.value); }
    public static Currency operator *(Currency a, double factor) { return new Currency(a.value * factor); }
    public static Currency operator *(double factor, Currency a) { return new Currency(a.value * factor); }
    public static Currency operator /(Currency a, double factor) { return new Currency(a.value / factor); }
    public static Currency operator /(double factor, Currency a) { return new Currency(a.value / factor); }
    public static explicit operator double(Currency c) { return System.Math.Round(c.value, 4); }
    public static implicit operator Currency(double d) { return new Currency(d); }
    public static bool operator <(Currency a, Currency b) { return (a.value - b.value) < -EPSILON; }
    public static bool operator >(Currency a, Currency b) { return (a.value - b.value) > +EPSILON; }
    public static bool operator <=(Currency a, Currency b) { return (a.value - b.value) <= +EPSILON; }
    public static bool operator >=(Currency a, Currency b) { return (a.value - b.value) >= -EPSILON; }
    public static bool operator !=(Currency a, Currency b) { return Math.Abs(a.value - b.value) <= EPSILON; }
    public static bool operator ==(Currency a, Currency b) { return Math.Abs(a.value - b.value) > EPSILON; }
    public bool Equals(Currency other) { return this == other; }
    public override int GetHashCode() { return ((double)this).GetHashCode(); }
    public override bool Equals(object other) { return other is Currency && this.Equals((Currency)other); }
    public override string ToString() { return this.value.ToString("C4"); }
}

Result:

Currency: $1,000,000.0008
Double: 1000000.00077928
Float: 262144

We're only up to .08 cents, but eventually that'll add up.


Your edit:

    static void Main(string[] args)
    {
        Currency c = 1.00;
        c /= 100000;
        c *= 100000;
        Console.WriteLine(c);
        Console.ReadLine();
    }
}

struct Currency
{
    private const double EPS = 0.00005;
    private double val;
    public Currency(double val) { this.val = Math.Round(val, 4); }
    public static Currency operator +(Currency a, Currency b) { return new Currency(a.val + b.val); }
    public static Currency operator -(Currency a, Currency b) { return new Currency(a.val - b.val); }
    public static Currency operator *(Currency a, double factor) { return new Currency(a.val * factor); }
    public static Currency operator *(double factor, Currency a) { return new Currency(a.val * factor); }
    public static Currency operator /(Currency a, double factor) { return new Currency(a.val / factor); }
    public static Currency operator /(double factor, Currency a) { return new Currency(a.val / factor); }
    public static explicit operator double(Currency c) { return Math.Round(c.val, 4); }
    public static implicit operator Currency(double d) { return new Currency(d); }
    public static bool operator <(Currency a, Currency b) { return (a.val - b.val) < -EPS; }
    public static bool operator >(Currency a, Currency b) { return (a.val - b.val) > +EPS; }
    public static bool operator <=(Currency a, Currency b) { return (a.val - b.val) <= +EPS; }
    public static bool operator >=(Currency a, Currency b) { return (a.val - b.val) >= -EPS; }
    public static bool operator !=(Currency a, Currency b) { return Math.Abs(a.val - b.val) < EPS; }
    public static bool operator ==(Currency a, Currency b) { return Math.Abs(a.val - b.val) > EPS; }
    public bool Equals(Currency other) { return this == other; }
    public override int GetHashCode() { return ((double)this).GetHashCode(); }
    public override bool Equals(object o) { return o is Currency && this.Equals((Currency)o); }
    public override string ToString() { return this.val.ToString("C4"); }
}

Prints $0.



回答4:

Mehrdad, I don't think I could convince you if I brought in the entire SEC. Now, your entire class basically implements BigInteger arithmetic with an implied shift of 2 decimal places. (It should be at least 4 for accounting purposes, but we can change 2 to 4 easily enough.)

What advantage do we have backing this class with double instead of BigDecimal (or longlong if something like that is available)? For the advantage of a primitive type I pay with expensive rounding operations. And I also pay with inaccuracies. [Example from here 1]

import java.text.*;

public class CantAdd {
   public static void main(String[] args) {
      float a = 8250325.12f;
      float b = 4321456.31f;
      float c = a + b;
      System.out.println(NumberFormat.getCurrencyInstance().format(c));
   }
}

OK, here we backed with a float instead of a double, but shouldn't that be a BIG warning flag that the whole concept is wrong and that we may get in trouble if we have to make millions of calculations?

Every professional who works in finance believes that floating-point representation of money are a bad idea. (See, among dozens of hits, http://discuss.joelonsoftware.com/default.asp?design.4.346343.29.) Which is more likely: they are all stupid, or floating-point money is indeed a bad idea?



回答5:

Cur c = 0.00015;
System.Console.WriteLine(c);
// rounds to 0.0001 instead of the expected 0.0002

The problem is that 0.00015 in binary is really 0.00014999999999999998685946966947568625982967205345630645751953125, which rounds down, but the exact decimal value rounds up.