6

I am writing numerical calculation software using .NET C#, which needs to be blazingly fast. There is a lot of fractional math. So using decimal type is pretty much out of the question, given its poor speed relative to using double. But of course double has its problems testing for equality, with floating point rounding issues.

My options seem to be subclassing double and overriding ==, < and >; versus creating extension methods for double equivalent to these. My tendency is to go with the latter - less code to change and maybe it will be less confusing to others reading the code later? Is there another option? What are other good reasons to choose one over the other?

8
  • 19
    How would you subclass double when it's a value type and, by definition, not eligible to be a base class? Commented Oct 21, 2020 at 7:42
  • 12
    Note that "blazingly fast" software is unless if it gives completely wrong answers. I also question whether C# is an appropriate choice given your stated requirements. For maximal performance of correct maths, Fortran usually wins.
    – OrangeDog
    Commented Oct 21, 2020 at 8:18
  • 2
    @OrangeDog Fortran doesn't buy you anything over C++ or Rust unless perhaps if you're using arrays that are parallelized across a supercomputer cluster. And whilst garbage-collected languages like C#, Java and Haskell are indeed generally somewhat slower, it's not a huge difference (whereas dynamic languages or decimal arithmetic are much slower than floating-point in any of those languages). So, it can be a perfectly valid decision to use C# here. Commented Oct 21, 2020 at 9:02
  • 2
    @OrangeDog BLAS deals with an extremely simple, extremely homogeneous sort of problem, which thus lends itself ideally for Fortran. For linear algebra, the right thing to do is generally to call that library, regardless from what language you use yourself. (Also: neither C nor Fortran is best in BLAS – Cuda is.) Commented Oct 21, 2020 at 9:59
  • 3
    What kind of numerical calculations are you doing that equality tests are important? Generally algorithms in numerical analysis rely more on tests for inequality. E.g., you deem an algorithm converged when the error is less than some tolerance. About the only time you test for (approximate) equality in practice is when validating results against a known standard, and there it's easy enough to just use an approximate comparison subroutine, instead of pretending that you're really testing for "equality".
    – Nobody
    Commented Oct 21, 2020 at 13:26

4 Answers 4

47

"double has its problems testing for equality".

No, that is not true. "double" does not have such problems. Equality testing for double values is well defined and usually works as it should (which may sometimes not be what several programmers expect, of course).

Truth is: programmers have often problems with testing for equality correctly in numerical software. You cannot simply fix this by using another data type, or by providing some standard equality comparers with some standard precision for equality up-front. Though such approaches may be part of a solution, you first and foremost need to make sure the programmers in your team know how to do floating point comparisons correctly.

Before reading the rest of my answer, please have a look into "What Every Computer Scientist Should Know About Floating-Point Arithmetic". Now. No excuses.

So, since you read this paper, you now have learned that there are several alternatives on how comparisons can be done when using floating point numbers, and one has to pick the correct one for the specific case. For example, it may be necessary to take absolute or relative errors into account, to analyse the required precision for each individual comparison/quantity, or to take the specific operations and algorithms into account which will be used in the numerical software you are designing. Another thing which might be necessary is to adapt the scaling of some quantities, or other measures to keep rounding errors under control.

To find out what one really needs, I would recommend starting to implement some of the algorithms and determine precisely which kind of floating point comparisons are required there. When comparisons of the same kind occur more than two or three times, then it is time to refactor them into a reusable library (maybe using extension methods, which is a useful way in C# whenever it comes to adding some reuseable methods to an existing type one cannot change). It should be clear now why overloading an operator like == is not useful, since there is only one such operator per type, with no additional parameters like a precision.

Don't try this up-front until you have already written several of such numerical programs!

20
  • 12
    The floating point article is a classic and definitely recommended reading. But I do think it IS fair to say that "double has its problems testing for equality", since the results are different than their would mathematically. Sure it's up to programmers to work around it, but it's definitely a limitation of doubles, inevitable though it may be.
    – Mark
    Commented Oct 21, 2020 at 11:39
  • 6
    @Mark: Lots of iterative numerical algorithms require to stop when the value of some function goes below some "epsilon" - and the number of iterations depend on how epsilon is chosen deliberately. Choosing epsilon=zero would end up in an infinite loop, even if there would be something like a infinitely precise fractional data type available.
    – Doc Brown
    Commented Oct 21, 2020 at 12:05
  • 2
    ... where I agree is that sometimes the typical "64 bits" for a double are not enough, and there are problems where 128 bit floating point numbers can be more useful. But to find out when this is the case, one will need usually need a firm understanding of the requirements of the specific situation.
    – Doc Brown
    Commented Oct 21, 2020 at 12:10
  • 6
    @Mark: the OP did not make the comparison "double" versus "real numbers", they made the comparsion "double" vs. "some higher precision type like decimal", and my point is that this is a misconception - higher precision types will always lead the the same problems. What you call a "limitation of double" I would only call "a limitation of having to work on real hardware where any fractional number will get only a small, finite number of bits for its representation, whatever type one choses to work with".
    – Doc Brown
    Commented Oct 22, 2020 at 13:06
  • 4
    @SilentAxe That expression does not give a wrong answer. It's only wrong if you make the assumption that "double precision float" and "real numbers" are the same. If you do write your model assuming they are the same, you will be surprised. That all being said, the argument typically made is that "3.0" and "3.0000000000000001" are different spellings of the same floating point number, just as "1.0" and "0.999..." are different spelling of the same real number.
    – Cort Ammon
    Commented Oct 22, 2020 at 20:43
26

financial calculation software

double has its problems testing for equality, with floating point rounding issues.

These two problems are not compatible. Do not write a == that isn't transitive, especially not for financial software. Depending on the calculations you are doing, you might not need floating point numbers at all, but rather do everything in fixed point (i.e. integer) arithmetic.

31
  • 1
    Unfortunately, programming languages nowadays are expected to define == and != in a broken non-transitive fashion for floating-point types, rather than specifying e.g. that if x and y are both NaN, x==y, x>=y, and x<=y will be true (and other comparisons false), and if x is NaN and Y isn't or vice versa, x!=y will be true and all other comparisons false. That would have allowed testing for NaN via !(x>=0 || x<=0), while keeping == as an equivalence relation.
    – supercat
    Commented Oct 21, 2020 at 16:47
  • 2
    @supercat as far as I can tell, you're just proposing another kind of broken. There are different reasons for a number to be NaN, but the reason isn't saved in the object. So you really cannot assume NaN==NaN, because that could mean 0/0==sqrt(-1). Commented Oct 22, 2020 at 5:42
  • 1
    @supercat also, do you have an example of non transitive ==? The only half surprising fact I could find is that == isn't reflective with NaN.!= cannot be transitive, and isn't supposed to be, is it? Commented Oct 22, 2020 at 6:59
  • 4
    To combine NaN with ==, you need NaB - not a Boolean. The fundamental idea of NaN is that it propagates through calculations, and that means it has to propagate through expressions involving booleans as well.
    – MSalters
    Commented Oct 22, 2020 at 9:09
  • 1
    @supercat == over { NaN } is vacuously transitive, because the relation is empty
    – Caleth
    Commented Oct 22, 2020 at 15:29
13

Don’t do that. Think about what you really want and name functions accordingly. Given a number x, there is a range of numbers that are likely equal to x (those close to x). A number is definitely greater than x if it is greater, and not in the LikelyEqual range. A number is likely greater or equal x if it is greater or equal to x or in the LikelyEqual range etc. You want these functions:

LikelyEqual
LikelyNotEqual
DefinitelyGreater
LikelyGreaterOrEqual
DefinitelyLess
LikelyLessorEqual

This solution makes it absolutely clear what your code does. Your solution is clever. Clever is rarely a good idea.

PS. Feel free to use better names. Just do NOT call a function “equal” that doesn’t return whether the arguments are equal.

4
  • 7
    I'd call it "nearly equal" or "mostly equal" not "likely".
    – Bergi
    Commented Oct 21, 2020 at 7:34
  • Agree with Bergi. Epsilon is definitely greater than zero. Also, this does not communicate whether the values have an small absolute or relative difference.
    – MSalters
    Commented Oct 22, 2020 at 9:11
  • Bergi, the name came from a use case where it was reasonably possible that two results were mathematically equal, but slightly different due to rounding errors, but where it was highly unlikely to have results that were mathematically very close but different. “NearlyEqual” would be better for example for an iteration, where results will never be mathematically equal, but floating point arithmetic is expected to give close or equal results.
    – gnasher729
    Commented Jan 17, 2021 at 16:37
  • I think when I implemented it, I asked “what are the integer fractions with smallest denominator such that a/b != c/d mathematically but “LikelyEqual (a/b, c/d)” returns true.
    – gnasher729
    Commented Jan 17, 2021 at 16:41
8

Extension methods seem to be the best way to go here, so we don't confuse "exactly equal" with "almost equal." An optional precision argument should be used (give a default that is best for the general situation; it can always be overridden). So for testing "equality", have something like

public static bool ApproxEqual(this double a, double b, double precision = 0.0000000001)
{
    return Math.Abs(a - b) <= precision;
}
7
  • Instead of 0.0000001 which is subject to rounding issues as well, why not epsilon instead ?
    – Christophe
    Commented Oct 20, 2020 at 22:54
  • 7
    @Christophe because Double.Epsilon is ~5e-324, which is way beyond the floating point error ranges. Using that results in essentially overloading ==/</>.
    – Brie
    Commented Oct 20, 2020 at 23:12
  • 6
    In numerical simulation software, one should always analyse what precision is required when ApproxEqual is used, in the specific usage context. So I would heavily recommend against providing any default value for precision . Force the user of such a function always to think about the required precision, and make them choose a value for it deliberately.
    – Doc Brown
    Commented Oct 21, 2020 at 5:59
  • 1
    This function is not good for the general case. Better would be a absolute and a relative tolerance (with either no default argument (as DocBrown suggests) or default argument set to zero (with a better name for the function ) ). With only absolute precision you get arbitrarily large relative errors for small numbers (e.g. comparison with 0 or anything that is smaller than precision). For large numbers this even reduces to the exact comparison. You make here an implicit assumption on the magnitude of numbers you want to compare. For "numerical calculation" use-case this cannot be made.
    – Andreas H.
    Commented Oct 21, 2020 at 7:17
  • 8
    To apply the best, most favorable interpretation of the answerer's intention, I think this answer points out that the tolerance (whether absolute or relative or both) is something that needs to be injected into the relation comparison function (i.e. allowing customization at each use site), rather than baked into the user-defined type. That is it. It does not go into considering what would be considered an actual, "industrially robust" implementation.
    – rwong
    Commented Oct 21, 2020 at 9:06

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.