The "zero problem" seems to be a concern for many readers here (it was also my first question in this thread).
But it's only a problem as far as we'd like to provide a one-size-fits-all method in the stdlib.
The proposal explicitly guards us from such a goal:
There is no uniformly correct notion of "approximate equality", and there is no uniformly correct tolerance that can be applied without careful analysis, but we can define approximate equality functions that are better than what most people will come up with without assistance from the standard library.
But the proposal also creates great expectations:
Even though [features of floating-point arithmetic] are relatively simple things to account for, people get them wrong more often than they get them right, so it's a great opportunity for the standard library to help improve software quality with a small well-considered API.
There lies the "zero problem", in this uncanny valley. I do not know if we need to reduce our expectations, or if we need to improve the proposal. In both case I'll improve my understanding of the problem anyway. What do you think?
Thanks Josh, that's exactly what I'm feeling about this proposal, too.
While IEEE-754 is indeed not very beginner-friendly, it would be wrong to hide the difficult places.
I like the approach Swift took with Strings: provide safe ways for important use-cases, but don't try to over-simplify thinks and don't provide do-what-I-mean methods.
We should use the same thinking for floating-point, i.e. we should really analyse the use-cases before providing such apparently-simple-to-use methods. What use-case are people trying to solve with isApproximately(), besides unit-tests?
The idea behind this proposal is great, and something I would use often, however I think it could be refined some more:
Different names have been suggested for this function. One possibility I like is âisRelativelyCloseTo()â. This would emphasize the fact that the âclosenessâ depends on this size of the numbers being compared rather than an absolute difference. This might also help not to oversell the functionality, in particular that the function wonât work with zero. (Actually it does work for zero; itâs just that nothing besides zero is relatively close to zero.)
Some have suggested creating a function that automatically checks for zero and handles it as well. But that would give some strange results and could lead to unexpected errors. You could have a situation, for example, where 0.0000001 is not close to 0.00000001 but it is close to 0.0, even though 0.0 is farther away.
In fact, I think there is an argument for not even providing an âisAlmostZero()â function. It will be tempting for users to write code like this:
This code suffers from the same problem as in #2 above.
The problem with âisAlmostZero()â is that it uses an absolute tolerance whereas âisAlmostEqual(to:)â uses a relative tolerance and the two donât work well together. Having âisAlmostZero()â complicates the concept of âalmostEqualnessâ. If we concentrate on relative closeness only, the solution is cleaner.
When you need absolute closeness, it is not difficult to roll your own. To my mind, writing
abs(a) < 0.000001
is just as easy to read and understand as
a.isAlmostZero( absoluteTolerance:0.000001 )
I wonder if the method of specifying the tolerance in âisAlmostEqual(to:)â is best. I would rather specify the number of digits of precision loss that is tolerable. It is an easier way to think of how much precision might lost due to rounding.
The default tolerance in âisAlmostEqual(to:)â seems wrong to me. It is essentially saying that half of the digits can be wrong. In the situations I encounter, I want most of the digits to be right. And I think the default tolerance should be quite low, perhaps 1.5 digits. In my experience this works fine in a wide variety of applications. (When 1.5 digits is too little, users can loosen the tolerance as needed. But I think it is better for the tolerance to be on the tight side to begin with.)
I would have thought that infinity should only be considered close to infinity (like zero is only relatively close to zero). Are there certain calculations that produce finite values that should be considered infinite? Is the âisAlmostEqual(to:)â function the best place to test for these?
Is the problem being addressed significant enough to warrant a change to Swift?
Yes
Does this proposal fit well with the feel and direction of Swift?
Yes
If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
I have written my own similar function and use it regularly.
How much effort did you put into your review? A glance, a quick reading, or an in-depth study?
I read the proposal and the comments in the review thread.
R all.equal<T: FloatingPoint>(x: T, y: T, tolerance: T = T.ulpOfOne.squareRoot(), scale: T? = nil) â has an absolute tolerance argument and a scale argument used for relative tolerance. If scale is supplied it scales the difference by that. Otherwise, it scales the by abs(y) only if that is greater than the absolute tolerance. If the result of this scaling is less than the absolute tolerance it returns true.
You can see even these two languages with similar purposes do not agree. I suspect any choices you make about how to simplify this area are just choosing which set of confusions your users will run into.
The Julia function is more recently designed and a better comparison point for Swift I believe. If you google it then above the result for the documentation page you get two results for the Discourse thread about using it. They get questions about comparison to zero as you might expect. They do not have a separate function for comparing to zero and discuss that. The discussion includes calls for removing isapprox from the language, or moving it to the unit testing library. In the end they circle back to just updating the documentation to better explain comparing to zero and the absolute tolerance argument. I found the whole thread a good read.
Use cases brought up in the thread for the function in general:
Unit testing â if you have a known constant to compare to for your test you actually can choose a reasonable absolute tolerance for use in e.g. XCTAssertEqual(expression1:expression2:accuracy:). If you are generating test cases programmatically something like isAlmostEqual could still help though
Checking if some process has converged â if we want Swift to be used for mathematics/stats/ML this is one we may want to facilitate. That user group knows their numbers but not necessarily floating point.
An important misuse case for comparing to zero was also brought up:
If you want to know if a and b are almost equal it would be a misuse to call isAlmostZero(b - a). You should really call isAlmostEqual(a,b) The contention was made that most comparisons to zero are from people translating old code of the form abs(b - a) < tolerance.
I found that point somewhat telling. There are valid times to compare to zero of course, but if it is a sufficiently minority use case perhaps it doesn't make sense to address it with a separate function. An absolute tolerance argument (defaulted to zero) and some extra lines in the docs could be the best way forward.
A note for a separate future discussion: both R and Julia also try to do the right thing for comparing vectors of numbers as well. This involves taking some norm of the vectors when scaling for relative tolerance, as opposed to doing an element-wise comparison.
At first I thought it is unfortunate now I think it is its achilles heel. What happened if I have an array of Doubles and I want to do a comparison? I have to check for zero every time but itâs not apprehend that I have to do it. I guess documentation to the rescue? Maybe on the case crash just like division by zero?
The problem needs addressing, and I think the proposal is a good start, but like others, I think it falls short in how it handles zero, and I'm worried that it may actually become a footgun in itself.
Is the problem being addressed significant enough to warrant a change to Swift?
Yes, this is something that is often needed, but hard to get right.
Does this proposal fit well with the feel and direction of Swift?
Not quite. In general Swift takes great care to provide APIs that are not only technically correct, but easy to use, and help guide the user to the right solution. The proposed solution is technically correct, but hard to use, and doesn't provide much guidance.
Others have already mentioned the usefulness of isAlmostEqual(to:) in unit tests, so let me provide another use case: In a vector graphics application I'm working on, we sometimes need to check if values are approximately equal from the userâs point of view. Let's say the user is pasting some vector graphics from another document, and we want to check if any of the colors are "close enough" to colors in the target document that we should consider them the same.
A naĂŻve solution using the proposed API would look like:
That's... quite a mouthful. And still not what we want. What we actually want to do is account for the type of data weâre dealing with. Given that red, green and blue are presented to the user as 0-255 and alpha is presented as 0-100, what we want is probably something like:
...but the proposal doesn't support that. In fact, the proposal doesn't even state at the point of use whether tolerance is relative or absolute, so if you didn't read the documentation very carefully, you might even write the following:
With this use case in mind, I think the API could do a better job of guiding the user to the correct solution. To be widely useful, and not create accidental footguns, I think the API needs to:
Support zero.
Handle bothrelativeTolerance and absoluteTolerance. Either in separate methods, or in a single method, if that's possible in a technically correct way.
Clearly specify at the point of use which type of tolerance is being used.
Do a better job of explaining in the documentation when to use a relative tolerance vs. an absolute tolerance, and how to choose that tolerance for different types of data, without having to "consult a friendly numerical analyst."
This really isn't possible within the scope of Swift documentation. I mean, it is, but this function's documentation would be larger than all of the other documentation for the standard library. We can provide sensible defaults, and references to reading, but at some point the only sound advice that can be presented in the documentation format really is "consult an expert."
I'm not sure if anyone responded to this yet, but this design sounds very appealing to me. It would be great to handle the zero case dynamically instead of with a bespoke api that people will forget to use. If the argument value is constant, it should be specializable and inlinable anyway, right?
To add another color to the shed, is there any reason not to complement the existing isEqual(to:) with isEqual(to:tolerance:)? On the other hand, I realize that would make tolerance a required argument, perhaps necessitating a special static member: foo.isEqual(to: bar, tolerance: .defaultEpsilon).
One possible argument for rejecting isEqual is that equal, particularly in computer science and mathematics, has a very specific dichotomy: things either are, or are not, equal. In an effort to maintain consistency with this definition, I would think it less than optimal to blur the line between equal and approximately equal.
Right. Furthermore, equal is generally a transitive relationship that implies substitutability. Approximate equality does not satisfy either of those.
(Normal floating-point equality violates substitutability, but in a much, much weaker way, which you can make go away if you abstract the model slightly--identifying +inf and -inf and nan--while approximate equality just violates the principle everywhere.)
Speculating here. At first blush, it is weird for a finite number to be approximately infinity. On the other hand, we're using these for values which might experience rounding errors and finite numbers can round to infinity. Allowing really large finite numbers to be approximately equal to infinity could be pragmatic and testing the approximate largeness by passing in infinity could be useful too.
Not without introducing a second tuning parameter.
The following are both desirable properties of an approximate equality relation on the real numbers:
If a is approximately equal to c, and a < b < c, then b is approximately equal to a and to c.
a is approximately equal to b if and only if c*a is approximately equal to c*b for non-zero c.
In an idealized model of floating-point arithmetic these are satisfied simply by using a relative tolerance. The problem is that nothing in this model is approximately equal to zero, unless you use a tolerance is so large as to be meaningless (>= 1, specifically).
Now, we all know that in practice, we frequently get floating-point values that "should" be zero (and should definitely be approximately zero), but are not, due to intermediate rounding. The classic example of this is:
let x = 0.1 + 0.2 - 0.3 // not quite zero!
if x.isAlmostEqual(to: 0.0) {
// should be executed, surely?
}
Possible solution 1
A common technique to handle this is to mix an absolute tolerance with a relative tolerance, and consider two values equal if either is satisfied. This resolves the problem with zero, but you lose property (2):
let a = 0.1 + 0.2
let b = 0.3
let A = bigNumber*a
let B = bigNumber*b
a.improvedAlmostEqual(b) // true
A.improvedAlmostEqual(B) // false!
let C = 2.0
let D = 3.0
let c = smallNumber * C
let d = smallNumber * D
C.improvedAlmostEqual(D) // false
c.improvedAlmostEqual(d) // true!
Now, maybe that doesn't seem so bad; it's ok if tiny numbers are approximately equal, after all, they're very close together! But a major part of the whole reason for floating-point arithmetic to exist is "scale invariance" (up to the underflow and overflow boundaries)--without floating-point, computational libraries need to carefully rescale all their values to ensure that they preserve full precision at all times. One of the two huge wins for floating-point is that libraries can--for the most part--simply ignore this issue and deliver good results without needing to care about the scaling of the data that is given to them. Using a tolerance of this form by default costs us that property.
Possible solution 2
Use an absolute tolerance only if either argument is exactly zero. This preserves scale invariance in some of the cases, but costs us desirable property 1; we get weird behavior like the following;
let a = smallNumber
a.improvedAlmostEqual(0) // true
a.improvedAlmostEqual(a/2) // false!
What's weird about this? a/2 is closer to a than 0 is, so if a is almost equal to 0, it must be almost equal to a/2. So detecting exact zeros and handling them specially is undesirable.
Proposed solution
The solution that I used in the proposal is admittedly a little bit of a punt on this issue. Rather than attempting to make an almost-equal predicate that's universally correct (which necessarily has compromises we just discussed), it instead tries to provide a high-quality relative comparison, which solves the problems that people often encounter there: it does not spuriously overflow or underflow and produce bad results, and it handles gradual underflow sensibly. This is not a complete solution for all cases, but it's a very careful partial solution that does address some real difficulties. Rather than a complete solution for all use cases, it gives you the building blocks to build a good solution for your use case, but requires a bit more expertise to assemble. Including isAlmostZero with it was probably a mistake, because it clouds the issue.
Proposed solution, take 2
The difficulty boils down to the fact that we don't know what the expected scale of values we are supposed to compare is; maybe these are products of probabilities and very small numbers are to be expected, and differences between them are significant. On the other hand, maybe these are physical coordinates for bolt holes, and all tiny numbers are de-facto zeros.
One option is to do what Julia does and simply spell this all out, with a default absolute tolerance of zero. The problem is that there's no satisfactory way to pick a non-zero default value for the absolute tolerance parameter, which is necessary to get the default behavior that some people on this thread would like to have. So another option would be to make this completely explicit:
Note that there is no default value for absoluteToleranceÂč. This is the fundamental tradeoff.
Âč Because it's fundamentally conveying scale information, we might even call it something like scale or unit; this might make it a bit more intuitive for new users, or easier to document.
It depends on what you mean by "the problem". If you mean "the natural scale of my problem is roughly unity, so anything vaguely zeroish should be considered zero," then it's still a problem. If you mean "a pure relative tolerance breaks down at zero," then it goes away. That's a lot of the subtlety that I'm trying to convey.
Suggestion for unifying isAlmostEqual and isAlmostZero:
Perhaps isAlmostEqual()'s tolerance can be construed as relative tolerance, except for when to == 0, in which case tolerance behaves as absolute tolerance. Unless I'm missing something, this would combine the behavior of isAlmostEqual for to != 0 and isAlmostZero for to == 0.
General suggestion:
Introduce an overload for isAlmostEqual which is spelled as isAlmostEqual(to: Self, absoluteTolerance tolerance: Self)
This overload improves the flexibility of isAlmostEqual, allowing one to use absolute tolerance for isAlmostEqual.
This doesn't handle the fairly common case where a and b are both essentially zero, but have different rounding errors, so slightly different values:
let a = 0.1 + 0.2 - 0.3
// 0.000000000000000027755575615628914
let b = 0.3 - 0.1 - 0.2
// -0.000000000000000027755575615628914
// neither a nor b is exactly zero, but the user likely wants them to
// be considered "approximately equal".
Perhaps I'm missing something, but I don't quite see how this scenario is directly related to my suggestion; the scenario you are proposing requires the use of isAlmostEqual, and my proposal would not alter this behavior. Rather, my suggestion was intended to provide a way in which to obviate isAlmostZero.
(On a tangential note, without considering the ramifications of actually doing this, perhaps instead of checking to == 0 we check to.isAlmostZero() [an internal method, in this case])
Steve, I can barely understand what you're saying there (both lack of expertise and lack of time to properly mind meld with it :) but I don't think that an a.isApproximatelyZero() function will work in practice.
Based on that disclaimer (poor understanding of the core issues involved) it seems that a.isApproximately(b) will get passed zeros, and that will need to mean something. Also, while you're right that this is a complicated issue to understand, I don't think the presence of isApproximatelyZero() will help any more than a doc comment on isApproximately would.