Funciones iteradoras

Go 1.23 introduce las funciones iteradoras, que se parecen al forEach de otros lenguajes de programación. Su construcción puede resultar un poco convulsa al principio, pero entre las ventajas que aportan está su integración limpia con los ranges de Go.

Las funciones iteradoras se incorporaron al lenguaje de programación Go en la versión 1.23. Esta característica permite no sólo fabricar iteradores en el lenguaje de programación Go, sino también integrarlos con la palabra clave range. Esta palabra es la que usas para recorrer un array, un slice o un mapa, y a partir de la versión 1.23, puedes usarla para recorrer un iterador.

Algunos sectores consideran que este enfoque es demasiado funcional para lo que típicamente ha representado Go hasta ahora, un lenguaje más imperativo, por lo que esta característica no está absuelta de polémica.

En cualquier caso, este iterador está parcialmente basado en el concepto general de iterador (es decir, en el patrón iterador), y se basa en dos tipos muy concretos que se han incorporado a la API: iter.Seq e iter.Seq2:

type (
    Seq[V any]     func(yield func(V) bool)
    Seq2[K, V any] func(yield func(K, V) bool)
)

Ambos son muy similares, pero se diferencian únicamente en la aridad. Por lo tanto, voy a cubrir primero la explicación general con iter.Seq, y después te hablo de qué diferencia hay entre iter.Seq e iter.Seq2.

iter.Seq

Esta forma es la que definen dos tipos que se han incorporado al nuevo paquete iter: iter.Seq e iter.Seq2. De acuerdo con la documentación del paquete iter, tienen la siguiente forma:

Es decir, el tipo Seq se corresponde con una función que acepte como parámetro otra función, la cual a su vez acepta un parámetro de tipo V (genérico), y devuelve un bool. Wow. Aquí hay que empaquetar demasiadas cosas, es como una oración subordinada. Vamos a mirarlo más despacio.

Una función iteradora (iter.Seq) va a ser una función, como su propio nombre indica. Generalmente, esta función está asociada con la colección de datos sobre la que vamos a iterar. Por ejemplo, es posible fabricar una función iteradora a partir de un array, un slice o un mapa, de modo que se puedan recorrer mediante una función iteradora. Anótate mi lección sobre las nuevas funciones All y Collect, tal vez te interese verla a continuación para profundizar sobre este tema.

Sin embargo, al igual que ocurre con cualquier otro iterador, una función iteradora también podría ser una función hecha artesanalmente, que permita fabricar una secuencia de elementos sobre la marcha usando código. Por ejemplo, una función iteradora podría ir calculando los números primos comprendidos entre 1 y 1000 sobre la marcha, o los valores de la secuencia de Fibonacci, y exponerlos como un iterador.

Cuando llames a una función iteradora, tendrás que hacerlo pasándole como parámetro una función de callback. Es el parámetro que ves en el tipo que hay declarado arriba. La documentación de Go denomina este parámetro yield. Si me preguntas a mí, no es un nombre muy claro, porque si vienes de otro lenguaje de programación en el que yield sea una palabra clave, como Python, tal vez te lleve a confusión. Vamos a dejarlo claro, yield no es una palabra clave de Go. Es únicamente que han decidido llamar a su parámetro así.

Esta función de callback, como puedes ver en la definición de iter.Seq, acepta un parámetro de tipo V como argumento, y devuelve un boolean. Cuando invoques tu función iteradora con esta función, lo que la función iteradora debe hacer (concretamente, lo que debes hacer que haga tu función si eres tú quien la programa) es llamar a ese callback tantas veces como valores tenga tu colección.

Es decir, si fabricas un iterador para el array {0, 1, 2, 3, 4}, tu callback se llamará 5 veces, y como parámetro le entrará cada uno de los valores del array. Esta es la razón por la que llamo a mi parámetro la función de callback. Se parece a lo que otros lenguajes de programación llaman forEach, como JavaScript, donde puedes pasarle una función de callback que se invocará para cada elemento de la colección.

Te habrás dado cuenta que ese callback devuelve un valor bool. Eso es porque puedes usar el retorno para detener antes de tiempo el iterador. El funcionamiento es muy simple. Tu función de callback debe devolver verdadero o falso tras cada invocación que se le haga. Si devuelve true, y realmente hay más elementos por recorrer, seguirá llamando. En cuanto tu función de callback devuelva false, le señalizará al iterador que no quiere más elementos y el iterador dejará de recorrerse. Con esto podemos interrumpir de forma temprana el recorrido del iterador.

Entonces, imaginemos que quiero definir una función iteradora que cuente hasta N, donde N es un número que le paso como parámetro. Para hacer las cosas más sencillas, voy a hacer una función que devuelva funciones iteradoras:

func iterarHasta(n int) iter.Seq[int] {
  return func(cb func(v int) bool) {

  }
}

Esta función acepta como parámetro la n para especificar hasta qué número voy a contar, y devuelve como resultado un iter.Seq[int]. Seq es un tipo con genérico, así que tengo que especificar que en concreto este es un iterador que va a trabajar con ints.

Devuelve una función iteradora que deberá invocar la función cb para cada elemento que forme parte del mismo. En este caso, para iterar entre el 1 y el N, puedo usar un bucle for tradicional:

func iterarHasta(n int) iter.Seq[int] {
  return func(cb func(v int) bool) {
    /* Incompleto! Lee debajo */
    for i := 1; i <= n; i++ {
      cb(i)
    }
  }
}

FInalmente, tengo que recordar que mi función de callback puede devolver falso si quiere que deje de recorrer elementos. Si compruebo cada resultado de llamar al callback, puedo interrumpir el iterador antes de tiempo. Este es el resultado final de mi función iteradora:

func iterarHasta(n int) iter.Seq[int] {
  return func(cb func(v int) bool) {
    for i := 1; i <= n; i++ {
      if !cb(i) {
        return
      }
    }
  }
}

Para usar este iterador, ahora todo lo que tengo que hacer es llamar a la función iterarHasta, para obtener un iterador concreto, y luego invocarlo pasándole el callback como parámetro. Aquí te doy dos ejemplos. En el primer caso, simplemente recorro de forma pasiva cada elemento del iterador:

it := iterarHasta(10)
it(func(num int) bool {
  fmt.Println("Siguiente valor", num)
  return true
})

Como mi callback siempre devuelve true, siempre se pide el siguiente elemento. Si ejecutamos este código, dado que llamar a la función it provoca que se ejecute ese bucle, el cual en consecuencia llama a mi callback, todas las piezas encajan y el sistema despega.

Siguiente valor 1
Siguiente valor 2
Siguiente valor 3
Siguiente valor 4
Siguiente valor 5
Siguiente valor 6
Siguiente valor 7
Siguiente valor 8
Siguiente valor 9
Siguiente valor 10

Finalmente, vamos a poner un ejemplo de esta interrupción temprana del recorrido. Vamos a hacer que la ejecución del iterador se detenga de forma abrupta si el valor sobre el que iteramos es múltiplo de 5:

it(func(val int) bool {
  fmt.Println("Siguiente valor", val)
  if val%5 == 0 {
    return false
  }
  return true
})

(Sí, podría haber un único return, pero con fines didácticos quería ser explícito). En este caso, en cuanto la condición haga que se devuelva false, la llamada a it lo detectará y abandonará la ejecución, así que ahora solo se escriben los valores del 1 al 5. Ten en cuenta que la iteración para la que devolvemos false se ejecuta también. Por lo tanto, tienes que tratar esta condición de salida adelantada como la de un do-while, no como la de un while. Si hay una iteración que quieres saltarte, tienes que pararlo inmediatamente antes, no después.

iter.Seq2

Te habrás dado cuenta que hay otro tipo, iter.Seq2. Este se usa para crear funciones iteradoras que aceptan un par clave-valor. Por eso el genérico lleva dos tipos, K y V, y por eso el callback también acepta dos parámetros.

Generalmente, vas a usar iter.Seq cuando quieras iterar sobre elementos de un array o slice, donde lo único que te importe sea el elemento sobre el que estás iterando; y vas a dejar iter.Seq2 cuando tengas un par de elementos sobre los que iterar, como puede ser el elemento y su posición del array, o bien los pares clave-valor de un mapa, que es donde más partido le vamos a sacar a esta función.

Funciones generadoras y range

El secreto de estos tipos está en su integración con la palabra clave range. Y esta es la auténtica crema. Si has llegado hasta aquí en la lección, esta es la mejor parte. Ahora, la palabra clave range acepta como parámetro a su lado derecho directamente una función iteradora. Esa función tiene que tener la aridad compatible, es decir, debe ser o un iter.Seq o un iter.Seq2. Mientras sea así, podrás colocarla tal cual:

// nuevoIterador(int) es una función que devuelve un iter.Seq:
for val := range nuevoIterador(10) {
  // ...
}

// parIterador(int) es una función que devuelve un iter.Seq2:
for k, v := range parIterador(10) {
  // ...
}

Gracias a esta construcción, ahora ya no es necesario que pre-computes todo un array antes de empezar a recorrerlo en un range, sino que podrás hacer un range que sea lazy y donde los elementos no se generen hasta que llega su iteración del bucle.

Lista de reproducción
  1. 1
    Funciones iteradoras
    10 minutos
  2. 2
    Funciones All y Collect
    8 minutos
  3. 3
    Cómo usar pull-iterators
    7 minutos
  4. 4
    Interning en Go con unique.Make
    8 minutos