FOR-BOTH: Loop Composability For The Win ❗

FOR-BOTH was an early talking point for an extremely simple usermode loop construct that would be built out of two FOR-EACH loops:

>> for-both x [1 2] [3 4] [print [x]]
1
2
3
4

A naive implementation of this in Rebol2 might look like:

rebol2>> for-both-naive: func ['var blk1 blk2 body] [
             foreach (var) blk1 body
             foreach (var) blk2 body
         ]

...but...

  • It will not honor BREAK correctly

    rebol2>> for-both-naive x [1 2] [3 4] [
                 if x = 2 [break]
                 print [x]
                 x = 5
             ]
    1
    3  ; the BREAK only broke the first FOREACH
    4
    == #[none]
    

    There's no way from the outside of Rebol2 or Red's FOREACH to know for sure that a BREAK was requested. BREAK returns NONE!, but a loop body can (and often does) evaluate to NONE! as well. Red made it even worse by adding BREAK/RETURN--so a breaking loop can return anything.

    So you'd need some kind of complex binding to search the loop bodies and bind the BREAK word to something that throws and gets caught...even for this simple goal.

  • The loop won't evaluate to the last result of the body.

    rebol2>> for-both-naive x [1 2] [] [
                 print [x]
                 x * 10
             ]
    1  ; evaluated to 10
    2  ; evaluated to 20
    == #[none!]
    

    If the second series is empty, the fallout from the first loop is forgotten.

Behold Ren-C's Elegant Solution to FOR-BOTH

Quick notes for those coming from historical Redbol:

  • LAMBDA is just FUNC that doesn't have a definitional RETURN (...so body result falls out)

  • Since 'arg unbinds arguments in Ren-C (and ' can modify any parameter convention), @arg means take it literally, @(arg) means take literally but allow parentheses to escape.

  • Terminal slash (paths ending in blank) means "GET but don't execute"...like a historical GET-WORD!...but also ensures it's a function. (Good semiotics since it throws up a "wall" between the word and any subsequent arguments, to indicate it's not taking those arguments.)

  • The ^ operator in this context means "I know what I'm doing: allow this expression to vanish if it returns a void."


  for-both: lambda [@(var) blk1 blk2 body] [
      all [
          ^ for-each (var) blk1 body then lift/
          ^ for-each (var) blk2 body then lift/
      ] then unlift/
  ]

If you prefer English words, the ^ exists as a function called IDENTITY.

for-both: lambda [@(var) blk1 blk2 body] [
     all [
         identity for-each (var) blk1 body then lift/
         identity for-each (var) blk2 body then lift/
     ] then unlift/
 ]

I feel it's easier to "see" the code if you use ^, but if you are allergic to symbols, YMMV. (It's a part that has to be there because FOR-EACH vanishing by default if the loop never runs seems empirically unwise. Although you could disagree and change that, too.)


The strategy here is that we want to LIFT anything that a THEN would react to, leaving "light void" and "light null" in their unlifted states. This lets them serve as the signal for breaking or opting out of contributing to the final loop result:

>> lift ()
== ~

>> () then lift/
== \~\  ; antiform (void!)

>> lift null
== ~null~

>> null then lift/
== \~null~\  ; antiform (logic!)

So when the lift doesn't happen we can get null or void. FAILURE! antiforms will also not trigger the THEN, hence they will escalate to PANIC.

It solves the BREAK case

Below we see a situation where the first FOR-EACH returns NULL (and null then lift/ is just null). So it short-circuits the ALL, and propagates the null as a signal that it broke:

>> for-both x [1 2] [3 4] [if x = 2 [break], print [x], x = 5]
1
== \~null~\  ; antiform (logic!)

Note that the first pass through the loop did not terminate the ALL, just because the body evaluated to null (x = 5 is null). That's because that branch evaluation produced a "heavy null", and LIFT of non-ghost, non-light-null antiforms produces QUASIFORM!, which is a branch trigger even if the antiform thing would not be:

>> ~(~null~)~
== \~(~null~)~\  ; antiform (pack!) "heavy null"

>> lifted: lift ~(~null~)~
== ~(~null~)~

>> type of lifted
== \~{quasiform!}~\  ; antiform (datatype!)

>> if lifted [print "All QUASIFORM! are branch triggers!"]
All QUASIFORM! are branch triggers!

This means the loop can gracefully recover the QUASIFORM! as the ALL result if the loop completes, and remove the quasi level:

>> for-both x [1 2] [3 4] [print [x], x = 5]
1
2
3
4
== \~(~null~)~\  ; antiform (pack!) "heavy null"

It Solves the Fallout From The Last Loop Body

This takes advantage of a new invariant: loops which never run their bodies return voids.

>> for-each x [] [panic "This body never runs"]
== \~\  ; antiform (void!)

Voids act invisibly in constructs like ALL. So we get the result we want:

>> for-both x [1 2] [] [print [x], x * 10]
1  ; evaluated to 10
2  ; evaluated to 20
== 20

You Can Even Return NULL From the Body!

Thanks to isotopes, the following is possible:

>> x: for-both x [1 2] [] [print [x], if x = 2 [null]]
1
2
== \~(~null~)~\  ; antiform (pack!) "heavy null"

>> x
== \~null~\  ; antiform

How cool is that? Even though NULL is being reserved as the unique signal for loops breaking, there's a backchannel for it to escape...out of the FOR-EACH, and up out of the FOR-BOTH wrapping it!

It Holds Up Under Scrutiny!

I'm really pleased with it, and here are some tests:

ren-c/tests/loops/examples/for-both.loops.test.r at master · metaeducation/ren-c · GitHub

I invite you to test it some more...ask questions...and perhaps come up with your own loop compositions!

:atom_symbol:


UPDATE: Prefix Operator THENCE ... and LIFT* / UNLIFT*

It's hard to name a prefix operator that means "If THEN would act on the second argument, run the first argument on it as a handler."

But Gemini suggested THENCE, and I think that is about as good as it gets:

     thence f ( expr )    ≡    ( expr ) then f

So we can write:

for-both: lambda [@(var) blk1 blk2 body] [
    thence unlift/ all [
        ^ thence lift/ for-each (var) blk1 body
        ^ thence lift/ for-each (var) blk2 body
    ]
]

The existence of an elegant postfix formulation should hopefully make people more empathetic to why it's hard to name. I think putting it in the box as LIFT* and UNLIFT* is probably fine:

for-both: lambda [@(var) blk1 blk2 body] [
    unlift* all [
        ^ lift* for-each (var) blk1 body
        ^ lift* for-each (var) blk2 body
    ]
]

I'm just glad it's not what it was at first ("LIFT:LITE" and "UNLIFT:LITE")! (All right, we're here. Let us never speak of the shortcut again...)

4 Likes

Wow! There is no bespoke primitive:

  • no "lift-except-null"
  • no "loop-aware break"
  • no privileged status for FOR-BOTH

Everything is expressed in terms of:

  • THEN interception
  • LIFT/ and UNLIFT/ duality
  • vanishing vs heavy results
  • ordinary combinators (ALL)

That’s the gold standard: new behavior emerges from existing laws.


Let’s analyze why this actually works--despite (or because of) its weirdness.

  • THENCE literally means “from there”, “as a consequence”
  • It is historically paired with THEN
  • It is not commonly used as a verb or operator elsewhere

That last point is a feature, not a bug.

THENCE honeslty communicates:

  1. Temporal / logical relation, not transformation
  2. Causal continuation, like THEN
  3. "This thing is related to THEN, but not identical"

It does not pretend to explain itself.

That matters because: this operator is genuinely hard to explain without understanding THEN.

So instead of lying with a "friendly" name, THENCE says: "I'm part of this control-flow family. Learn the family."

That’s exactly the right posture for a power-user primitive.


The asterisk is doing a lot of honest work here.

It signals:

  • "This is not the ordinary operation"
  • "This is a scoped / starred variant"
  • "You probably shouldn't reach for this until you know why"

Crucially, it does not lie. It avoids naming yourself into a corner. By choosing * instead of a word, you:

  • avoid bikeshedding forever
  • leave room for future refinements
  • don’t canonize a metaphor prematurely

You can teach, cleanly and honestly:

  1. Postfix THEN
    Intercepts rejections after an expression finishes.
  2. LIFT/ and UNLIFT/
    Convert THEN-reactive signals to values and back.
  3. Prefix (scoped) handling
    Exists, but is conceptually “the same thing as THEN, just scoped”.

Once that mental model is internalized, the syntax becomes secondary.

2 Likes