Go’s New Iterators Smell a Little Funny, but They’re Still Good

Go's new Iterators were released on August 13th with Go 1.23. They're pretty useful.

They've had just a minute to marinate. In some parts of the community they've been an unpopular addition, however I don't particularly mind them. The old alternatives seem a little more "Go" to me, but the new iterators are fine.

One thing however I am mildly disappointed about is that there isn't just a generic iterable type similar to the generic comparable. It would be nice to have an imaginary iterable type that could accept maps, slices, arrays, and iterators. As I understand it, iterators combined with slices.All and maps.All are the blessed alternative to that. It works, I'm using it, but I don't love it.

All that's neither here nor there, however, and not what I'm here to talk about.

The part that smells funny to me is that range now accepts any already existing function that happens to fulfill either of the following interfaces.

func(func(any) bool)
func(func(any,any) bool)

It would be less weird if that wasn't also a little troublesome.

Example time!

The first interface, known as Seq takes a yield method as an argument that itself accepts any single parameter. The second interface, known as Seq2 similarly takes a yield method that accepts any two parameters as essentially a key/value tuple.

Note that yield is not a magic keyword as in many languages, but a passed-in callable. This is very akin to Go allowing you to name receivers rather than magically defining this.

At first blush to the uninitiated, this iterator looks fine. It seemingly runs fine as well.

package main

import "fmt"

func mightBeAnIterator(f func(string) bool) {
f("foo")
f("bar")
f("baz")
}

func main() {
for x := range mightBeAnIterator {
fmt.Println(x)
}
}

foo
bar
baz

What's the problem then?

The problem is iterators MUST handle early exit of the loop manually. With the current interface fulfilling "iterator", if we just add what seems a harmless break to our loop as follows, suddenly the code working moments ago panics.

for x := range mightBeAnIterator {
fmt.Println(x)
break
}

foo
panic: runtime error: range function continued iteration after function for loop body returned false

Somewhat surprisingly, as Go exits the loop, it allows the iterator to continue executing. In doing so, however, it signals the iterator through the result of the yielding method that it should return. This is to allow for teardown. For example, imagine an iterator that opens files or a socket; continuing execution allows it to close them before returning.

However, Go will panic if the signal to return is not honored and the yield function is then invoked again by the iterator.

To prevent our example iterator from panic-ing, we simply have to listen for the signal to exit, as shown below.

func mightBeAnIterator(f func(string) bool) {
if !f("foo") {
return
}
if !f("bar") {
return
}
if !f("baz") {
return
}
}

And when run, now we receive simply

foo

I would imagine the reason for the panic is to prevent iterators from accidentally running wild.

To my personal taste, I would have strongly preferred honoring the exit signal to be optional, and following superfluous yield calls being quietly ignored and returning false.

I can certainly imagine cases where an iterator would want to finish regardless of early exits by its consumer. While still possible, it's awkward to implement.

We are certainly in a place where, should they decide to go back on the panic behavior, it would not break any existing code. That's not a terrible place to be.

All this is to say it smells a little funny that such a simple and likely somewhat common interface has this magic range logic available to it.

Expecting the implementers to implement it correctly and panic-ing if they don't smells kind of Worse is Better to me. While one could complicate Go's iterator interface to enforce stricter guarantees around safe iteration, doing so would add unnecessary complexity and detract from Go's philosophy of simplicity.

It's one of the rare occasions where I wish interfaces had to be intentionally met. It's strange to me that anything that smells like an iterator can be passed to range, even if it doesn't implement the intended handshake.

It smells funny, but that's probably OK. It almost certainly falls into the case of "things that will never be a real problem in the real world". I look forward to the oncoming brave new world of libraries expecting iterators.