Virtual Binding + Macros => BREAK, CONTINUE, AGAIN

In the last moments of 2023, Ren-C finally bit the bullet and made BREAK and CONTINUE definitional (it was a long time coming, just hadn't gotten around to it...):

Definitional Break and Continue: The Time Is Now

This means that loops create (optimized) variants of BREAK and CONTINUE in which the ACTION! cell has been tweaked to hold the identity of the loop.

The methodology for this optimized representation was pioneered with definitional RETURN.

Except in this case, there are two new "LET-style variables" tacked onto the loop's binding environment... one for BREAK, and one for CONTINUE.

It Would Be Nice To Have AGAIN

It seems to me pretty powerful to be able to ask a loop to start again from the top, but not increment its loop index...or check the condition, or whatever.

(This has precedent in other languages, e.g. Perl's REDO... but I like AGAIN better.)

That would add a third LET-variable. :pouting_cat:

But Could It Be Just ONE LET-Variable?

As I've realized the kind-of-awesome power of virtual binding, I realized that it might be the case that there was just one "throw to loop" construct, that takes different parameterization:

  • throw-to-loop ~(veto)~ => BREAK

    • VETO is a function that returns an ERROR! with the identity 'VETO... which is an awesome generalization I haven't talked about yet, that makes constructs abort and return NULL. It works inside things like REDUCE (e.g. reduce [1 + 2 veto] => null) but also inside GROUP!s of code in PARSE to allow match failures to be signaled by a GROUP! (whose product would otherwise be discarded). It seems the perfect argument for THROW-TO-LOOP to mean "let's abort this whole loop and give null".
  • throw-to-loop () => CONTINUE

    • This would have the same effect as reaching the end of the loop body and synthesizing VOID. So if you were doing a MAP-EACH, an iteration of the loop that ran CONTINUE would not contribute anything to the result.
  • throw-to-loop ~(again)~ => AGAIN

    • I don't know if RETRY should be a function that just returns an ERROR! the way VETO is so I'm hand-waving a little here to say "let's have another special trigger that loops can respond to"... if they don't support it, they can treat it like any other error.

    • I'm not thrilled that "retry" doesn't sound like an "error" name the way VETO kind-of-does, but there's only so many unstable antiforms to choose from (and VOID seems random to pick to mean "try again"). Maybe INCOMPLETE is a better error ID... where the loop goes "oh, the failure was it didn't complete... so the natural response is to try again"?

  • throw-to-loop <whatever> => CONTINUE:WITH

    • CONTINUE has had a refinement called :WITH that allows you to act as if the loop body completed with some other value besides VOID. So continue:with spread [d e] inside a MAP-EACH would add the splice ~(d e)~ to the mapped result. I don't know if it's better to have this :WITH refinement or if you should just use THROW-TO-LOOP directly... (could THROW-TO-LOOP have a better name?)

One Way To Do It... Macros!

break: macro [throw-to-loop ~(veto)~]
continue: macro [throw-to-loop ()]
again: macro [throw-to-loop ~(again)~]  

While we'd most likely want to nativize CONTINUE, BREAK, and AGAIN... maybe MACRO can be smart enough to produce native-speed code for this when not running under a stepwise debugger...

Any Submissions For Better Names For THROW-TO-LOOP?

Maybe being bluntly literal is best.

But @BlackATTR suggested TOSS, PASS, PITCH, PUNT, FLICK

They all sound weird to us now, but everything has a learning curve. You learned what BREAK and CONTINUE meant, you'd learn what AGAIN meant. Could you learn what TOSS meant, that it was specifically a THROW targeting a LOOP...?

Leaving that open for now.

Or... Variadic CONTINUE?

I'll also point out that once-upon-a-time, CONTINUE was variadic... so you could say continue 10 and if you left off the parameter it would assume you meant VOID. This was too error prone, due to line continuation bugs:

Line Continuation and Arity Bugs: Thoughts?

But I've been thinking maybe you have to continue lines with a backslash. So this would error:

append [a b c]
[d e f]

But this would be legal:

append [a b c]
\ [d e f]

And presumably this would be, too (though it's inferior...)

append [a b c] \
[d e f]

The reason it would be legal is it would actually LOAD the code as not having a line break marker.

Then we'd just make line break markers illegal outside of interstitial evaluations. (Maybe relax it, so that if you were inside a GROUP! evaluation it would allow it, though that might make it toothless.)

(append [a b c]
[d e f])

If the system got more persnickety about line continuation, then we might feel comfortable bringing back variable-arity CONTINUE, QUIT, RETURN...

I doubt we want to go down the route of JavaScript's automatic semicolon insertion debacle, and act like there's a comma at "some" line end markers.

I have a hard time being psychic about whether this is a big creativity-enabler (by letting us be more purposeful about semantics of line continuation markers) or if it would lead to hassles. Overall I feel like it would cut down on bugs, by making line endings usually mean expression endings... and having you be specific when that's not what you want.

There's another option...

Arity-1 CONTINUE

This would mean if you wanted the "traditional" continue, you would typically say continue ().

The concept of the parameter to CONTINUE is "act as if the loop body completed and returned this result".

The most obvious place this is useful is in things like MAP-EACH:

>> map-each 'x [1 2 3] [
       if x = 2 [continue spread [pretty cool!]]
       x * 10
   ]
== [10 pretty cool! 30]

But it can serve a purpose in any loop, by being the value that "drops out" the bottom of the loop if there are no further iterations.

x: 10
until [x = 30] [
   x: x + 10
   if x = 20 [continue <x was 20>]
   x * 100
]
== <x was 20>

It may make sense to let people know they have this degree of freedom... that if they continue the loop, they're always submitting a value to it...

ARITY-1 continue gives feature exposure, and it also saves us from having to come up with a name for "throw something to the loop".

continue ~(veto)~ is thus a synonym for BREAK, which as I'm proposing would just be defined as passing VETO to whatever the current concept of CONTINUE is.

continue ~(again)~ is weird, but maybe it makes more sense to help drive home that it's connected to loop constructs.

I'm not sure, but I am feeling kind of open to this idea that CONTINUE has you pass what you want the loop to continue with.

It does suffer slightly from a redefinition problem: while you could redefine other words, you couldn't redefine CONTINUE itself.

Hm. Maybe that's enough to kill it. If the loops are using a fixed desirable word like CONTINUE, you couldn't then redefine CONTINUE to be arity-0... whereas if it was called THROW-TO-LOOP you could.

This might be a good argument for RETURN being a macro that calls THROW-TO-FUNCTION. That gives you more power to define your own "smart return". For instance, one that looks to see if the current THROW-TO-FUNCTION return type was specified as [], and in which case morphs to take no arguments. I can imagine people having other rules that might be specific to certain contexts.

This makes THROW-TO-LOOP having a crappy name seem like a good thing, not a bad one.

1 Like

Thinking about this further, it would be a fairly double-edged thing.

Currently you can overwrite RETURN e.g. as:

 foo: func [] [
     return: adapt return/ [value: value + 20]
     return 1000  ; acts as if you hadn't adapted, and returned 1020
 ]

If RETURN were some definition in LIB that hooked up to whatever the local definition of THROW-TO-FUNCTION was, having a long and crappy name would throw this off. Your local specialization would do "something else" (which wouldn't be to overwrite the LIB RETURN, but likely error by default, though it could create a local definition in the current module).

So at minimum you'd have to say:

 foo: func [] [
     let return: adapt return/ [value: value + 20]
     return 1000  ; acts as if you hadn't adapted, and returned 1020
 ]

Though this costs a LET, and would only affect RETURN that had visibility of the LET.

And... if the global RETURN just forwarded to "whatever you defined return as", there's issues about what its interface would be. Would it have no parameters, and then if RETURN happened to be defined differently (let's say, returning 3 parameters?) it would forward that normally? That would prohibit this kind of specialization at all.

So to be able to specialize it, it would have to build a frame and presume the nature of THROW-TO-FUNCTION that it was proxying to. Or you'd specialize THROW-TO-FUNCTION instead:

 foo: func [] [
     throw-to-function: adapt throw-to-function/ [value: value + 20]
     return 1000  ; acts as if you hadn't adapted, and returned 1020
 ]

A middle ground might be to call the raw-return something nicer, but indicative of its low-levelness, like RETURN*

 foo: func [] [
     return*: adapt return*/ [value: value + 20]
     return 1000  ; acts as if you hadn't adapted, and returned 1020
 ]

Are Such Contortions Worth It?

Kind of feels like... no. I like the way you can just off the cuff twist the local RETURN, and call it RETURN, and it "just works".

If "global hooking" of RETURN is truly desirable, maybe that should be done with some more general facility of hooking WORD!s in an environment. That seems wiser.

1 Like

Namingwise for throw-to-loop I thought of just loop, but this might be too nice name to burn for this, so how about loop-instruction, loop-direction, loop-order, loop-do?

e.g.

loop-instruction veto
loop-order again
loop-do break
   

I think having THROW in there is fairly informative about what's actually happening mechanically, but maybe loop-throw is more succinct than throw-to-loop. :thinking:


BUT... beyond the naming question, I had another thought about this... which is kind of a big picture question about dialecting.

If we were to imagine that CONTINUE, BREAK, and AGAIN are all some kind of globally defined macros that are in terms of LOOP-THROW or whatever... then those macros have to be defined broadly everywhere. So it's like you've "stolen" these words from all contexts, not just those that are underneath loops.

This is different from the situation we have today, where the words get their meaning popping into existence only when underneath a loop construct. Hence in non-loop related code you don't worry about any kind of contention, and you can have them redefined but then the words work again once you have a loop pop into existence underneath that context.

Though today's situation is a little reliant on global definitions. Because despite the words only being defined underneath loops, there still are declarations in LIB of TRASH! values that help inform you that you used CONTINUE/BREAK/AGAIN when there's no loop in effect.

Hence we are already sort of "paying the cost" of carving out the words globally, despite them only being relevant in loop contexts. Though it only affects the availability of some helpful warning messages if you redefine them...loops will still work as expected once you instantiate one.

This makes me start thinking about hybrid approaches, where there's some context called "loop-helpers" that gets imported by looping constructs, and the default has CONTINUE/BREAK/AGAIN macros that speak in terms of LOOP-THROW but you could customize what's in that context.

Then I get a headache and think "maybe I should just take the W of the definitional constructs and think about something else."

:face_with_head_bandage:

1 Like

I came up with a good-enough trick for moving ahead with arity-1-or-0 constructs.

If your argument can take a <hole> (missing parameter), then there can't be a newline on anything that would act as a parameter to it.

>> map-each n [1 2 3] [
       continue
       comment "not legal"
   ]
** PANIC: value is a <hole> param of continue and can't span a newline

Simple and effective.

It's nice to have CONTINUE back as able to pass values without an ugly :WITH.

>> map-each 'x [1 2000 3000] [
       if x > 1000 [continue <big>]
       x + 1
   ]
== [2 <big> <big>]

But CONTINUE as "THROW-TO-LOOP" Kind of... Sucks

Maybe it's more efficient implementation-wise. But the behavior is surprising:

 >> for-each x [1 2 3 4] [
       continue: cache [
            print "Caching!"
            adapt continue/ [print "Continuing!"]
       ]
       print ["X is" x]
       if x = 3 [break]
    ]
Caching!
X is 1
X is 2
X is 3
Continuing!  ; <-- but... I said BREAK, not CONTINUE
== \~null~\  ; antiform (logic!)

(That's a super-keen use of CACHE, by the way... to only do the specialization once!)

Maybe CONTINUE* Is The Better Bet...

It's a good observation that "If CONTINUE takes ~(veto)~ and ~(retry)~, then it can act as BREAK or AGAIN"

  for-each ... [
      ...
      continue case [
          condition1 []  ; act like continue, no arg
          condition2 [print "Continuing with 10" 10]
          condition3 [print "Going again..." ~(retry)~]
          condition4 [print "Breaking!" ~(veto)~]
      ]
      ...
  ]

That's very neat. But I think BREAK and AGAIN need to be separate entry points for overriding.

However... it does seem to me that being able to globally say what things like RETURN or BREAK or CONTINUE mean in a larger scope is a killer feature.

Killer enough to say that if you want to override in a local sense, you use LET to set up that scope and get a new variable.

This splits overriding such things into some strata...

The global RETURN would be an argument-taking macro, which delegated to whatever the current definition of RETURN* was that is in effect...something equivalent to:

return: inliner [^value] [spread compose '[return* (lift ^value)]]

So we're splicing in an unbound RETURN* and a quoted/quasi form of the value we got. The quoted/quasi lifted form won't pick up any binding (because dropping the quote or quasi in the evaluator doesn't manipulate binding...well the quasi would get rid of it if it had any.)

Then if you wanted to override this, you could tune that however you wished. Suddenly all the RETURN you have in a module for every function can get a feature.

Where you might start getting confused with this approach would be if you have nested functions, and you didn't want to affect them:

foo: func [x] [
    let return: adapt return/ [
        value: value + 10
    ]
    let bar: func [y] [
        return y
    ]
    return bar x
]

Is the result intuitive or counter-intuitive?

>> foo 1000
== 1020

If that wasn't what you wanted, you needed to know to adjust RETURN*... not RETURN. And you don't have to use a LET:

foo: func [x] [
    return*: adapt return*/ [
        value: value + 4
    ]
    let bar: func [y] [
        return y
    ]
    return bar x
]

>> foo 300
== 304

It's new. But I think the super-power of redefining RETURN for scopes is so compelling that I've kinda gotta do it this way.

CONTINUE, BREAK, and AGAIN are all macros

And if you wish, you can declare loop-body-local implementations, that redefine the CONTINUE, BREAK, or AGAIN... or you can just manipulate the specific loop's behaviors.

I don't think we need a BREAK*, and AGAIN* per-loop. These can all be in terms of CONTINUE*... if you're going low-level, you're as likely to want to adjust all three at once as you are to want to do any one of them.

It's Pretty Amazing All Of This Actually Works

:exploding_head:

Not sure how people aren't going to be impressed.

1 Like