17
$\begingroup$

Some languages, such as Python, support chaining comparison operators, such that x < y < z is equivalent to x < y && y < z. This is handy syntactic sugar, and since (x < y) < z (comparing the boolean result of x < y to z) is almost never useful, it would seem to have little cost.

However, many modern languages lack support for this, including Rust, Go, and Kotlin.

What downsides/pitfalls do chained comparison operators have, and/or what justifications do existing languages cite in not including them?

$\endgroup$
4
  • 1
    $\begingroup$ Related, probably not duplicate: Unambiguous syntax for a ternary comparison operator $\endgroup$
    – Michael Homer
    Jul 2, 2023 at 3:08
  • 1
    $\begingroup$ I don't know if this counts as a pitfall, but x < y && y < z isn't quite equivalent because it evaluates y twice. If you do have this feature then you need to ensure that y is evaluated only once. $\endgroup$
    – kaya3
    Jul 2, 2023 at 3:30
  • 1
    $\begingroup$ @kaya3-supportthestrike Another slight inequivalence is that you'd presumably want to ensure x < z too if you allow arbitrary implementations of comparison operators $\endgroup$ Jul 2, 2023 at 3:36
  • 1
    $\begingroup$ Instead of being syntactic sugar, <, and friends could return their RHS wrapped in a type that can be cast to a boolean. Same applies to = and . In fact Goal-Oriented Languages like SNOBOL, Icon and Verse, where first-class success/failure logic replaces boolean logic, work this way and generalise it. $\endgroup$
    – Longinus
    Jul 3, 2023 at 14:59

5 Answers 5

9
$\begingroup$

One thing I think Python got wrong in this regard, is that they made this work with too many "comparison operations". Sure, x < y < z is common to write in mathematics, but x < y == z looks kind of weird, and x is y not in z is probably not useful to anybody.

The reason I say Python got this wrong is that empirically, a lot of people are confused about it. Chained comparisons are one of the most recurring Python questions on Stack Overflow; currently, the canonical question has 145 upvotes and 52 duplicates. I think there are even more questions which are indirect duplicates. In almost all of those questions, the user has thought that a OP b OP c must mean either (a OP b) OP c or a OP (b OP c), and been surprised that it means neither; understandably so, because it would be true for all other operators.

What could Python do differently? I think chained comparisons like x <= y < z make the most sense when the two comparisons are both inequalities in the same direction, either both < or <=, or both > or >=. Other comparison operators like ==, !=, in, not in, is and is not should not get this treatment.

On the other hand, if chained comparisons are limited this way then most use-cases could be served instead by testing membership in a range or interval, e.g. y in range(x, z). So, if you have suitable types in your standard library for representing intervals, then you might not need comparison chains at all. This still leaves longer chains like x < y < z < w, and cases where computing z might be expensive so you want to skip computing it when x < y is false; but these are edge-cases, so I wouldn't worry too much about them as a language designer.

This doesn't mean that x < y < z should mean (x < y) < z instead; as you mention, that's generally useless. My approach in my language MJr is to make this expression a syntax error: the comparison operators are neither left- nor right-associative. This requires no special treatment in the parser ─ just treat both operands like you treat the right operand of a left-associative operator or the left operand of a right-associative one ─ but I did decide to add a specific error message to say that chained comparisons are not supported.

$\endgroup$
16
  • 8
    $\begingroup$ I disagree when you say that ==/is should be excluded. Sometimes, it can be useful to check that a = b = c, and I don’t like having to arbitrarily choose between a = b AND b = c vs a = b AND a = c. (I’ve heard that in some SQL implementations, which pairs you choose can drastically affect performance.) $\endgroup$
    – Bbrk24
    Jul 2, 2023 at 3:32
  • 1
    $\begingroup$ @Bbrk24 The problem is that beginners often write x < y is True or x < y == True, which would be correct if it meant (x < y) is True, but it doesn't. $\endgroup$
    – kaya3
    Jul 2, 2023 at 3:34
  • 5
    $\begingroup$ Then just make it its own group, the same way you suggest separating < from >. $\endgroup$
    – Bbrk24
    Jul 2, 2023 at 3:36
  • 1
    $\begingroup$ @Bbrk24 You could, but you would have the same problem with x == y is True, or x in y in z where z is a list of booleans (yes, I have seen someone write that and wonder why it didn't work). The case for x == y == z is strongest, but personally I don't find anything wrong with x == y and y == z; or you can write len({x, y, z}) == 1. $\endgroup$
    – kaya3
    Jul 2, 2023 at 3:38
  • 1
    $\begingroup$ y in range(x, z) is okay, but it lacks the syntactic clarity of x<=y<z and immediately becomes fiddly and ugly if you want x < y <= z or whichever variant isn't immediately covered by whether the particularly implementation of range is open, closed, or half-open. $\endgroup$ Jul 2, 2023 at 11:28
7
$\begingroup$

Generally speaking, I'd say it's always best if a feature is first class instead of ad-hoc. It's easy to have first-class (2-arg.-infix) comparison operators, but chained comparison operators can only be done as an ad-hoc rules in most languages.

What I mean here is, a normal < operator is simply a function with type (Int,Int) -> Bool (or some polymorphic version of this) and some infix precedence. None of this really requires special parser handling: infix operators are pretty much universal because (unless you're a Lisper) you certainly want + and * with the normal precedences.

That means it is also fairly obvious how these operators should be overloaded or passed to higher-order functions (as long as you know how that is generally done in your language). It's not impossible to do that kind of thing with chained operators, either, but it's certainly a lot less obvious what's actually going on. The way Python does it is quite ad-hoc, in that it desugars the chain of comparisons to binary calls to __lt__ connected with and operators. Why use those in particular? What if somebody would like to use this with another type instead of bool? It gets all very confusing.

You might argue that the special case of simple boolean-valued comparisons is important, "canonical" enough to warrant special-case handling. Perhaps it is, but it's always best to make do without special cases.

I'd remark that there is at least one language that supports chained comparison "naturally", without a special syntax rule: in Verse, there is no boolean type at all. Instead, comparison operators either succeed or fail, which are concepts the language has hard-baked, and having a chained comparison simply means you twice compare and each time it might fail.

The language designers themselves aren't quite sure whether that's just a cute quirk of the language, or actually a good feature...

https://www.youtube.com/watch?v=832JF1o7Ck8?t=1262

Another thing I'd claim is that chained comparison are pretty much useful for only one thing: testing whether a number is within an interval. Sure that's a fairly common thing to do, but wouldn't it be better to extract that general concept? You just need an operator (or some ASCII rendition) which checks whether a variable is inside some set-like thing and a suitable class of set-like things including ranges (which for example Python does in fact already have).

$\endgroup$
5
  • $\begingroup$ python's b in range(a, c) is only equivalent to a < b < c if a,b,c are all integers, though. There is no continuous range for floats. Module sympy does have an Interval type but it's heavy artillery. $\endgroup$
    – Stef
    Jul 2, 2023 at 18:46
  • $\begingroup$ @Stef well, Python has chained comparison and uses range mosly for looping. My point was that if a language really wanted to support interval checks in a nice way, it could easily do that without chained comparisons. $\endgroup$ Jul 2, 2023 at 18:50
  • $\begingroup$ Isn't a bool just "succeed or fail"? I can't see any advantage whatsoever to allowing comparison operators to return non-bools $\endgroup$ Jul 2, 2023 at 19:12
  • $\begingroup$ @RydwolfPrograms a bool is just a type with two values. What meaning is assigned to these values depends; often true is actually the failure-ish case (e.g. classical 0 = exit_success). Also, whether you like it or not, comparison operators can and do often return things other than booleans. For example in Matlab and Numpy, comparisons of arrays/matrices give a mask as the result. In proof assistants, comparison operators may yield a Prop rather than a Bool. Etc. etc.. $\endgroup$ Jul 2, 2023 at 20:28
  • $\begingroup$ speaking of Lisp, comparison functions are variadic (< 3), (< 3 4), (< 3 4 8 7) so it is quite natural too $\endgroup$
    – coredump
    Jul 13, 2023 at 11:44
2
$\begingroup$

For what it's worth, modern C# allows this syntax as part of its pattern matching features:

public static bool IsLetterOrSeparator(this char c) =>
    c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '.' or ',';

That's an extension method (signified by the this keyword) that extends the char type. It returns true if c is between 'a' and 'z' or between 'A' and 'Z' or a period or a comma. Personally, I think it's more readable than the "chained operators" discussed here.

Coming from a C family background (C, C++ and C# - a touch of Java), I'd read x < y < z < w as ((x < y) < z) < w which makes no sense to my eyes.

Reference: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-9#pattern-matching-enhancements

$\endgroup$
1
$\begingroup$

Boolean equality is XNOR. I have (ab)used this before; a language which allows a == b == c would not be able to exploit this.

$\endgroup$
2
  • $\begingroup$ != is XOR on Booleans, too. $\endgroup$
    – kaya3
    Jul 2, 2023 at 3:54
  • 12
    $\begingroup$ You would still be able to do this right? You just need parens: (a == b) == c $\endgroup$ Jul 2, 2023 at 4:04
1
$\begingroup$

My inclination would be to have a comparison operator for type T yield a comparisonResult(T) which encapsulated a Boolean and a T, and have a comparison operator overload which accepts a comparisonResult(T) and an operationYielding(T). For efficiency, operationYielding(T) should be a compiler-internal type distinct from functionReturning(T); functions accepting arguments of that type would generally have their code folded into the calling context in such a way as to avoid having to create any kind of closure object to access automatic objects in the parent context.

$\endgroup$
4
  • $\begingroup$ Does this mean that let b = x > y; would have b be some type other than Boolean? Or would this be contextual? $\endgroup$
    – kaya3
    Jul 2, 2023 at 16:21
  • $\begingroup$ @kaya3-supportthestrike: Interesting question. If Boolean types were not allowed to use comparison operators, then any use of b would either unambiguously require that the type coersion be performed (and its timing would be irrelevant), or that it not be performed (rendering the timing moot). I think I'd handle the situation by specifying that let c = b > z would only be allowed when z was known to be a type that couldn't be compared to a Boolean but could be compared to y, and a compiler had kept the earlier ComparisonResult. If code wanted to chain a comparison using b, ... $\endgroup$
    – supercat
    Jul 2, 2023 at 16:47
  • $\begingroup$ ...it would need to be written as something like let c = cast(ComparisonResult(typeof y,b)(b) > z, which would be rejected unless b was already that type, or as cast(bool,b) > z. I subscribe to the idea that having compilers reject constructs whose intended meaning is ambiguous in the absence of directives that would set default behaviors, is better than having language designers try to guess what such constructs should mean. Incidentally, I'd also (absent config directives) reject e.g. double1 = float1*float2; requiring either double1 = (double)(float)(float1*float2); or... $\endgroup$
    – supercat
    Jul 2, 2023 at 16:53
  • $\begingroup$ ...double1 = (double)float1*float2;. Even if language rules were clear about how a compiler woudl interpret the original construt, and a human reading the code would have no trouble knowing how a compiler would interpret it, a human reading the code would often have no way of knowing how the programmer who wrote the code was intending that it be processed. I'd regard the latter forms as superior to the former if the program cares about which method is chosen, and if a programmer wants "whichever is faster" semantics, there should be a way to indicate that. $\endgroup$
    – supercat
    Jul 2, 2023 at 16:56

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .