Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

R.equals()'s treatment of -0 creates unpleasant corner cases #2415

Closed
JLRishe opened this issue Dec 19, 2017 · 17 comments
Closed

R.equals()'s treatment of -0 creates unpleasant corner cases #2415

JLRishe opened this issue Dec 19, 2017 · 17 comments

Comments

@JLRishe
Copy link

JLRishe commented Dec 19, 2017

It seems intentional that R.equals(0, -0) evaluates to false, given that it calls off to a function specifically designed to treat 0, and -0 as unequal:

if (!(typeof a === typeof b && _objectIs(a.valueOf(), b.valueOf()))) {

I can appreciate that 0 and -0 are not the "same" in the strictest sense of the word, but this can have undesirable consequences in situations where one (doesn't/shouldn't have to) care about the distinction between 0 and -0 and the equality comparison is several layers removed from what one is actually doing.

For (a slightly contrived) example:

const mirrorX = R.evolve({ x: R.negate });

const containsXMirror = (p, ps) => R.contains(mirrorX(p), ps);

const points = [
    { x: -2, y: 3 },
    { x:  5, y: 9 },
    { x:  0, y: 2 }
];

containsXMirror({ x:  2, y: 3 }, points);  // true
containsXMirror({ x: -5, y: 9 }, points);  // true
containsXMirror({ x:  0, y: 2 }, points);  // false

Is there any straightforward way to guard against this, or something that can be done about it?

If R.equals(0, -0) === false really is desired behavior, perhaps R.negate, R.multiply, and R.divide could be made to return 0 when the underlying math operation evaluates to -0? At least that way we could guard against these edge cases by ensuring that we use R.negate, R.multiply, and R.divide instead of the built-in operators.

@CrossEye
Copy link
Member

CrossEye commented Dec 19, 2017

That, I'm afraid, was done very intentionally. We try to take serious the idea that

`x ≍ y` should imply `f(x) ≍ f(y)`

for any f, which would fail (albeit in fairly limited sets of circumstances) if equals conflated these distinguishable numbers. For instance if f = (x) => 1 / x, and equals(-0, 0), then this would imply equals(-Infinity, Infinty). We couldn't have that.

So we follow the SameValue algorithm of Object.is rather than the SameValueZero used in many other places. (See the MDN article for more information.)

Obviously there are work-arounds for this, but they are definitely less convenient. For instance, in the example above:

const mirrorX = R.evolve({ x: R.negate });
const pointEquals = R.curry((p1, p2) => p1.x === p2.x && p1.y === p2.y);
const containsXMirror = (p, ps) => R.any(pointEquals(mirrorX(p)), ps);

This is the first time in the more than two years since we got rid of containsWith that it seems to have come back to bite us.

@dionyziz
Copy link

In that case, do we want to introduce an operator named differently (perhaps R.valueEquals?) which does not separate 0 and -0? It seems that this may be useful. For example, we ran into this problem when creating a vector library, whose vector equality operator we implemented as such:

    return R.zipWith(R.equals, this.data, other.data).reduce(R.and)

Then we created a unit test that checked if the negation of the zero vector is equal to itself, which makes intuitive sense.

@CrossEye
Copy link
Member

CrossEye commented Jul 22, 2019

@dionyziz:

Feel free to create a PR for such a function if you find it important. But do note that my own feelings are mixed. This has very rarely come up as an issue. Is this something that you expect to bite you in production code? Is your unit test realistic?

And how would you document that function? Would you say anything more than the equivalent of "like equals except that 0 and -0 compare as equal"? Would you point out that 1 / n would generate different results?

@dionyziz
Copy link

dionyziz commented Jul 22, 2019 via email

@Bradcomp
Copy link
Member

It most definitely does not behave like the === operator though. We have a separate function, R.identical that is much closer than equals is.

@JLRishe
Copy link
Author

JLRishe commented Jul 22, 2019

@Bradcomp I think you misunderstood. Scott asked "How would you document [the new function that you are proposing]?" And Dionysis replied with "Document it as behaving exactly like ===."

1.5 years after filing this case, I understand the reasoning behind equals behaving the way it does, but I still find it bizarre that Ramda wouldn't include something that performs normal mathematical equality. I would wager that most people performing mathematical operations usually don't really care what the value of n/m is when m is zero, but they do expect 0 / n and 0 * n to equal the same value for all finite n (or all finite n ≠ 0 in the former case). These are fundamental principles of mathematics and Ramda seems to place a huge emphasis on an artificial characteristic of floating point representation.

@CrossEye
Copy link
Member

@JLRishe:

What I said earlier is still a very important principle here. Although we don't achieve it 100% yet, I really don't want to make changes that moves us away from

`x ≍ y` should imply `f(x) ≍ f(y)`

I see this behavior of JS numbers as unfortunate, but as one of the important tradeoffs when trying to represent mathematical objects in code. We've dealt with this before. #186 and #672 talked about adding a mathematical modulus operation, different from the remainder one that matches %. I wouldn't mind doing the same thing here, with something like eqNumber (0, -0) //=> true or some such, although I still would hate any behavior we could define for eqNumber (NaN, NaN).

Maybe because this has never bitten me, I don't find it a high priority. If I'm expecting numbers, these days I would probably use the obvious lambda expression in place of equals, but even if I didn't, I have few experiences with code generating -0, and so I haven't run into any problems with equals.

@alexbepple
Copy link

@CrossEye I just unsuspectedly ran into this and wanted to report this as a bug, only to find that this is by design. Seriously uncool. The problem is not so much with the behavior as such, it is that you don't suspect it.

In any algebraic sense 0 and -0 are the same, no? So the fact that it leads to problems with

`x ≍ y` should imply `f(x) ≍ f(y)`

is unfortunate, but IMO is heavily outweighed by the intuitiveness of equals(0, -0).

I also feel that prioritizing the principle over the practicality violates what Ramda wants to be:

Using Ramda should feel much like just using JavaScript. [...]
We aim for an implementation both clean and elegant, but the API is king. We sacrifice a great deal of implementation elegance for even a slightly cleaner API.

`x ≍ y` should imply `f(x) ≍ f(y)`

is elegant, yes. But API (which I understand to include behavior) is king.

@CrossEye
Copy link
Member

CrossEye commented Apr 17, 2020

@alexbepple:

The problem is not so much with the behavior as such, it is that you don't suspect it.

Is this something that you think better documentation would help? If so, do you have a suggestion?

In any algebraic sense 0 and -0 are the same, no?

I guess it depends on what you mean by "algebraic" In the general sense, the fact that they have clearly distinguishable reciprocals means that they are different. If you mean something more like arithmetic, then there are not two distinct values 0 and -0, only a single 0, so the question might not be coherent.


Our decision was not made in a vacuum. It was heavily influenced by Object.is. That was added precisely because of the issues in handling 0 and -0 and NaN.

I would not object to a PR that added a mathematical version of equals, although I can't imagine what it would do with NaN.

@semmel
Copy link
Contributor

semmel commented Apr 18, 2020

@CrossEye Please do not muddy the waters here.

In any algebraic sense 0 and -0 are the same, no?

I guess it depends on what you mean by "algebraic"

No, I don't think "it depends". It's a plain mathematical fact that the whole numbers are a ring and form the groups (R, ·) and (R, +) with the binary multiplication and addition operators. 0 is the neutral element of (R, +), and 1 is the neutral element of (R, ·). There is no place in (R, +) for -0. -0 cannot be the result of any addition. It's just a JavaScript quirk without any benefit. I am not a mathematician, please correct me if I am wrong.

As a side note, I find it surprising that in Ramda large parts like Functor, Foldable, Applicative, Traversable, ... are motivated and by category theory and only make sense if one understands category theory (Which itself generalises algebras). On the other hand however, Ramda takes algebraic nonsense like -0 seriously.
What is the target group of Ramda then? 🤔

@JLRishe
Copy link
Author

JLRishe commented Apr 18, 2020

Adding a note to the documentation of equals or adding a mathematical version of equals wouldn't really solve the problem I raised in my original issue, as the issue I encountered was with contains (which indirectly relies on equals).

The potential solution I suggested back then was to have some of the mathematical functions (multiply, negate, divide) behave more "mathematically" by coercing a -0 result to 0. Other than it being a change from current functionality, is there any reason that would be inconsistent with Ramda's philosophy?

@semmel
Copy link
Contributor

semmel commented Apr 18, 2020

@JLRishe

The potential solution I suggested back then was to have some of the mathematical functions (multiply, negate, divide) behave more "mathematically" by coercing a -0 result to 0.

I doubt anybody expresses their math in code using these functions. -x seems to me clearer than negate(x).

I think equals needs to be patched.

@CrossEye
Copy link
Member

@semmel:

In any algebraic sense 0 and -0 are the same, no?

I guess it depends on what you mean by "algebraic"

No, I don't think "it depends". It's a plain mathematical fact that the whole numbers are a ring and form the groups (R, ·) and (R, +) with the binary multiplication and addition operators. 0 is the neutral element of (R, +), and 1 is the neutral element of (R, ·). There is no place in (R, +) for -0. -0 cannot be the result of any addition. It's just a JavaScript quirk without any benefit. I am not a mathematician, please correct me if I am wrong.

I'm not a mathematician either, although my degrees are in mathematics. You're absolutely right that it is a quirk with little or no benefit; it's not specific to JS, as the same underlying floating point number system is used in a great number of programming languages.

The problem is that JS numbers do not form a ring (and therefore not a field either), even if we exclude NaN. They are close, if that even means anything. But problems with overflow and with 0/-0 mean that they cannot be treated as algebraic structures. One thing that might make them closer (to a ring at least, although possibly not a field) could be to include +Infinity and -Infinity.

But the JS definition, for some good reasons, make 0 * Infinity return NaN, which means that unless we try to include NaN in our ring/field, we have an issue... and many more issues if we do.

But, if we were to be able to define a field on the JS numbers (or really on IEEE-754 numbers), then it could not conflate 0 and -0 unless it also conflated -Infinity and Infinity. And that is an extremely weird concept.

@JLRishe:

The potential solution I suggested back then was to have some of the mathematical functions (multiply, negate, divide) behave more "mathematically" by coercing a -0 result to 0.

I can't see this adding much. First of all, it's likely just papering over the problem. It doesn't change what happens when you use the native operators. So

const x = 1 * 0
const y = -1 * 0
Object.is(x, y) //=> false

and suddenly we'd have complaints that Ramda's functions weren't properly doing JS math.

But more importantly, Ramda is meant to work with anybody's functions. We don't expect you to pass only prop('foo') or path(['foo', 'bar']) or some other application of a Ramda function to groupBy; you can pass anything you want. If Ramda functions are somehow privileged when used with other functions, the library becomes much less useful. I would not want to have to say, "oh, your contains will work fine if you change x * y to R.multiply(x, y)!


I'm not dead-set against changing this. This is a very important principle for Ramda:

x ≍ y should imply f(x) ≍ f(y)

But we will never be able to achieve it entirely. (Think f = (z) => z === x; then f(x) and f(clone(x)) will be different, even though clone(x) and x are the same.)

Perhaps this should be one of those places where we let it go. It might match some other parts of the language that we now miss. (new Map([[-0, 'a'], [0, 'b']]) .size //=> 1)

But we need to think through the implications very carefully. This is a fairly subtle change, and might make for subtle bugs.

@semmel
Copy link
Contributor

semmel commented Apr 19, 2020

@CrossEye Thanks for the clarification!

But, if we were to be able to define a field on the JS numbers (or really on IEEE-754 numbers), then it could not conflate 0 and -0 unless it also conflated -Infinity and Infinity. And that is an extremely weird concept.

I am curious; Did you ever use +/-Number.Infinity in a meaningful way? I mean isn’t it better to work with MAX/MIN_SAFE_INTEGER and Number.MAX/MIN_VALUE instead of Number.Infinity?

Should JS not rather throw exceptions instead of returning the errors NaN and Infinity disguised as numbers?
If performing calculations with Number.Infinity makes sense, I stand corrected. Otherwise I‘d favour conflating 0 and -0, if not because of the algebra argument, then because it "feels right".

@CrossEye
Copy link
Member

I am curious; Did you ever use +/-Number.Infinity in a meaningful way? I mean isn’t it better to work with MAX/MIN_SAFE_INTEGER and Number.MAX/MIN_VALUE instead of Number.Infinity?

I only use them the same way that I think might be the only way JS itself really uses them, as the defaults for min or max functions. They seem much cleaner for that purpose, as a returned ±Infinity has useful information that wouldn't necessarily be included with MAX/MIN_VALUE: namely that the list had no values in it.

This is useful, but it's not enough of a rationale to inform our call on equals. equals was patterned after Object.is, and maybe that needs to change. But it makes me quite nervous. Part of it, I suspect, is that it took a lot of very smart people a lot of patient hours of explanation to make me understand why we couldn't write a conforming Maybe that used null or undefined as a signal for the empty style. Now that I've internalized that notion (parametricity), it has started to feel like a natural law that must be applied everywhere. When you start trying to apply laws to your data, you don't want to start adding exceptions to them. Although it's not one of the Setoid laws, the phrase I keep returning to, x ≍ y should imply f(x) ≍ f(y), feels closely related, and it worries me to intentionally add an exception to it.

@semmel
Copy link
Contributor

semmel commented Apr 20, 2020

With real numbers 0 ≠ -0 even produces values which make sense:

const
cotangent = a => Math.atan(1 / a);
cotangent(-0) / Math.PI // -0.5
cotangent(0) / Math.PI // 0.5

So if cotangent is a pure function,

A pure function is a function that, given the same input, will always return the same output and does not have any observable side effect.

then what does the word same mean in that definition? If same means R.equals(a, b) is true, then R.equals(0, -0) must be false, or cotangent is not pure.

In conclusion I think that w.r.t. -0 and 0, R.equals should remain as it is.

@CrossEye

... we couldn't write a conforming Maybe that used null or undefined as a signal for the empty style.

Indeed, the functor composition: F.map(x => f(g(x)), a) ≡ F.map(f, F.map(g, a)) is broken e.g. with g(x): x => null!

After reading good pieces of Prof. Frisby's Mostly Adequate Guide and recently James Sinclair's (@jrsinclair) nice Introduction to Static Land I was just about to fall into that trap!

Incredible that still today so many tutorials on that topic present hand-waving implementations of algebraic data types. I still think ramda-fantasy did the best job in being lawful, but also approachable for beginners.

Thank you for your (again) very enlightening comments! 💝

@CrossEye
Copy link
Member

@semmel: That's a great example. I'm going to have to remember it next time this comes up!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants