Traits in `std::cmp` and mathematical terminology

If NaN is always smaller than any number, then this doesn't work:

if (a < 0.0) { /* negative */ }
else if (a >= 0.0) { /* non-negative */ }
else { /* something bad happened! */ }

If f64::NAN < 0.0, then a < 0.0 would be true (which is somewhat odd).

I have worked with systems where NaN is "sorted" in some way (I don't remember where, maybe it was SQL?), and it didn't feel good.

Regarding workarounds for floats, there's an obligatory mention of

missing in this thread.

The crate supports two legitimate approaches with wrapper-types: Either define an order for NaN (in this case arbitrarily, as the greatest element), or disallow NaN entirely (which is checked when constructing the wrapper type).

2 Likes

Odd perhaps, maybe positive NaNs are more intuitive than negative NaNs :stuck_out_tongue:

The positive/negative example shows why NaNs are a nuisance and it would be better if they didn't exist at all. After all, i32 doesn't have NaNs (even if you divide 0/0), and it works out fine.

But the code you wrote is weird. If you simply forgot about NaNs it would be more natural to write:

if a < 0.0 { /* negative */ }
else { /* positive */ }

The only reason to write something like you wrote is if you remember about NaNs and want to deal with them separately. But in that case it's more natural to write:

if a.is_nan() { /* NaN */ }
else if a < 0.0 { /* negative */ }
else { /* positive */ }

I think that's a bad approach; this kind of sort_by call does not meaningfully sort the list in the presence of NaNs. E.g.

use std::cmp::Ordering::Equal;

fn main() {
    let mut list = [
        3.0,
        6.0,
        9.0,
        f64::NAN,
        2.0,
        5.0,
        8.0,
        f64::NAN,
        1.0,
        4.0,
        7.0,
    ];
    list.sort_by(|x, y| x.partial_cmp(y).unwrap_or(Equal));
    dbg!(&list); // order unchanged, definitely not sorted at all, though
}

(playground)


If you want to meaningfully sort a list of floats with NaNs, it's better to properly implement Ord for a wrapper type, and instead disregard IEEE. Or to disallow NaNs alltogether. I.e. either of the approaches offered by the ordered_floats crate.

Unless you're saying that you only use this "sort_by(partial_cmp().unwrap_or(Equal))" if you already checked the list for NaNs and there were none, but you don't like wrapper types; in which case you might as well use "sort_by(partial_cmp().unwrap())" though.

1 Like

But with IEEE 754, I can write:

if (a < 0.0) { /* negative */ }
else if (a >= 0.0) { /* non-negative */ }

And that's something, which I like about them.

I also do like NaN. If we have something like 0.0 / 0.0, what can we do?

  • panic?
  • give a wrong result such as zero?

Neither of that is nice. Not sure though if NaN != NaN is a good idea. (But it's what we have.)

You wouldn't write such code with i32. The reason you wrote that is because you want to detect NaNs. I don't understand why you prefer the "redundant" comparison with 0.0 to detect NaNs rather than using is_nan() which is a more direct way to say it, and thus nobody would be confused and think the comparison is redundant.

I would say: return infinity, just as you would when any other number is divided by 0.0. Seems reasonable.

If you're dividing two numbers that have both just rounded to 0.0, the accurate answer could be anything, so it doesn't really matter what you return (except panic, you don't want panic).

This is a stronger argument. If Eq denotes equality (which is a consequence of Ord being a total order), then PartialOrd + Eq should be a partial order in regard to <= and >= (I think).

But I think when a type is !Eq, then <= and >= don't need to be transitive, as explained here:

When a type is !Eq, then == doesn't denote "equality".

That way, we couldn't distinguish this case (0.0 / 0.0) from infinity (as a result of 1.0 / 0.0) with .is_nan. We only would have .is_infinite, which means we lose information of the "failed computation".

Anyway, NaN has other issues, I think. I remember the -ffast-math switch of C compilers which breaks IEEE 754 rules to make code faster.

Also this is odd:

fn main() {
    let a = 0.0;
    let b = -0.0;
    assert_eq!(a, b);
    assert_ne!(1.0/a, 1.0/b);
}

(Playground)

Yes but in practice there is no real reason why 1.0 / 0.0 needs to give a different answer from 1.0 / (-0.0), or why 0.0 / 0.0 needs to give a very different answer from 1e-300 / 0.0.

When you have a calculation that results in 0.0 / 0.0, and you're relying on it returning NaN, then you'll very likely run into the problem of rounding errors, which result in 1e-300 / 0.0 = inf , or 0.0 / 1e-300 = 0.0.

So this NaN result is not very reliable, and not a useful thing to depend on in practice.

I disagree. There is:

fn main() {
    assert_eq!(f64::exp(-1000.0), 0.0);
    assert_eq!(-f64::exp(-1000.0), 0.0);
    assert!(1.0 / f64::exp(-1000.0) > 0.0);
    assert!(1.0 / -f64::exp(-1000.0) < 0.0);
}

(Playground)

I agree, it's not "reliable".

I wouldn't depend on it, but it's still nice to have it as an indicator that something went wrong. It often helped me, at least during development. Maybe it's less useful when building a --release.

Are you saying the rationale for this complexity is so that you get the infinitely precise answers to your last two questions, involving numbers underflowing out of floating point range? Surely you don't think this is true as a rule in floating point? Usually you don't get mathematically precise answers when underflowing!

assert!(1.0 / (f64::exp(-1000.0) - f64::exp(-999.0)) > 0.0);

Regarding the question whether >= should be transitive, we can think this further and apply this to my example of setting −∞ == +∞ but keeping −∞ < +∞.

Consider:

fn main() {
    let a = f64::exp(-1000.0);
    let b = -f64::exp(-1000.0);
    assert_eq!(a, b);
    // Today we have:
    assert_ne!(1.0/a, 1.0/b);
    // But we might enjoy:
    //assert_eq!(1.0/a, 1.0/b); // because a == b
    // While keeping:
    assert!(1.0/a > 1.0/b);
}

(Playground)

That's what I meant with:

P.S.: Note that I don't suggest changing f64 or IEEE 754. This is just an example to explain why >= should (perhaps) be allowed to be non-transitive when a type implements PartialOrd (because we could implement such alternative rules in a wrapper type).

You're trying to make even more ridiculous rules than what f64 already has, just to match some specific examples with rounding errors, while pretending they don't have rounding errors.

Definitely it shouldn't be the case that both −∞ == +∞ and −∞ < +∞.

This is only odd if you see comparison through < and > as related to equivalence through == and !=. We could also assume these are independent operations (disregarding that >= is a shorthand for "> or ==").

I don't think my example is more odd than:

The cited example is what we have already, and it cannot really be explained with "rounding errors" as a == b and 1.0 == 1.0.

Now that is a rounding error (resulting in an infinitely wrong result). :innocent:


But seriously, f64::exp(-hugeval) is a common thing to encounter in practical code. Still, distinguishing between -0.0 and 0.0 won't work nice in many cases. E.g., what is -5.0 + 5.0? Is it -0.0 or 0.0? I don't even know.

I don't really want to defend IEEE 754. I just wanted to say some things are really "odd", and I think it's okay that PartialOrd has some pretty relaxed rules because of it.

I would not like if PartialOrd became "as strict as possible without breaking IEEE 754", while at the same time it doesn't support any other, similar approaches.


My personal favorite would perhaps be to have NaN and one(!) infinity (which covers both positive and negative infinity), where infinity is greater than and smaller than any other value. But that would certainly break everything :speak_no_evil:. (And I'm sure there are other bad implications. It also can't be implemented with PartialOrd.)

Meta: To some people this discussion might seem useless or "not relevant" regarding practice. I'd like to give a short statement, why I find these issues worthy of discussion.

We have IEEE 754, so we gotta deal with it. We have operators such as ==, <, or >=. Rust uses some traits in std::cmp to allow us to modify the behavior of these operators.

Because we know these operators from math, we sometimes assume certain properties that aren't true in case of programming. Nobody really wants to change behavior in regard to floating point numbers. Yet we need traits to allow operator overloading. Defining how these traits are defined (and which requirements implementations must fulfil), will have an impact on how we can overload these operators in practice.

Adding additional axioms to PartialOrd, for example, would impose practical restrictions on how operators can be overloaded in future.

Also: Using wrong mathematical terms in the docs can confuse developers when doing their daily (practical) work.

Funny: My personal conclusion from this example would've been to want a third kind of zero and a third kind of infinity, both with unknown sign. The new zero compares equal to the existing two, the new infinity is equal to itself and not comparable to other numbers. Absolute value operation converts either to their positive counterpart. Addition or subtraction resulting in exactly 0 would yield this zero of unknown sign; taking 1/x (reciprocal) converts between the zeroes and infinites of corresponding signage. Adding finite numbers to unknown-sign infinity keeps unknown-sign infinity; adding it to other infinities would be NaN; adding it to itself would ... hmm, probably be NaN? But then 2*x is significantly different from x + x (I believe currently they are quite similar, perhaps even equivalent). I guess there's a reason for not having such a thing. Also the most straightforward bit-representation of a third kind of zero is not quite clear. The current approach of using all-zero-bits for +0, and the same thing with the sign bit set for -0 is quite neat; and there is only one sign-bit.

If there aren't any algorithms using PartialOrd bounds anyways, it might be an option to say "this trait requires no conditions, but many implementations follow at least these properties (...list of properties, including transitivity of <=)". Perhaps similarly for PartialEq. I don't think there really are any function in std with PartialEq bounds or PartialOrd bounds, are there? The main intention of these traits would then be operator-overloading.

Oh God no!! :slight_smile:

The two kinds of zeros already cause problems and aren't very useful.

They are a kind of "interval arithmetic" but only in one special case, which is strange. If one wants interval arithmetic, one should do proper interval arithmetic rather than such hacks for some special cases.

1 Like

I had such thoughts in past too.

But yeah, it might cause more trouble than it resolves.

I think this is too lax, but not sure.

There are certain guarantees which programmers want to rely on. Most importantly:

  • (a < b) == (b > a)
  • (a.partial_cmp(&b) == Some(Equal)) == (a.eq(&b))

If these basic assumptions aren't true, working with those operators (or .partial_cmp) might be a hassle. Not sure about the following though (which currently hold, unless someone does something nasty to quickly sort their f64's :wink:):

  • (a < b || a == b) == (a <= b)
  • a < b && b < c implies that a < c

Note that relaxing the latter would mean that PartialOrd isn't a partial order anymore in regard to < and >. But that's just a naming issue.

I could follow if you said: Either

  • relax the requirement on transitivity of < in PartialOrd

or

  • demand transitivity of <= as well (which seemed to be the same as demanding that a == b && b < c implies a < c (and a < b && b == c implies a < c).

I can see that demanding transitivity for < but not for <= feels odd. So maybe here the solution could be to make PartialOrd not require transitivity for <. That would keep more freedom regarding operator overloading. But then the name PartialOrd would be wrong(er); plus it would formally be a breaking change.

I feel like the best solution is to keep things as is, but adding some important hints to the documentation:

  • Add a note to PartialOrd that only < and > but not <= and >= are guaranteed to describe a partial order. (We owe this to the readers because "PartialOrd" really implies there is a partial order.)
  • Add a note to Ord that it denotes either a "total order" or a "weak order" depending on the semantics of Eq's implementation. (This helps people to understand that some elements which are not the same might still be non-distinguishable in regard to ordering, e.g. if you had something like struct ByArea { rectangle: Rectangle }.)

To give an example why calling Ord being a "total order" can be misleading:

fn main() {
    let r1 = Rectangle { width: 5, height: 4 };
    let r2 = Rectangle { width: -10, height: -2 };
    let mut vec1 = vec![ByArea {rectangle: r1}, ByArea {rectangle: r2}];
    let mut vec2 = vec![ByArea {rectangle: r2}, ByArea {rectangle: r1}];
    // vec1 and vec2 only differ in regard to ordering of their elements.
    // Now we will sort them according to a "total order" (as the docs say):
    vec1.sort();
    vec2.sort();
    // Yet vec1 and vec2 have different contents:
    assert_eq!(vec1[0].rectangle.width, 5);
    assert_eq!(vec2[0].rectangle.width, -10);
}

(Playground)

P.S.: I first made up that example using f64 instead of i32 just to figure out (once more), that I can't .cmp two floats in Rust.

NaN is kind of "null for floating point values". So if there was to be a "rust 2.0" where major breaking changes is not a problem, I would do it like this:

  • A f64 cannot be NaN.
  • An Option<f64> takes as much space as a f64.
  • There is no f64 in repr(C), only Option<f64>.
  • Any function where IEEE would make it possible to return NaN should return an Option<f64> (or possibly a Result<f64, Something>).
  • Everything that is implemeted for f64 should be implemented for Option<f64> too, in a way that propagates None similar to how IEEE propagates NaN.
  • Same for f32 and f128.

This would of course be too big a change to even consider now. But maybe it could be introduced as a separate type (say, r64, for a 64 bit real number, though misleading, since my suggested type would still have +∞ and -∞ as special values).

Then we should have impls of From<r64> for f64, From<Option<r64>> for f64, TryInto<r64> for f64, making the new types interoperable with the old (and with f64 in FFI).

2 Likes