Stream: ideas

Topic: for loops


view this post on Zulip Richard Feldman (Dec 12 2023 at 20:02):

building on #ideas > Shadowing Sigil, here's an idea along the lines of "syntax sugar for making things look imperative in the circumstances where that's easier to read" (e.g. backpassing) - consider this Roc code that would work today and do a List.walk that changes a bunch of state with each iteration:

fn = \initBuf, list ->
    { buf, y } = List.walk list { x: 0, y: 0, buf: initBuf } \state, elem ->
        newX = state.x + 1
        newY = state.y + newX
        newBuf = "\(state.buf) (Num.toStr newX) (Num.toStr newY)"

        { x: newX, y: newY, buf: state.buf }

    (buf, y)

view this post on Zulip Richard Feldman (Dec 12 2023 at 20:02):

here's an idea for syntax sugar that could make it clearer what this is doing:

fn = \$buf, list ->
    $x = 0
    $y = 0

    for elem in List.walk list
        $x = $x + 1
        $y = $y + $x
        $buf = "\($buf) (Num.toStr $x) (Num.toStr $y)"

    ($buf, $y)

view this post on Zulip Richard Feldman (Dec 12 2023 at 20:03):

the idea is that this would desugar to:

fn = \$buf, list ->
    $x = 0
    $y = 0

    ($buf, $x, $y) = List.walk list ($x, $y) \($x, $y), elem ->
        $x = $x + 1
        $y = $y + $x
        $buf = "\($buf) (Num.toStr $x) (Num.toStr $y)"

        ($buf, $x, $y)

    ($buf, $y)

view this post on Zulip Richard Feldman (Dec 12 2023 at 20:04):

so kinda the idea is "this is using List.walk like a for loop anyway, so let's call a spade a spade and make it easier to read :stuck_out_tongue:"

view this post on Zulip Richard Feldman (Dec 12 2023 at 20:05):

but if it's just syntax sugar, then it's not changing any of the guarantees of the language

view this post on Zulip Richard Feldman (Dec 12 2023 at 20:06):

I don't think this is a big pain point that needs addressing urgently or anything, it just occurred to me that shadowing/redeclaration makes this possible and I wanted to share the idea

view this post on Zulip Agus Zubiaga (Dec 12 2023 at 20:17):

Hm, I don’t love this one because it introduces one more way to do something that’s already possible with not much better ergonomics. I feel like this would make me hesitate every time I have to walk over a List.

view this post on Zulip Eli Dowling (Dec 13 2023 at 14:11):

I find the initial example is actually pretty good with just shadowing on its own tbh. I think the for loop is nice but I also think the benifit isn't so significant once you already have shadowing.

Example using just shadowing:

fn = \buf, list ->
    { buf, y } = List.walk list { x: 0, y: 0, buf } \state, elem ->
        x = state.x + 1
        Y= state.y + x
        buf = "\(state.buf) (Num.toStr x) (Num.toStr y)"

        { x, y, buf }

    (buf, y)

view this post on Zulip Kevin Gillette (Dec 13 2023 at 14:16):

@Richard Feldman in your desugars-to code, you might be missing $buf in both the initial state argument and the callback param list?

view this post on Zulip Kevin Gillette (Dec 13 2023 at 14:19):

In both the for and desugared snippets, it's not clear how the elem is used or what that corresponds to... maybe that's intended to be the same as $buf ?

view this post on Zulip Kevin Gillette (Dec 13 2023 at 14:47):

The things that are throwing me off, as a day-to-day imperative programmer, are:

  1. The in List.walk list part: I'd have expected that, perhaps naively, to just be in list, with List implementing some iteration ability. That's clearly much less flexible, but also much more familiar. Because it takes an explicit "orchestration" function (List.walk), it suddenly becomes unclear what this does or how it works.
  2. Some cognitive dissonance on how to make an imperative concept like this fit into a purely immutable language. On both the imperative and functional sides, I have come to prefer straightforward (unmagical) mechanisms. With the non-sugared form, I can see exactly what's going on (state passing in explicitly, state coming out explicitly). Ironically, this sugar could make the language seem more approachable to imperative programmers, yet make it harder to learn, by turning a steady learning curve incline into an incline followed by a plateau followed by an incline, where that plateau is "I don't have any idea what it's actually doing, but I've learned how to use this for thing by rote." Some people may never move past the plateau, and may retain a sense of avoidable mysticism about the language. That said, I suspect there are a few typos in the original sugar snippet that, if addressed, will make this somewhat clearer.
  3. I'll need to catch up on the shadowing sigil discussion, but it seems like an extra leap that shadowed names are implicitly returned somehow. Could you enumerate the proposed rules around this?

view this post on Zulip Kevin Gillette (Dec 13 2023 at 14:58):

Regarding suspected typos, should the sugared code example be the following?

fn = \$buf, list ->
    $x = 0
    $y = 0

    for {$x, $y, $buf} in List.walk list
        $x = $x + 1
        $y = $y + $x
        $buf = "\($buf) (Num.toStr $x) (Num.toStr $y)"

    ($buf, $y)

(where for {$x, $y, $buf} would work just as well with for ($x, $y, $buf) in this case).

I'm inferring that if the "loop body" ends with shadowed-assignments, then it sugars implicitly returns the same structure, referencing the same symbols (albeit with updated values) that were pattern-matched into after the for keyword?

Conversely if the loop ends with a non-assignment, that expression is the return value for the desugared callback?

view this post on Zulip Kevin Gillette (Dec 13 2023 at 15:07):

@Richard Feldman Could your proposal be used with List.map? If so, how would it work?

Could the result of the whole loop be piped?

view this post on Zulip Kevin Gillette (Dec 13 2023 at 15:11):

And how would it work with conditionals? It would seem that an if branch body would need to be able to contain nothing but shadowed assignments?

view this post on Zulip Kevin Gillette (Dec 13 2023 at 15:46):

Regarding point 1 above, perhaps it may read better as:

for List.walk of list as ($x, $y, $elem) ->

That said, I suspect the real opportunity here might be in implicit-return-expression sugar with records of shadowed names, not the introduction of a for keyword or anything. Consider:

countPositiveNegative = list ->
    List.walk list (0, 0) \($x, $y), elem ->
        if elem > 0 then
            # reuse value for $y in return,
            # since $y was not reassigned.
            $x = $x + 1
        else if elem < 0 then
            # reuse value for $x in return
            $y = $y + 1
        # no else, thus return input state

# sample result of call
countPositiveNegative [2, 1, 0, -1] == (2, 1)

This could also be used when piping values between arbitrary functions, particularly those which take open records with common field names. The compiler could infer that if there's an $x in scope, no explicit value is being returned, and the next function takes as input a record containing an x field (if a named outer scope function) or $x (sibling scope function), the $x could be implicitly passed along into the correct position.

Whether this will lead to good code is an open question, and over-eager use of the feature could lead to confusing code, such as when $x has different types/meanings in different nested lambdas, but it was used in the way just to shorten the code via this feature. Granted, i think that potential confusion can still happen today with sibling lambas (thus sibling scopes), since the names wouldn't be technically shadowing each other.

view this post on Zulip Ryan Bates (Dec 13 2023 at 17:27):

Coming from Ruby, it has both for loops and other iterators such as Array#each. I personally never use for loops in Ruby and found they just added to the confusion when I started learning the language. It added a decision point of "which do I use?" every time I needed to iterate.

That said, there isn't an advantage to using for in Ruby, but looks like the result is nicer to read and write in this example. So I'm probably comparing apples to oranges here.

Would this syntax work with all walk functions? What about walkUntil?

view this post on Zulip Ryan Bates (Dec 13 2023 at 17:41):

That said, I suspect the real opportunity here might be in implicit-return-expression sugar with records of shadowed names, not the introduction of a for keyword or anything. Consider:

@Kevin Gillette I like the idea of finding a syntax sugar that can improve existing iterators and simulate mutable state. The "no else" would take some getting used to though. Perhaps else {} would mean "do nothing for this case"?

view this post on Zulip Kevin Gillette (Dec 13 2023 at 20:37):

Since Roc doesn't use curly braces for scoping, else {} would literally mean "return an empty record". There could be some syntax to cover that case though. Whether any of this would be a net-positive for the language is an open question of course

view this post on Zulip Ryan Bates (Dec 13 2023 at 20:45):

Isn't an empty record sometimes used as a placeholder for "nothing"? Like Dict.empty {} and {} <- Stdout.line "foo" ...

view this post on Zulip Kevin Gillette (Dec 13 2023 at 21:04):

yeah, definitely it's used in that way now. i just mean that if the function as a whole returns an int or a tuple or whatever, having {} mean "infer the right tuple to return" is probably confusing.

iiuc, use of {} in that way is just a convention, but doesn't have special meaning in the language. what we're discussing would give it special meaning.

view this post on Zulip Brendan Hansknecht (Dec 13 2023 at 21:12):

Yeah, it is convention. Could technically using Nothing or Unit as a tag with a single variant, also no data in those types.

view this post on Zulip Ryan Bates (Dec 13 2023 at 21:30):

yeah, definitely it's used in that way now. i just mean that if the function as a whole returns an int or a tuple or whatever, having {} mean "infer the right tuple to return" is probably confusing.

I was thinking in the case of the implicit return example, the last line of the function wouldn't be the return value, it would always return the state. The else {} would allow keeping an else clause and be a way of clarifying "do nothing for this case".

view this post on Zulip Brian Carroll (Dec 13 2023 at 22:57):

Yes, the problem is that it doesn't type check if one branch returns a number and the other returns {}. This is a difference between functional and imperative programming.

view this post on Zulip Brian Carroll (Dec 13 2023 at 23:01):

The branches of the if don't mean "do this or do that", they mean "this value or that value". Think of each branch as a noun, not a verb.

view this post on Zulip Brian Carroll (Dec 13 2023 at 23:03):

In C or JS it would be condition ? x : y instead of an if

view this post on Zulip Brian Carroll (Dec 13 2023 at 23:05):

Back to the main topic, I think that the for loop idea causes too much confusion with imperative programming. Seems like it really is imperative when it's actually just an analogy.
Rather than making it actual syntax I think it would be better to show functional and imperative versions side by side in a tutorial.


Last updated: Jun 16 2026 at 16:19 UTC