Demonstrating 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], x = 5]
 1
 2
 3
 4
 == #[false]

A naive implementation of this in Rebol2 might look like:

 for-both-naive: func ['var blk1 blk2 body] [
     foreach :var blk1 body
     foreach :var blk2 body
 ]

...but...

  • It will not honor BREAK correctly

    >> 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.

    >> 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

Underneath its apparent simplicity lies quite a lot of deep thought. And the mechanisms it uses apply far beyond just loops!

    for-both: func ['var blk1 blk2 body] [
        return unmeta all [
            meta for-each (var) blk1 body
            meta for-each (var) blk2 body
        ]
    ]

(Note: see followup for why RETURN is necessary with FUNC, and how to avoid it with LAMBDA.)

It solves the BREAK case

Below we see a situation where the first FOR-EACH returns NULL (and meta null 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~  ; isotope

Note that the first pass through the loop did not terminate the ALL, just because the body evaluated to false. That's because meta of isotopes produces QUASI!, which is truthy even if the isotopic thing is falsey:

 >> metafalse: meta ~false~
 == ~false~

 >> type of metafalse
 == #[datatype! quasi!]

>> if metafalse [print "All QUASI! are truthy!"]
All QUASI! are truthy!

This means the loop can gracefully recover the QUASI! 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
== ~false~  ; isotope

It Solves the Fallout From The Last Loop Body

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

>> for-each x [] [fail "This body never runs"]
; 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

There's a slight fib here, that META of the void did not produce a "meta-void" (')...but passed it through as it would a NULL. That's a "user-friendly" property of the META-as-a-word form:

>> meta if false ["META-the-word passes through the vanishing void isotopes"]
; void

>> ^ if false ["The ^ operator is more exact, gives the meta signal"]
== '

But in situations like this, passing through the void state is what we wanted.

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
; first in pack of length 1
== ~null~  ; isotope

>> x
== ~null~ 

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.reb 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:

4 Likes

Pursuant to the new rules for having a return from FUNC[TION], this either needed to RETURN the result or be changed to a lambda.

I chose to add a RETURN just so that it didn't look too "foreign". (Or at least so that this talking-point example can focus its foreignness on the explanation of META and UNMETA).

But it could be written without a RETURN as:

for-both: lambda ['var blk1 blk2 body] [
    unmeta all [
        meta for-each (var) blk1 body
        meta for-each (var) blk2 body
    ]
]
1 Like