Going For a Loop

Go fixing loops in 1.22 and I've seen that before
Published on 2024/02/11

I mentioned in Server Mux Rabbit Hole that go 1.22 brings some for loop changes. It was interesting to see because JS went through a similar:

for (var i = 0; i < 10; i++) {
  setTimeout(function () {
    console.log(i);
  }, 0);
}

The above would print "10" every time. Since the i variable gets updated at every loop, by the time the setTimeout callback runs, the loop has completed so i == 10 which is why it gets printed for every log. You could fix that with closures:

for (var i = 0; i < 10; i++) {
  (function (j) {
    setTimeout(function () {
      console.log(j);
    }, 0);
  })(i);
}

or with "modern" js (it's been a while that we have let):

for (let i = 0; i < 10; i++) {
  setTimeout(function () {
    console.log(i);
  }, 0);
}

In this case let creates a new block scoped variable which is different at every iteration. This means that the i referenced by each setTimeout callback is different (very simply put). Turns out (for folks who have not fallen into this trap yet) that go has the same issue:

s := []string{"test0", "test1", "test2", "test3", "test4"}
ch := make(chan bool)

for i := range s {
  go func() {
    fmt.Println(i)
    ch <- true
  }()
}

for _ = range s {
  <-ch
}

This would print "4" every time. Without going into detail about how goroutines work, by the time they execute the loop is completed and the variable i (referenced by each goroutine) is exactly 4. Fixing this feels more natural in go than it does in js because the go keyword requires you to call a function right there. All you gotta do is pass the i as an argument:

s := []string{"test0", "test1", "test2", "test3", "test4"}
ch := make(chan bool)

for i := range s {
  go func(j int) {
    fmt.Println(j)
    ch <- true
  }(i)
}

for _ = range s {
  <-ch
}

Switching to go 1.22 you don't need to do that since the i is now a different instance on each iteration. Worries be gone!

The additional change is the preview of range-over-func iterators. Simply put this means we can pass a function to the range operator. As long as it adheres to the iterator signature we can use it! The simple example clarifies this a bit.

package slices

func Backward[E any](s []E) func(func(int, E) bool) {
  return func(yield func(int, E) bool) {
    for i := len(s)-1; i >= 0; i-- {
      if !yield(i, s[i]) {
        return
      }
    }
  }
}

With the proposal it means that we can range over the function:

s := []string{"hello", "world"}
for i, x := range slices.Backward(s) {
  fmt.Println(i, x)
}

Which the program translates to:

slices.Backward(s)(func(i int, x string) bool {
  fmt.Println(i, x)
  return true
})

Say what you want but in order to internalize this I like to do a little refactor. When reading the Backward function the first time (also being later in the evening didn't help), it wasn't immediately obvious how it all comes together. We defined a function Backward that returns a function that takes a function as an argument. Let's break it down a little bit and use a concrete type.

// rangeFn would take as arguments the usual values
// returned by a range so an index and, in this case,
// a string or whichever value of the type we are
// ranging over.
// The interesting part is the return type of bool.
// That's used to indicate when we should exit the
// loop. When it returns true it is equivalent to
// "continue" when it returns false it is equivalent
// to "break" so:
//   true <-> "continue"
//   false <-> "break"
type rangeFn = func(int, string) bool

func Backward(s []string) func(rangeFn) {
  // Maybe cb is not an ideal name, yield got me
  // confused for a hot second since it's used in JS.
  return func(cb rangeFn) {
    // This is the custom logic of how I want to
    // iterate over the data structure. In our case
    // a simple slice of strings.
    for i := len(s) - 1; i >= 0; i-- {
      // Now if you remember we call this with the
      // index and the value. As you see if the
      // returned value is true, we continue to the
      // next iteration (true <-> "continue").
      // If it returns false, we return and interrupt
      // the loop (false <-> "break").
      if !cb(i, s[i]) {
        return
      }
    }
  }
}

Thoughts

Even simple abstractions can make certain concepts more complex to understand than necessary. Grabbing a practical example can often help get a good understanding which then allows you to abstract again. The moment you can move back and forth between abstraction and practical example you develop a deeper understanding.

I almost feel like this proposal went a little bit under the radar but it can affect how expressive go can be quite a lot. I haven't had the chance to play with generics much but excited to see what people will do with this addition.

0
← Go Back