Python-like chained comparison operators

Python allows chaining comparison operators as a helpful shorthand:

if a < b < c: # equivalent to (a < b and b < c)
    ...

However Rust explicitly disallows this:

fn main() { 
    5 < 10 < 15 
} 
// error: comparison operators cannot be chained 
// --> chain.rs:2:5 
// | 
// | 5 < 10 < 15 
// |   ^    ^ 
// | 
// help: split the comparison into two 
// | 
// | 5 < 10 && 10 < 15 
// |      ^^^^^ 
// 
// error: aborting due to previous error

rust-lang/rfcs#558 seems to be the source of this explicit disallow, with the primary motivation being confusing behavior: a < b < c used to be interpreted as (a < b) < c , resulting in a type error. The RFC also claims this would allow implentation of Python-like chained comparison operators in the future. Is there any motivation not to add these Python-like chained comparison operators into Rust? Given that the syntax for it has been a compiler error since pre-1.0, it doesn't seem like it could be a breaking change. Thoughts?

6 Likes

This should be valid Rust, but it is compilation error now:

5 < 3 < true

There's no fundamental reason not to allow it, and this change was intentionally forward-compatible with the possibility. It would require a lang project proposal (MCP), and a spec.

The spec should cover things such as limiting the types of operators that can chain; for instance, a < b < c should work, as should a >= b > c, but == and != should not chain, and chains with operators in opposite directions like a < b >= c should not work.

The spec would also need to document the desugaring, which should only evaluate each piece once and preserve left-to-right evaluation order; for instance, a() < b() < c() would desugar to { let temp1 = a(); let temp2 = b(); let temp3 = c(); temp1 < temp2 && temp2 < temp3 }.

Given a project proposal, and a subsequent spec, I don't see any fundamental reason we wouldn't do this. However, the lang team would need to discuss the project proposal and determine if this would be a net win for the language, before the spec went forward.

11 Likes

That is intentionally not valid Rust, to avoid confusing expressions like that (or, much more complex confusing expressions that include type inference). Even if we decided not to allow chained comparisons, I don't think we'd allow that.

6 Likes

What makes you say this? Letting == and != chain would be hugely helpful and I'm not seeing any issues it would cause. Operators in opposite directions may be a little unusual but also don't seem to cause any issues; a < b >= c would desugar into a < b && b >= c, which is a perfectly valid thing to write.

Operators in opposite directions could have a valid desugaring, but that doesn't mean they make for clear code.

== and != is a little more debatable, and personally I'm less attached to that one; I could probably be convinced, given some code that shows how it'd be useful. I'd be much more hesitant to support x == y > z != a > b, though.

6 Likes

"Perfectly valid thing to write" seems necessary but not sufficient. I would guess a < b >= c is much harder to mentally parse than a < b && b >= c, and it's thus not clear to me the additional density is worth it for that case.

8 Likes

I guess that depends on how often code like a < b && b >= c actually occurs. If one had to do this a lot then I could see this syntax be worth the complexity budget. But I don't know if that's actually the case.

1 Like

That's a good point. The most common uses of this syntax I see in Python are either checking that a value lies between two others, or checking that a bunch of values are all equal to each other; mixed direction comparisons don't show up much. Not sure if this is also true in Rust, but I imagine mixed-direction chained comparisons could always be added later if there's demand for them.

2 Likes

I think this is the core of the issue. I feel like the most common cases are things where the are other acceptable ways. For example, (a..b).contains(c) seems like a perfectly acceptable way of writing a <= c && c < b. And I'm fine with a <= b <= c <= d not working, since I can write it fine as [a, b, c, d].is_sorted(). Similarly, a < b > c is b > max(a, c), which I like as a way to write it anyway, plus it avoids weird generics-looking things.

So maybe there aren't amazing ways of writing mixes of these things, like if for some reason I needed a <= b < c <= d, but that case seems so unlikely that I don't care.

7 Likes

I guess that a == b == c is the case which brings no doubts about the meaning and could be allowed. I can yet imagine a < b == c < d (I'd tell that it is understandable as well). On the other hand, chaining != seems to me a bad idea. So, I agree that chaining == and != should be considered very carefully.

Judging from the examples, I think that chains that look like ordered comparing interval boundaries are the case where the chaining works well and where can be useful. The other cases… I think that they are more difficult to parse for a human.

1 Like

This is something that can be discussed as part of a project group working on the problem, ideally with code examples for how various projects would look with this available.

So how does f(a) < g(b) < h(c) desugar? Recall that the arguments to each comparison are evaluated in place expression context.

Unless the std::cmp::PartialOrd::lt operator is overloaded to have side effects, the following probably works:

{
    let fa = f(a);
    let gb = g(b);
    let hc = h(c);
    std::cmp::PartialOrd::lt(&fa, &gb) & std::cmp::PartialOrd::lt(&gb, &hc)
}
2 Likes

Actually it is not :slight_smile:

  • is overloaded == invoked for (a, b) or (b, c) first?
  • does it instead mean this?
   let a = 8;
   let b = 9;
   let c = true;
   
   (a == b) == c

...I've seen people write something similar in Java

1 Like

Note: Python "desugars" this as

{
    let fa = f(a);
    let gb = g(b);
    std::cmp::PartialOrd::lt(&fa, &gb) && std::cmp::PartialOrd::lt(&gb, &h(c))
}

(that is, lazily evaluate h(c)). This directly matches "what I would write by hand" if efficiency matters, and is maybe more analogous to Iterator::is_sorted or Generator::is_sorted. The actual desugaring should cover f(a) < g(b) < h(c) < i(d) < ... (and be laziness all the way down).

I am in favor of this proposal. I always thought the python chained comparators were pretty intuitively reasonable; I don't think it blows the complexity budget in that regard. The motivation (make some chained comparisons a little nicer) is not super pressing, so it probably more needs a motivated person to push it all the way through.

8 Likes

I've definitely done things like (a == b) != (c == d) in C#, because != is ^.

3 Likes

(Slightly off topic, but nice job to the compiler and team for providing an excellent diagnostic on this error)

13 Likes

also cc https://github.com/rust-lang/rfcs/issues/2083

I'm against this, especially with the short-circuit behavior:

  • In my experience, the problem of chained comparisons just doesn't come up. And even if it does, there's no significant inconvenience in writing a < b && b < c.
  • So far all the comparison operators have been eager. Making them conditionally lazy requires such a mental shift that I would consider it breaking (even though it technically may not be), and I definitely put it into the "too much clever terseness just for the sake of terseness" category.
14 Likes
  • So far all the comparison operators have been eager. Making them conditionally lazy requires such a mental shift that I would consider it breaking (even though it technically may not be), and I definitely put it into the "too much clever terseness just for the sake of terseness" category.

How could it be breaking if chained comparison did not exist.

As demonstrated before, for Python and Raku (Perl 6), in a < b < c when a < b is false the next expression c will not be evaluated. After all, a < b && b < c won't evaluate b < c either when a < b is false. So always eagerly evaluating c is the mental-shifting breaking change.

There are other languages with chained operators too.

In Julia the evaluation order is undefined (bleh), e.g. a < b < c in Julia will first evaluate (eagerly) b, then (eagerly) a, and finally (lazily) c. This breaks the left-to-right order guarantee of Rust, but the conditional laziness is still there.

Scheme and Clojure's comparison operator can accept multiple values e.g. (< a b c) where all of a, b and c are evaluated eagerly. But < isn't a special operator in these Lispy languages and thus not really applicable to Rust.

3 Likes