Iterator patterns
Go 1.23 stabilized the "range over func" feature.
This allows user-defined types to work with range
expressions,
which effectively allows for creating "iterators" as in languages like Rust.
As this feature is relatively new, here's some helpers on when and where to
deploy it, and what patterns it's replacing. Familiarity with the [iter
]
documentation is assumed throughout.
Large collections
The most obvious use is to replace collections (slices, maps) that have a lot of
entries that callers may ignore. Using an iter.Seq
allows for the values to be
produced one-by-one, allowing for better resource usage.
A pitfall to watch out for is holding resources longer than intended. Compare the two following snippets:
func do(ctx context.Context) (err error) {
var objs []any
objs, err = getLotsofDatabaseObjects(ctx)
if err != nil {
return err
}
obj := objs[0]
// Simulate doing something
time.Sleep(time.Minute)
return nil
}
func do(ctx context.Context) (err error) {
var objs iter.Seq[any]
objs, err = DatabaseObjectsIter(ctx)
if err != nil {
return err
}
for obj := range objs {
// Simulate doing something
time.Sleep(time.Minute)
break
}
return nil
}
Although the second one only produces one return, it holds onto a database
handle for the entire duration. This may be a net win or it may not, but the
lifetime of the handle has moved in a non-obvious way between the two
functions: the first has it scoped to the getLotsofDatabaseObjects
function,
and the second has it captured in the returned objs
iterator.
There's no way to express this sort of lifetime in Go, so make sure to document when iterators hold resources in a way that may have not been an issue when working with builtin collections.
Error handling
Error propagation requires more thought when working with iterators. There are three broad classes of error to consider.
As a rule of thumb, using an iterator raises procedure from the "value domain" to the "function domain," so error reporting must move domains with it.
Iterator construction errors
This class of errors occurs when code cannot construct an iterator. Functions generally have the prototype of:
type ConstructError func() (iter.Seq[any], error)
This means that if there's an error return, the iterator was not returned.
By way of analogy to the database/sql
package, this is like constructing a
sql.Rows
object:
var db *sql.Conn
rows, err := db.QueryContext(ctx, `SELECT version()`) // ← here
if err != nil {
panic(err)
}
defer rows.Close()
for rows.Next() {
var v any
if err := rows.Scan(&v); err != nil {
panic(err)
}
// ...
}
if err := rows.Err(); err != nil {
panic(err)
}
If an error is returned, there are no rows to read.
Internal iteration errors
This class of errors occurs when there's an error in the iterator itself, almost certainly from a lower layer. An example prototype would be:
type FallableIterator func() (iter.Seq[any], func() error)
Function returns like this are usually meant to be called after the iterator has
been consumed to see if there was a problem. By way of analogy to the
database/sql
package, this is like calling the Err
method of a sql.Rows
object:
var db *sql.Conn
rows, err := db.QueryContext(ctx, `SELECT version()`)
if err != nil {
panic(err)
}
defer rows.Close()
for rows.Next() {
var v any
if err := rows.Scan(&v); err != nil {
panic(err)
}
// ...
}
if err := rows.Err(); err != nil { // ← here
panic(err)
}
Per-iteration errors
This class of errors occurs when there's an error producing one specific value. An example prototype would be:
type PerIterationErr func() iter.Seq2[any, error]
Doing this allows the calling code to do error handling in a way it sees fit,
instead of a callee making the decision. For an iterator of this style, the
slice-based equivalent would be something like func[V any]() []struct{Value V, Err error}
. The slice-based code usually just returned no results and an error,
though. By way of analogy to the database/sql
package, this is like the return
of the Scan
method of a sql.Rows
object:
var db *sql.Conn
rows, err := db.QueryContext(ctx, `SELECT version()`)
if err != nil {
panic(err)
}
defer rows.Close()
for rows.Next() {
var v any
if err := rows.Scan(&v); err != nil { // ← here
panic(err)
}
// ...
}
if err := rows.Err(); err != nil {
panic(err)
}
Combining styles
There's no hard-and-fast rule, but if an author needs (wants) to make use of all three, an "iterator factory" is usually a good pattern:
type Collection struct {}
func NewCollection() (Collection, error) { // construction error reporting
panic("unimplemented")
}
func (c *Collection) Close() { // explicit lifetime for held resources
panic("unimplemented")
}
func (c *Collection) All() (iter.Seq2[any, error], func()) { // per-iteration and internal error reporting
panic("unimplemented")
}
Composition
Iterators are "just" some language help over the function-passing syntax, so it's possible to compose them in arbitrary ways. Just remember that the iterators are calling "into" the loop body; to put that another way, iterators invert control.