¿Dónde y por qué usar iteradores en Go?

¿Dónde y por qué usar iteradores en Go?

Los iteradores son una herramienta del lenguaje que permite recorrer los elementos de una colección o un flujo de datos sin necesidad de cargarlo todo en memoria de golpe. En Go los introdujeron el año pasado, y eso provocó mucho revuelo: a mucha gente estas novedades no le gustaron, los desarrolladores no entendían para qué servían ni dónde usarlas, pero la queja más habitual que escuché fue que los iteradores complicaban el lenguaje todavía más, y la simplicidad de Go es la base de la filosofía del lenguaje.

Desde el lanzamiento, yo, como muchos, no llegué a usar los iteradores ni una sola vez en el trabajo real: simplemente todas mis tareas se resolvían con las herramientas de siempre, y en ningún momento surgió algún problema que solo pudiera resolverse usándolos.

En este artículo decidí averiguar dónde podría aplicar los iteradores en el trabajo real. Revisé un montón de artículos, opiniones de desarrolladores, pregunté en reddit y al final reuní una lista de casos donde los iteradores realmente pueden ser útiles. Algunos de ellos ya conseguí usarlos en mis proyectos de trabajo y la verdad es que resultaron muy cómodos.

Todos los casos de uso se pueden dividir lógicamente en varios grandes grupos.

Lectura perezosa de datos

Cuando hablamos de usar iteradores al trabajar con un gran conjunto de datos, se trata, ante todo, de la lectura perezosa. Por ejemplo, al leer de un archivo o de una base de datos con varios millones de filas, no queremos volcar todas esas filas en memoria, queremos leerlas línea por línea y procesarlas de forma secuencial.

El caso con lectura perezosa que más aparece en el trabajo es la lectura desde la base de datos, así que veamos cómo se le puede aplicar un iterador y por qué esto puede resultar cómodo.

Lectura línea por línea desde la base de datos con procesamiento adicional de las filas

Nos guste o no, a menudo en las bases de datos relacionales hay que almacenar datos en json o en algún otro formato, para lo cual la mayoría de las bases modernas incluso tienen tipos especiales.

Imaginemos que trabajamos con postgres y decidimos almacenar los datos en formato cbor, un formato análogo a json, pero que se guarda en forma binaria, más eficiente en tamaño y velocidad. Queremos leer los datos del archivo y hacer el unmarshal de los datos de cbor a una estructura de go de inmediato.

La forma clásica de leer los datos de la base de forma secuencial y sin volcar en memoria todas las filas es usar el método Next() del tipo sql.Rows del paquete estándar "database/sql":

rows, err = db.Query("select data from users_serialized")
  if err != nil {
      panic(err)
  }
  defer rows.Close()
 
  for rows.Next() {
      var raw []byte
      if err := rows.Scan(&raw); err != nil {
          return
      }
 
      var user User
      if err := cbor.Unmarshal(raw, &user); err != nil {
          return
      }
 
      fmt.Printf("User: %+v\n", user)
  }

Podemos sacar este código a un repositorio y llamarlo donde lo necesitemos. Pero si en algún punto concreto necesitamos procesar adicionalmente la estructura User (por ejemplo, se añade otro campo más, pero ya en formato json, al que también querremos hacerle unmarshal), tendremos que usar este método y esperar a que todos los usuarios se vuelquen en memoria para luego filtrarlos otra vez, o bien escribir otro método con la lógica adicional.

Como alternativa, se puede hacer lo mismo usando iteradores:

func UsersFromRows(rows *sql.Rows) iter.Seq[User] {
  return func(yield func(User) bool) {
    defer rows.Close()
    for rows.Next() {
      var raw []byte
      if err := rows.Scan(&raw); err != nil {
        return
      }
 
      var user User
      if err := cbor.Unmarshal(raw, &user); err != nil {
        return
      }
 
      if !yield(user) {
        return
      }
    }
  }
}
 
...
 
rows, err := db.Query("select data from users_serialized")
if err != nil {
  panic(err)
}
 
for user := range UsersFromRows(rows) {
  fmt.Printf("User: %+v\n", user)
}

En este caso podemos reutilizar el método iterable UsersFromRows() y procesar a cada usuario como consideremos necesario sin volcar a todos los usuarios en memoria.

El código de arriba parece cómodo, pero le falta un detalle importante: el manejo de errores. Los iteradores no saben reanudar el trabajo tras una interrupción ni saben propagar errores hacia afuera. Por eso, en cualquier caso vamos a tener que complicar la lógica para añadir esa posibilidad. La forma más obvia de hacerlo es poner el error junto al valor devuelto:

type User struct {
  ID   int
  Name string
  Err  error
}
 
func UsersFromRows(rows *sql.Rows) iter.Seq[User] {
  return func(yield func(User) bool) {
    defer rows.Close()
    for rows.Next() {
      var raw []byte
      if err := rows.Scan(&raw); err != nil {
        yield(User{Err: err})
        return
      }
 
      var user User
      if err := cbor.Unmarshal(raw, &user); err != nil {
        yield(User{Err: err})
        return
      }
 
      if !yield(user) {
        return
      }
    }
  }
}
 
...
 
rows, err := db.Query("select data from users_serialized")
if err != nil {
  panic(err)
}
 
for user := range UsersFromRows(rows) {
  if user.Err != nil {
      // ...
  }
 
  fmt.Printf("User: %+v\n", user)
}

Trabajo con una API que ofrece paginación

Otro grupo son las API con paginación. En el caso de la paginación no necesitamos usar iteradores para ahorrar memoria: la API de por sí nos devuelve una cantidad fija de elementos por página. Aquí los iteradores son una herramienta que encapsula la lógica de recorrer las páginas.

Un buen ejemplo de una API de este tipo con paginación lo puso un usuario de reddit en los comentarios de mi post donde preguntaba si los desarrolladores usan iteradores:

I have wrapped the various AWS paginated APIs with great results. 

Veamos un ejemplo real de uso de iteradores para manejar la paginación de la API de AWS.

Lectura de archivos página por página con paginación desde AWS S3

type Object struct {
	Value types.Object
	Err   error
}
 
func S3Objects(ctx context.Context, client *s3.Client, bucket string) iter.Seq[Object] {
  return func(yield func(Object) bool) {
    var token *string
 
    for {
      resp, err := client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
        Bucket:            &bucket,
        ContinuationToken: token,
      })
      if err != nil {
        yield(Object{Err: err})
        return
      }
 
      for _, obj := range resp.Contents {
        if !yield(Object{Value: obj}) {
            return
        }
      }
 
      if resp.IsTruncated != nil && !*resp.IsTruncated {
        break
      }
 
      token = resp.NextContinuationToken
    }
  }
}
 
func main() {
	ctx := context.Background()
 
	cfg, err := config.LoadDefaultConfig(ctx)
	if err != nil {
		panic(err)
	}
 
	client := s3.NewFromConfig(cfg)
	bucket := "some-bucket-name"
 
	for obj := range S3Objects(ctx, client, bucket) {
		fmt.Printf("Object: %s (%d bytes)\n", *obj.Value.Key, *obj.Value.Size)
	}
}

Y esto, en mi opinión, es realmente cómodo. Ahora estoy trabajando en un proyecto que tiene un montón de dependencias, tanto entre servicios internos como externos, y la mitad de ellas usa en su API la paginación mediante tokens. Y los iteradores, usándolos de forma inteligente y con una buena estructura de proyecto, pueden simplificar mucho el trabajo con estas API.

Pipelines de datos

La construcción de pipelines es otra posibilidad muy potente de los iteradores que descubrí para mí.

Volvamos a mirar el código que escribimos arriba para la lectura perezosa desde la base de datos:

type User struct {
  ID   int
  Name string
  Err  error
}
 
func UsersFromRows(rows *sql.Rows) iter.Seq[User] {
  return func(yield func(User) bool) {
    defer rows.Close()
    for rows.Next() {
      var raw []byte
      if err := rows.Scan(&raw); err != nil {
        yield(User{Err: err})
        return
      }
 
      var user User
      if err := cbor.Unmarshal(raw, &user); err != nil {
        yield(User{Err: err})
        return
      }
 
      if !yield(user) {
        return
      }
    }
  }
}
 
...
 
rows, err := db.Query("select data from users_serialized")
if err != nil {
  panic(err)
}
 
for user := range UsersFromRows(rows) {
  if user.Err != nil {
      // ...
  }
 
  fmt.Printf("User: %+v\n", user)
}

Ahora imaginemos que este bucle, con el tiempo, empezó a usarse en una decena de sitios del proyecto, y en algunos de ellos apareció un filtrado adicional, por ejemplo, en algún lugar hay que mostrar solo los usuarios activos:

for user := range UsersFromRows(rows) {
  if user.Err != nil {
      // ...
  }
 
  if !user.IsActive {
    continue
  }
 
  fmt.Printf("User: %+v\n", user)
}

Y en otro sitio queremos además limitar la selección de usuarios a 10 elementos:

count := 0
for user := range UsersFromRows(rows) {
  if user.Err != nil {
      // ...
  }
 
  if !user.IsActive {
    continue
  }
 
  count++
  if count >= 10 {
    break
  }
 
  fmt.Printf("User: %+v\n", user)
}

Por lo general, con el tiempo habrá cada vez más filtrados de este tipo, y eso llevará a que en la base de código tengamos decenas de bucles donde una parte de la lógica se duplica y otra parte se diferencia, y no tendremos forma de extraerla y reutilizarla.

Esto se puede resolver mediante pipelines: una cadena de iteradores donde los datos en flujo pasan de forma secuencial por un conjunto de funciones.

Por ejemplo, queremos mostrar solo los usuarios activos, y además mostrar solo sus nombres, y limitar la selección a 10 elementos. Para filtrar a los usuarios por algún parámetro podemos escribir un iterador adicional:

func Filter[T any](seq iter.Seq[T], pred func(T) bool) iter.Seq[T] {
  return func(yield func(T) bool) {
    seq(func(item T) bool {
      if pred(item) {
        return yield(item)
      }
      return true
    })
  }
}

Para mapear el usuario a su nombre, un iterador Map:

func Map[T any, R any](seq iter.Seq[T], transform func(T) R) iter.Seq[R] {
  return func(yield func(R) bool) {
    seq(func(item T) bool {
      return yield(transform(item))
    })
  }
}

Y también escribamos un iterador adicional para limitar la selección de usuarios:

func Take[T any](seq iter.Seq[T], n int) iter.Seq[T] {
  return func(yield func(T) bool) {
    count := 0
    seq(func(item T) bool {
      if count >= n {
        return false
      }
      count++
      return yield(item)
    })
  }
}

Y el uso se vería así:

...
 
activeNames := Take(
  Map(
    Filter(UsersFromDB(db), func(u User) bool {
      return u.Active
    }),
    func(u User) string {
      return u.Name
    },
  ),
  10,
)
 
for name := range activeNames {
  fmt.Println("active user:", name)
}

Cuáles son las ventajas aquí:

  • Se pueden combinar iteradores para filtrar o modificar los datos en cualquier orden, reduciendo la duplicación de código

  • Mejor testeo: nos basta con cubrir con tests la lógica de los iteradores, y luego ya podemos testear todo el pipeline contra los valores esperados

Conclusiones

Los iteradores son un mecanismo potente y, en realidad, bastante simple si trabajas un poco con ellos y te acostumbras. Mientras escribía este artículo probé un montón de ejemplos de uso de iteradores, y los que destaqué arriba entrarán con seguridad en mi arsenal.

Tengo pensado ir ampliando este artículo a medida que vaya descubriendo nuevos casos interesantes, así que suscríbete :)

También llevo un canal de telegram en inglés con un resumen de material sobre Go:

https://t.me/the_dev_signal

Y mi canal personal de telegram, donde escribo distintas notas, herramientas y comparto experiencia:

https://t.me/junsenior