Why Do No-Op Loops Evaluate GROUP! Bodies?

Historical Rebol let you pass in arbitrary code to "build" a branch:

rebol2>> if 1 = 1 reverse copy ["running branch" print]
running branch

Ren-C can do this too, but requires you to put the branch in a GROUP!, due do taking branches literally...

ren-c>> if 1 = 1 reverse copy ["running branch" print]
** PANIC: if got ~{word!}~ for branch argument, expected [any-branch?]

ren-c>> if 1 = 1 (reverse copy ["running branch" print])
running branch

This actually makes more sense because if you have code that runs to generate a branch, you don't want to waste time on that code if the branch isn't going to be run:

rebol2>> either 1 = 1 (print ":-)" [print "A"]) (print ":-(" [print "B"])
:-)
:-(
A

ren-c>> either 1 = 1 (print ":-)" [print "A"]) (print ":-(" [print "B"])
:-)
A

So I'm a fan of this behavior. :+1:

But it raises a question...

Why Don't Loops Work The Same Way?

Loops don't take their bodies literally. So they evaluate unconditionally:

ren-c>> for-each item [] (print ":-(" reverse copy ["running loop" print])
:-(

Why wouldn't loops just use the same semantics as branches...skipping the cost of that work if the code isn't going to run?

This does sound tempting. But we have to confront something that's fundamentally different about branches and loop bodies:

  • Branches are meant to run zero or one times

  • Loops are meant to run zero to infinity times

If loops are to squirrel away their branches as literal, and receive a GROUP!, then they're in the awkward position of having to make their first run different.

This might sound like just a little tweak to the body-running logic, to say "if you get to the point of running the body, if it's a group, then EVAL it and re-store it in the body variable...so you don't run it again".

But the implications are deeper than that. It means the decision to do this transformation happens inside the loop, and that doesn't compose well.

Consider the implications of literal bodies on something like the FOR-BOTH loop wrapper...

Today's FOR-BOTH doesn't take the body literally:

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 it did take the body fully literally, you'd have to use trickery (COMPOSE or APPLY) to subvert it for composition, so it would look like:

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

But if you pass a GROUP!, now each FOR-EACH gets a GROUP!...and each makes the decision to transform it, resulting in two body evaluations.

>> for-both item [1 2] [3 4] (print ":-(" reverse copy [item print])
:-(
1
2
:-(
3
4

You're now worse-off than you were with evaluating the branch. Not only is the performance worse, but you don't even know if that code branch-generating code was semantically safe to run twice!

The only thing FOR-BOTH could really do in such a situation would be to EVAL groups ahead of time:

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

But if we have to do that... we're doing the same thing as taking the branch evaluatively, except we've made our code uglier.

Workaround: Don't Use GROUP!, Use CACHE

If there's some costly transformation you think you can avoid in the "not ever actually called" case, don't use a GROUP!. Instead, make your code an ordinary BLOCK! but cache the calculation:

 for-each item data-maybe-empty [
     eval cache [some-expensive-transformation [
         ...
     ]]
 ]

Also: opting out of the body is a good way to dodge wrapping work when you don't want to run the loop, as opposed to opting out of the data:

for-each 'x data (if condition [[
    x: ... ; ordinary block produced by IF branch, no wrapping
    ...
]])

for-each 'x data (if condition [{
    x: ... ; wrapped block produced by FENCE!, new var
    ...
}])

You can write that more succinctly without the parentheses and using $[...] in the non-wrapped case:

>> if 1 = 1 $[stuff]
== [stuff]  ; bound

for-each 'x data if condition $[
    ...
]

But pursuant to the argument above, this trick can't be used with fences:

>> if 1 = 1 $[stuff]
== {stuff}  ; bound

for-each 'x data if condition ${  ; FOR-EACH doesn't take FENCE!
    ...
}

If FOR-EACH were tolerant of accepting FENCE! by value you get into that problem of the loop wrappers having something which has to have a "moment" of wrapping digestion. And in practice that moment must happen unconditionally at entry to the function.

Since the caller can't expect any different semantics from wrapping before they called, we aren't doing anyone any favors by forcing that work onto every loop wrapper. It hides the mechanic and obscures the reality.

Hence opting out of a body that you want fenced is at minimum:

for-each 'x data if condition [{
    ...
}]

I see the problem. But...

...couldn't loops have a parameter convention where they could pass the body via variable, and the variable would be updated on-demand if the loop ran?

You wouldn't even have to use APPLY:

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

It's basically automating the CACHE feature, putting it into the loop wrappers. This way you're not making it the problem of every loop user.

And it might be useful for branches as well... like maybe you don't want to pay to wrap a code block multiple times, so you could even cache FENCE!-wrapping.

code: ${...}

while [...] [
     if ... $code
]
1 Like

:thinking:

Unfortunately... that makes... a lot of sense.

(I say unfortunately because, now I have to implement it!)

It's also a bit sad that the loop wrapper gets harder to explain. But, it's not like the original code with the evaluative bodies wouldn't still work. It just gets better if you follow the convention.

This does seem useful. Also, being able to have a way to pass a branch by variable name is good. Because if you pass a GROUP! variable inside a GROUP! branch, it has to double-evaluate... a group that makes a group. That perhaps should even be illegal (?).

It would change the current meaning of $BRANCH. Right now it's a shorthand for binding the material:

if condition $thing   =>  if condition [$thing]

But there's no question that it's more powerful to say $thing means "this variable holds a branch which may need wrapping or evaluation, so do that transformation to a block once if it's a FENCE! or a GROUP!"

You get a lot more mileage out of that meaning than saving a [ and a ] character.

1 Like