Go 1.23 iterators
This summer, the Go team introduced new iterators, which, naturally, stirred up some controversy. Critics argued the design wasn’t very "Go-like"—too functional, too complex, and missing Go’s typical simplicity. A common complaint was: why not just introduce an iterator interface and allow range operations over it?
I get the criticism, and I agree to an extent. But the decision to go with push iterators over pull iterators makes sense too. It simplifies common tasks—like iterating over a database cursor—in a way that’s easier to write and follow than managing an iterator state machine.
For a function to act as an iterator, it has to follow one of three signatures:
func(func() bool)
func(func(K) bool)
func(func(K, V) bool)
And here’s the first challenge: you end up writing a function that returns another function, which takes yet another function. It’s a bit of a nesting doll situation.
Let’s break it down with this example:
func rangeIterating(it iterator[int]) func(func(int) bool) {
return func(next func(int) bool) {
for it.Next() {
if !next(it.Value()) { // handle break
println("break")
return
}
println("loop")
}
}
}
So, what’s calling what? What’s returning what? You’ve got a function (rangeIterating) that returns another function, which takes a function (next) as an argument. Inside, next gets called on each value from the iterator. It’s a mess of indirection: a function calls a function, which returns a function. It’s like untangling a pair of headphones you swore you left neatly coiled.
To make matters worse, in some examples they call the parameter function yield, which immediately clashes with languages like C#, Python, or JavaScript—where yield is a keyword that suspends and resumes execution.
Let’s simplify it by recreating for range behavior:
func myForEach(iterFunc func(func(int) bool), valueMapFunc func(int) bool) {
iterFunc(valueMapFunc)
}
All this function does is call the provided iterator function (iterFunc) with a valueMapFunc as its argument. The valueMapFunc is simple: it returns false when there’s a break in the loop, and true in all other cases.
Here are two snippets that essentially do the same thing:
it := &IntIterator{0}
for val := range rangeIterating(it) {
if val > 2 {
break
}
println(val)
}
it := &IntIterator{0}
myForEach(rangeIterating(it), func(val int) (falseIfBreak bool) {
falseIfBreak = true
if val > 2 {
falseIfBreak = false
}
println(val)
return
})
So, yes, the new for range loop isn’t iterating over a collection itself. Instead, it expects the iterator function to handle the looping. The for range just receives values via the value mapping function. The iteration logic is offloaded to the iterator, with for range acting as a receiver for whatever values the iterator decides to pass along.
Here’s a full sample you can play with:
package main
type iterator[T any] interface {
Next() bool
Value() T
}
type IntIterator struct {
Val int
}
func (it *IntIterator) Next() bool {
it.Val++
return it.Val < 10
}
func (it *IntIterator) Value() int {
return it.Val
}
func rangeIterating(it iterator[int]) func(func(int) bool) {
return func(next func(int) bool) {
for it.Next() {
if !next(it.Value()) { // handle break
println("break")
return
}
println("loop")
}
}
}
func myForEach(iterFunc func(func(int) bool), valueMapFunc func(int) bool) {
iterFunc(valueMapFunc)
}
func main() {
it := &IntIterator{0}
for val := range rangeIterating(it) {
if val > 2 {
break
}
println(val)
}
it.Val = 0
myForEach(rangeIterating(it), func(val int) (falseIfBreak bool) {
falseIfBreak = true
if val > 2 {
falseIfBreak = false
}
println(val)
return
})
}
In the end, we didn’t really gain any groundbreaking capabilities with this change. I still wish we had something cleaner, like:
for k, v := range iteratorStruct {
}
But we’re still have:
for iteratorStruct.Next() {
}
And let’s be honest—this is still way better than the C++ iterator interface. Go’s approach might feel a little unconventional, but at least it’s easy to implement and far less painful than C++’s monstrosity.