37

The Python language has a neat feature: An expression like x < y <= z is interpreted, according to mathematical convention, as equivalent to x < y and y <= z. Operands are evaluated only as many times as they appear, and the short-circuiting nature of the equivalent operation is maintained. For example,

if f() <= g() < h() <= i(): whatever

behaves as if the code were

x1 = f()
x2 = g()
if x1 <= x2:
  x3 = h()
  if x2 < x3:
    x4 = i()
    if x3 <= x4:
      whatever

(Thus h() and i() aren't evaluated unless necessary.)

As far as I am aware, the first language to support this was CPL, as demonstrated on page 6.1.3 of the CPL working papers. However, CPL was never implemented fully, so it could be argued that the feature was never supported, per se. Were there earlier examples?

texdr.aft
  • 3,495
  • 1
  • 19
  • 42
  • 1
    This is something I am very surprised Algol 68 didn't have. I don't think it would have been too un-orthogonal, especially if the requirement of short-circuiting were dropped. Speaking of Algol 68, spot the various kinds of “displays” in the linked Python documentation. – texdr.aft Jun 23 '21 at 06:14
  • 1
    I don't see how it'd really fit in the syntax, myself -- a < b < c calls for op <(bool, int). And if a < b < c were legal, why not a < b < c < d? – dave Jun 23 '21 at 11:58
  • 23
    As another-dave said, just because Python (or Cobol) implemented it doesn't mean they should have done. It's a bug factory in non-trivial situations – alephzero Jun 23 '21 at 12:50
  • 19
    I bet you've never written an in-fix expression parser. A parser that knows that a < b < c means something entirely different from (a < b) < c or a < (b < c) will be more complicated—harder to write, harder to understand—than the parsers used in other programming languages. And, if it's harder for a program to parse the expression, then it's harder for a human to understand it too. Of course, once you've learned what it means, then it's easy enough to see, but before you could "see" it you had to learn one extra thing about Python expressions than you didn't learn for other languages. – Solomon Slow Jun 23 '21 at 14:07
  • 3
    @SolomonSlow: Chaining can be accomplished by having a comparison operator yield a result which isn't Boolean, but rather a special type which is provided for by special overloads of comparison operators, and is implicitly converted to Boolean when used in the context of an assignment, conditional expression, or parentheses. – supercat Jun 23 '21 at 15:44
  • 1
    @SolomonSlow I'd love to give a +5 for that comment. Though, I must add that there are multiple ways parentheses could be understood in above context - adding even more philosophical fireworks :)) – Raffzahn Jun 23 '21 at 16:19
  • 5
    @Solomon By that argument the grammar for say MIPS assembly is far easier to parse than that of any more complicated programming language (you don't even have to worry about ifs! just comparisons and jumps, great!). Hence by your argument it means that assembly is the easiest possible language for a human to understand. – Voo Jun 23 '21 at 16:50
  • 17
    @SolomonSlow I don't agree it's "one extra thing" you have to learn. In fact (from experience of teaching this) it's the opposite: it's what learners trying to write this code in other languages do before they learn how to do it the other, less intuitive, way. – Jack Aidley Jun 23 '21 at 16:52
  • (Note that I'm not arguing about whether that feature is valuable or not, just that the given argument doesn't work, because you could apply it to every single higher-level abstraction. Given how this abstraction maps closer to how you'd write such a thing in math, it doesn't strike me as very confusing though) – Voo Jun 23 '21 at 16:52
  • 13
    @Voo, Good point. A Python program actually is much harder to understand than an assembly language program if your goal is to understand what it's going to instruct the hardware to do. But, the Python code is much easier to understand if your goal is to understand it at the level of the problem domain. I will have to think on this. – Solomon Slow Jun 23 '21 at 17:02
  • @Voo Interesting argument, and I'm quite inclined to agree that "Assembler is the easiest possible language for a human to understand" as my experience does support this. Not at least as all features of an Assembly language are finite and, more important, fully defined, thus without any doubt. :)) – Raffzahn Jun 23 '21 at 17:03
  • @supercat, You're right. Here's how to make it work: A numeric literal has a left value and a right value that both are equal to the number, and it has a truth value that is True. The expression a<b inherits its left value from a, inherits its right value from b, and it's true if; the right value of a is less than the left value of b, AND the truth value of a is True, AND the truth value of b is True. – Solomon Slow Jun 23 '21 at 17:10
  • 1
    @Voo: Assembly language often involves fewer semantic corner cases and interdependencies than other languages. Some aspects of instruction behavior may be unspecified, but if an assembly-language instruction executing in one thread one loads a register from a memory location while it is being written in another, the load will either yield an old value or the new one; in C, however, such an occurrence may yield behavior which isn't consistent with any value the memory could have held. – supercat Jun 23 '21 at 17:13
  • @SolomonSlow: For transitive operators, that's a good approach. For non-transitive operators, things get more complicated. For example, a < b == c <= d not only implies a < b, b == c, and c <= d, but if comparisons are transitive, it would also imply that a < c, b <= d, a < d, etc. whether or not code explicitly tests those conditions. As an alternative to chaining, I wonder if languages could benefit from "push" and "pop"operators, so if $ is push and @ is pop, @ would yield whatever value was pushed by the previous unmatched $ in the expression, in source-code order. – supercat Jun 23 '21 at 17:24
  • 1
    @supercat - I rather suspect the counter to that is "have you tried that on Alpha?". I don't have a specific example, but Alpha was notoriously full of snares with respect to memory consistency. – dave Jun 23 '21 at 17:26
  • 3
    Sigh! Now I remember why I used to like Lisp. In Lisp It would be trivially easy to extend the meaning of < such that (< a b c ...) would be true if and only if a<b AND b<c AND... – Solomon Slow Jun 23 '21 at 17:28
  • @another-dave: On the Alpha, it may be hard to predict whether a read would yield an old value or a new value, but it would yield some value that the memory has held at some point since the last hard memory barrier. In C, lvalues may not work that way if not qualified as volatile. For some targets, gcc will sometimes "optimize" something like unsigned short temp = *p; return temp - (temp >> 15); into return *p - (*p >> 15);, which loads the value twice and may behave oddly if the two values differ. – supercat Jun 23 '21 at 17:34
  • Not on Alpha, but I'm also wondering about processors that support unaligned access of a datum that straddles two cache lines. – dave Jun 23 '21 at 17:40
  • 2
    @SolomonSlow - but that seems little different to defining less(a, b, c) in more conventional notations. Maybe you don't notice in LISP since everything is prefix, and elsewhere the distinction between infix and prefix is obvious? – dave Jun 23 '21 at 17:42
  • @another-dave: C compilers are allowed to invent reads (of non-_Atomic / non-volatile objects), specifically because a data-race on a plain object is undefined behaviour. LWN: Who's afraid of a big bad optimizing compiler? A safe program won't be reading *p while another thread could be modifying it in the first place. Otherwise yes, even in asm without invented reads you can get tearing, and even store-fwd a value that was never globally visible for another core to have loaded: Globally Invisible load instructions – Peter Cordes Jun 23 '21 at 17:53
  • @another-dave: At the CPU level, an ordinary read of a datum that spans multiple words would typically be expected to behave in a fashion consistent with choosing the contents of each word in possibly-independent fashion from among the values that it has held since the last hard memory barrier. – supercat Jun 23 '21 at 19:50
  • @PeterCordes: The authors of the Standard gave the principle that the Standard must not define the behavior of action that might be affected by a possibly-useful optimization priority over the principle that the Standard should avoid characterizing generally-useful operations as UB. If a program's needs could be satisfied by having a read yield any value that an object has held since the last memory barrier or will hold before the next one, having an ordinary read behave with such semantics may allow more efficient code generation than requiring that a programmer demand stronger semantics. – supercat Jun 23 '21 at 19:55
  • @PeterCordes: If, for example, code which is executing in a trusted context needs to do something with a buffer received from an untrusted context, and code in that context asynchronously writes the buffer while the trusted code is using it, saying that the trusted code might arbitrarily use old or new buffer contents would be acceptable, but having the trusted code jump the rails would not. – supercat Jun 23 '21 at 20:20
  • @SolomonSlow Indeed, that's how <, =, >, <=, >=, and /= work in Common Lisp (along with char<, char=, char>, char<=, char>=, char/=, char-lessp, char-equal, char-greaterp, char-not-greaterp, char-not-lessp, and char-not-equal). – texdr.aft Jun 23 '21 at 20:21
  • @another-dave Regarding Algol: It would have to be treated as exceptional (“chained op <”), but otherwise the evaluation rules need not change. (Whereas short-circuit Boolean operators were left out because there would be no way to define them in the language without major changes.) – texdr.aft Jun 23 '21 at 20:24
  • @Voo: Though, one difference is that when you “write such a thing in math”, the arrowheads tend to all point in the same direction. Whereas Python allows things like f() < g() > h() <= i(). – dan04 Jun 23 '21 at 21:09
  • 4
    @Solomon Sure, but I'd say in general what one is interested in is a more high-level "what does this program do?" and high-level constructs while generally harder to parse, can be very intuitive to humans. Not that there aren't any horribly confusing higher-level constructs (C++ is full of them), I'm just arguing against the notion that something that is complex for a computer is necessarily complex for a human to interpret. – Voo Jun 23 '21 at 21:35
  • 4
    @dan04: More generally, in math such constructs are used with operators that behave transitively, so that given X op1 Y op2 Z, one will be able to tell, based upon the choice of operators, that either X op1 Z or X op2 Z will be true. It would thus make sense to combine e.g. X==Y!=Z or X!=Y==Z, but not X!=Y!=Z because the latter expression implies nothing about whether X==Z or X!=Z. – supercat Jun 23 '21 at 22:27
  • 4
    I have no problem with a < b < c, but things go down hill when you allow a > b < c, and further down hill when you start including in/is (and their negated counterparts) in the chaining. I don't think anyone ever wanted to write a < b is c. – chepner Jun 24 '21 at 15:51
  • I remember, when looking into details of the languages that are the direct ancestors of C, that this feature was present but I don't recall how far back. You mentioned CPL, but I seem to recall that it was already a feature it carried over from its ancestor. – JDługosz Jun 25 '21 at 13:46
  • 1
    Parsing: In Perl6, it's easy since the truth value of an expression can be separate from the normal value, rather than implied (e.g. non-zero is true). So the < operator when called with 3<x when x is 2 can produce 2 but false. The parser does have to decide which argument to propagate based on context, though. – JDługosz Jun 25 '21 at 13:50
  • 2
    It's a terrible feature and one that should have been strangled at birth. – JeremyP Jun 26 '21 at 09:31
  • @SolomonSlow It is very easy to implement in a recursive-descent parser, actually. – user253751 Apr 19 '22 at 13:50
  • @user253751, "easy" and "hard" are relative terms. If you say that it's easy to do some task, that doesn't mean anything unless everybody understands what other task or tasks you are comparing it to. In my original comment on this thread, I was quite explicit about what tasks I was comparing. – Solomon Slow Apr 19 '22 at 14:16

3 Answers3

59

COBOL

IF X IS GREATER THAN 0 AND LESS THAN 99 ...
user3840170
  • 23,072
  • 4
  • 91
  • 150
dave
  • 35,301
  • 3
  • 80
  • 160
  • 49
    EXCELLENT ANSWER – DrSheldon Jun 23 '21 at 14:26
  • C# is now getting something similar: c is >= 'a' and <= 'z' or >= 'A' and <= 'Z' – md2perpe Jun 23 '21 at 17:11
  • 3
    @wizzwizz4 you've obviously never used COBOL. (It's a great language for record processing.) – RonJohn Jun 23 '21 at 18:44
  • 2
    Just curious, does COBOL support short-circuiting? Could you express, using whatever convention for subroutine calls, an analog of IF X IS GREATER THAN F() AND LESS THAN G() in a way to guarantee that G() is called only if X > F()? – Leo B. Jun 24 '21 at 03:13
  • 5
    Enterprise COBOL 6.3 seems to be always short-circuiting according to the Language Reference: "The constituent connected conditions within a hierarchical level are evaluated in order from left to right, and evaluation of that hierarchical level terminates as soon as a truth value for it is determined, regardless of whether all the constituent connected conditions within that hierarchical level have been evaluated." – piet.t Jun 24 '21 at 07:14
  • @md2perpe In that particular case - due to the different precedence of and and or, I think the older-style (c >= 'a' && c <= 'z' ) || ( c >= 'A' && c <= 'Z' ) is still more readable and doesn't make me think. But if the expression was comprised of only and - or only or then I'd agree with you. Or better yet: Char.IsLetter( c ) ;) – Dai Jun 24 '21 at 13:27
  • 1
    @Dai. You can add parenthesis if you want: c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') – md2perpe Jun 24 '21 at 13:41
  • 1
    COBOL: look how inviting a friendly our language is, you should use it! Python: eff off ya wee heathens, there's too many of ya making crummy programs! – MonkeyZeus Jun 24 '21 at 17:05
  • I thought COBOL didn't have functions. Maybe it just (originally) didn't have parameter passing? – JDługosz Jun 25 '21 at 13:59
  • 1
    Originally, it didn't even have subroutines, exactly. Look up the horrorshow that was PERFORM. The nearest equivalent elsewhere is BASIC's GOSUB, but at least that comes with a distinctive RETURN statement. – dave Jun 25 '21 at 17:04
5

It's also available in BCPL: see section 5.2 of the 1967 reference manual which unlike CPL was implemented and used very widely.

The semantics are described very concisely: each of the relations between pairwise operands must be true, so A < B > C means (A < B and B > C), except that B (one assumes) is only evaluated once.

I also found a 1974 version of the reference manual in which it is stated that the number of times B is evaluated is implementation-defined.

Michael Kay
  • 569
  • 2
  • 6
0

Lisp:

(if (<= (< (<= (f) (g)) (h)) (i)) whatever)

Everything old is new again...

  • 6
    That won't work in any Lisp dialect I know of, unless you've defined > and >= to be macros. On the other hand, (> a b c d) is perfectly valid in Common Lisp, and you could consider it to be a form of chaining. In any case, all Lisp dialects in existence when COBOL was created allowed the arithmetic predicates to accept only two arguments. – texdr.aft Jun 25 '21 at 19:32