Reverting UNTIL, and adding WHILE-NOT and UNTIL-NOT

Long ago, there was a fervent debate about whether UNTIL was a word that was "naturally paired" with WHILE...like IF and UNLESS. Hence, should UNTIL be an arity-2 function which was effectively WHILE-NOT, instead of its arity-1 form.

The change was introduced in Ren-C to try it. But as an interim solution before the name was truly reclaimed, while-not was added. The intent was to go in and replace it with UNTIL later, after the change had settled. LOOP-WHILE and LOOP-UNTIL were the arity-1 natives.


I've come to believe the interim solution should be the final solution. Hence UNTIL is reverted to its previous meaning, with WHILE-NOT being an arity-2 native, and UNTIL-NOT being an arity-1 native.


UPDATE: October 2020: There is now an even more general solution with predicates. You can write until .not [...] or until .even? [...] or until .not.integer? [...]... the predicate will be applied for the purposes of the test, but you'll be returned the last loop result unmodified! It's not clear whether predicates will be added to WHILE...but they may play some role with it.

To make a long story short: I guess I don't think that it wound up making that big a difference in the scheme of things. The primary impact on me personally, was actually just making code golf programs longer. (Having U be UNTIL is an appealing option.)

When you look at forever [...] and until [...], they seem to go together sensibly, and you might ask--as some have--if it's WHILE that's weird. If you think about WHILE and UNTIL as being two words offering entry points into loop space, it's like it's giving you two common ones with common words.

Plus I didn't wind up liking loop-while and loop-until as much as I thought I would...and I actually didn't end up minding while-not or until-not.

So here in the time of analyzing changes for goodness or badness and trying to pin things down, I think the status quo is the better answer. Plus, when there are so many other slam-dunk good changes, this one is more a distraction than anything else.

So DocKimbel/Rebolek/Oldes/etc. get to win on this one.


Note: The reason for having WHILE-NOT and UNTIL-NOT natives isn't just performance (though they are faster than having to run each condition through a Rebol NOT in the evaluator). They also avoid a precedence issue you get in the new workings of things like equality tests, where while [not x = 10] [...] is read as while [(not x) = 10] [...]. If you use them, you can take advantage of the fact that your condition is "already grouped" by its block, and not worry about parenthesizing it further.

(Note that interpretation of not and = is the common one in languages with precedence... = is lower than ! in C for instance, and you're expected to use !=. So it shouldn't surprise most people, but since it's not coming from precedence, see more about why it happens in: TO NOT B OR TO NOT (B)…is…no longer a question)

Here is the old argument for LOOP-UNTIL and LOOP-WHILE from the Trello, for historical reference.

The points are still pretty reasonable, and I don't think it was any kind of disaster. It just didn't move the needle very much...some things looked better, some things less good, it was near break-even. And with WHILE-NOT and UNTIL-NOT I think the mechanical arguments for having all 4 variants were covered.

There are simply bigger fish to fry than this particular change.

Historically Rebol has had a WHILE construct that takes two arguments...a condition block and body block:

x: 0
while [x < 3] [
    x: x + 1
]

The condition is evaluated first, before the body is ever run. Then alternately the body is evaluated and the condition re-evaluated until the condition comes back conditionally false (e.g. BLANK!, FALSE). At that point, the return result of the WHILE expression is the last evaluation product of the body. (3 in the case above.)

Under the hood it "takes turns" ramping up the evaluator twice each loop cycle on two different blocks. In a sense this can be seen as making the loop itself somewhat less efficient than if it had been chosen to be single-arity, and merely testing the result of a single block evaluation. Ren/C adds this more efficient construct as LOOP-WHILE:

x: 0
loop-while [
    x: x + 1
    x < 3
]

Using the prefix LOOP- is abstractly filling in the directive of "make a loop" or "turn a loop", to help inform what to do in lieu of any supplied body (the usual expectation being for while loops to have one). Other choices might be noop-while, continue-while, cycle-while etc. But LOOP- seemed the best prefix.

(Note: It is unrelated to what the command or dialect LOOP winds up meaning.)

The relative efficiency of LOOP-WHILE might lead one to ask if it deserves the WHILE name and should replace it entirely. There are several arguments for keeping the two separate constructs, plus keeping WHILE as-is:

  • LOOP-WHILE forces the test after the "body code"...so the "body" will run once before the test, which you may or may not want
  • LOOP-WHILE cannot return the last result of evaluating the "body" (which is usually more interesting than returning the result of evaluating a condition for a while, given the only possibilities for that are FALSE or BLANK!).
  • WHILE is ubiquitously used in arity-2 form in existing code...by nearly every person to ever use the Rebol language.
  • WHILE's visual separation of the condition from the body with separate blocks serves a communicative purpose which helps drive its popularity.

It's also worth noting that LOOP-WHILE may be more efficient when standing alone. But it's not more efficient if (for instance) you are dealing with condition and body blocks that aren't already in independent variables. The following will be measurably slower than just while condition body (and may behave differently, and return a different result!):

x: 0
condition: [x < 3]
body: [x: x + 1]
loop-while [
    do body
    do condition
]

This understanding of WHILE informs Ren/C's accompanying introduction of an arity-2 UNTIL to match:

x: 0
until [x = 3] [
    x: x + 1
]

It runs the condition before the first execution of the body, and returns the last evaluative result of the body before the condition became true.

There is also a LOOP-UNTIL:

x: 0
loop-until [
    x: x + 1
    x = 3
]

This makes LOOP-UNTIL a synonym for what Rebol2 and R3-Alpha just called UNTIL:

x: 0
until [
    x: x + 1
    x = 3
]

The reasons for retaking the name UNTIL are:

  • every new "non-guru" user (and many of the other "guru" users) found Rebol2's UNTIL to be an inconsistency of the words WHILE and UNTIL. They suggest a matched pair like IF and UNLESS.
  • A two-arity UNTIL is genuinely useful in its own right, for the same reasons that WHILE is, including bringing more efficiency to some scenarios than LOOP-UNTIL
  • A two-arity UNTIL is genuinely a natural fit for readability in many situations by separating the condition and body blocks.
  • Single-arity UNTIL's popularity "in the wild" is vastly lower than WHILE, making it less disruptive to change.
  • It is not difficult for codebases which worked with the old definition to work around it with a very simple until: :loop-until, pending a simple search-and-replace to fix the usages.

Given the enfix covenant, this is no longer true either. And the performance difference is negligible; certainly not the most important thing to be focusing on...as opposed to just making NOT faster systemically!

So as the knife falls on other superfluous changes...so it does on WHILE-NOT and UNTIL-NOT. But doing so leads me to think it's quite a good thing to get rid of these distractions, to focus on the much more consequential, much more obviously good changes...including things like the loop control protocol!

Welp, two years down the road...it seems I may have spoken too soon. (Which would be good, as I don't want them winning anything. :japanese_ogre:) The intuition on WHILE/UNTIL parity may have been correct, but to make them match as arity-1 operators instead of arity-2 ones.

There are some very solid arguments for why ANY should be replaced with WHILE in PARSE. And that WHILE is appropriately arity-1.

So now I'm thinking the right answer here may be to fold WHILE in with another arity-2 operator. LOOP seems like a decent candidate, and it would make it more deserving of its short name:

loop 3 [
   print "looping"
]

x: 0
loop [x < 3] [
    print "looping"
    x: x + 1
]

Interpreting a block as code is only one choice, though. I'd suggested there might be some kind of dialect interpretation of blocks with looping constructs, like:

>> for x [1 to 3] [print [x]]  ; or REPEAT...just some arity-3 operation
1
2

>> for x [1 thru 3] [print [x]]
1
2
3

So if LOOP interpreted a block argument as code that it was evaluating for truthiness or falseyness, that would seem contentious.

But is it actually contentious with an arity-2 operator, that has no variable specified for values to go into? How often do you have a generator dialect that's spitting out values and you are completely ignoring those values, but just want to run a loop each time one is emitted? Practically never.

The value of being consistent with PARSE's WHILE seems very high, and I think I may have underestimated how useful arity-1 WHILE is. It fills in a missing piece.

while [process-messages]
; ^-- why should you have to write `until [not process-messages]` or
;     `while [process-messages] []`

Making current LOOP take on the arity-2 WHILE has the feeling of destiny. Does anyone disagree?

1 Like

Giving it a try, I realize that I don't care for the polymorphism of not knowing if something is a block of code or not. :-/

; Is X a number?  Is it a block that runs code?
;
loop x [...]

But the replacement for WHILE has to be a short name, and there aren't that many to choose from.

(Note: Since I'm doing Rust programming these days, I'll mention that it uses LOOP for CYCLE ("FOREVER").)

I'd actually suggested at some point that the "rote" and "robotic" term of REPEAT seemed like the best choice for the version that passed no parameter to the body.

repeat 3 [
    print "Two guys are sitting on a fence, Pete and Re-Pete."
]

That transition has been in the works for a while, with COUNT-UP and COUNT-DOWN taking the place of REPEAT. With that done, REPEAT can take LOOP's current behavior... LOOP can take WHILE's behavior...and WHILE can go on to match between DO and PARSE.

1 Like