Skip to content

Iteradores

En Go, la palabra clave utilizada para iterar sobre estructuras de datos específicas es for range. En capítulos anteriores ya se han presentado algunas de sus aplicaciones. Solo puede actuar sobre algunas estructuras de datos incorporadas en el lenguaje:

  • Arrays
  • Slices
  • Cadenas
  • map
  • chan
  • Valores enteros

De esta manera, su uso es muy inflexible, sin extensibilidad, y casi no es compatible con tipos personalizados. Afortunadamente, después de la actualización de Go 1.23, la palabra clave for range admite range over func, lo que hace posible los iteradores personalizados.

Conociendo los Iteradores

A continuación, presentamos un ejemplo para conocer初步 los iteradores. No sé si recordáis el ejemplo de resolver la secuencia de Fibonacci con closures explicado en la sección de funciones. Su código de implementación es el siguiente:

go
func Fibonacci(n int) func() (int, bool) {
  a, b, c := 1, 1, 2
  i := 0
  return func() (int, bool) {
    if i >= n {
      return 0, false
    } else if i < 2 {
      f := i
      i++
      return f, true
    }

    a, b = b, c
    c = a + b
    i++

    return a, true
  }
}

Podemos transformarlo en un iterador, como se muestra a continuación. Se puede ver que la cantidad de código se ha reducido:

go
func Fibonacci(n int) func(yield func(int) bool) {
  a, b, c := 0, 1, 1
  return func(yield func(int) bool) {
    for range n {
      if !yield(a) {
        return
      }
      a, b = b, c
      c = a + b
    }
  }
}

Los iteradores de Go tienen estilo range over func. Podemos usar directamente la palabra clave for range para utilizarlos, y su uso es más conveniente que antes:

go
func main() {
    n := 8
  for f := range Fibonacci(n) {
    fmt.Println(f)
  }
}

La salida es la siguiente:

0
1
1
2
3
5
8
13

Como se muestra arriba, un iterador es simplemente una función closure que acepta una función callback como parámetro. Incluso puedes ver palabras como yield dentro, que deberían ser familiares para quienes han escrito Python. Es similar a los generadores en Python. Los iteradores de Go no agregan ninguna palabra clave nueva ni características de sintaxis. En el ejemplo anterior, yield es solo una función callback, no es una palabra clave. El nombre oficial se eligió para facilitar la comprensión.

Iteradores de Empuje (Pushing Iterator)

Para la definición de iteradores, podemos encontrar la siguiente explicación en la biblioteca iter:

An iterator is a function that passes successive elements of a sequence to a callback function, conventionally named yield.

Un iterador es una función que pasa sucesivamente elementos de una secuencia a una función callback, convencionalmente llamada yield.

Lo que podemos confirmar claramente es que un iterador es una función que acepta una función callback como parámetro. Durante el proceso de iteración, pasará los elementos de la secuencia uno por uno a la función callback yield. En el ejemplo anterior, usamos el iterador de la siguiente manera:

go
for f := range Fibonacci(n) {
    fmt.Println(f)
}

Según la definición oficial, el uso del iterador Backward anterior es equivalente al siguiente código:

go
Fibonacci(n)(func(f int) bool {
    fmt.Println(f)
    return true
})

El cuerpo del bucle es la función callback yield del iterador. Cuando la función devuelve true, el iterador continuará iterando; de lo contrario, se detendrá.

Además, la biblioteca estándar iter también define el tipo de iterador iter.Seq, cuyo tipo es simplemente una función:

go
type Seq[V any] func(yield func(V) bool)

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

La función callback de iter.Seq solo acepta un parámetro, por lo que al iterar, for range solo tiene un valor de retorno, como sigue:

go
for v := range iter {
  // body
}

La función callback de iter.Seq2 acepta dos parámetros, por lo que al iterar, for range tiene dos valores de retorno, como sigue:

go
for k, v := range iter {
  // body
}

Aunque la biblioteca estándar no define Seq con 0 parámetros, esto también está completamente permitido. Equivale a:

go
func(yield func() bool)

Su uso es el siguiente:

go
for range iter {
  // body
}

La cantidad de parámetros de la función callback solo puede ser de 0 a 2. Más parámetros no pasarán la compilación.

En resumen, el cuerpo del bucle en for range es la función callback yield en el iterador. Cuantos valores devuelva for range, tantos parámetros de entrada tendrá la función yield correspondiente. En cada iteración, el iterador llamará a la función yield, es decir, ejecutará el código en el cuerpo del bucle, pasando activamente los elementos de la secuencia a la función yield. A este tipo de iterador que pasa activamente elementos generalmente lo llamamos iterador de empuje (pushing iterator). Un ejemplo típico es foreach en otros lenguajes, como en JavaScript:

javascript
let arr = [1, 2, 3, 4, 5];
arr
  .filter((e) => e % 2 === 0)
  .forEach((e) => {
    console.log(e);
  });

La forma de manifestación en Go es que range devuelve los elementos iterados:

go
for index, value := range iterator() {
  fmt.Println(index, value)
}

En algunos lenguajes (como Java), tiene otro nombre: procesamiento de flujo de datos.

Dado que el código en el cuerpo del bucle se pasa al iterador como una función callback, y es muy probable que sea una función closure, Go necesita hacer que una función closure se comporte como un segmento de código de bucle ordinario al ejecutar palabras clave como defer, return, break, goto, etc. Consideremos las siguientes situaciones:

Por ejemplo, si retornamos en un bucle de iterador, ¿cómo manejar este return en la función callback yield?

go
for index, value := range iterator() {
    if value > 10 {
        return
  }
  fmt.Println(index, value)
}

No es posible retornar directamente en la función callback, ya que esto solo detendría la iteración, no lograría el efecto de retorno:

go
iterator()(func(index int, value int) bool {
  if value > 10 {
    return false
  }
  fmt.Println(index, value)
})

Otro ejemplo es usar defer en un bucle de iterador:

go
for index, value := range iterator() {
    defer fmt.Println(index, value)
}

Tampoco se puede usar defer directamente en la función callback, porque esto causaría que se llamara con retraso al finalizar la función callback:

go
iterator()(func(index int, value int) bool {
  defer fmt.Println(index, value)
})

Las otras palabras clave break, continue, goto son similares. Afortunadamente, Go ya ha manejado estas situaciones por nosotros, solo necesitamos usarlas. No es necesario preocuparse por esto por ahora. Si estás interesado, puedes explorar el código fuente en rangefunc/rewrite.go.

Iteradores de Extracción (Pulling Iterator)

Los iteradores de empuje (pushing iterator) son controlados por el iterador para la lógica de iteración, donde el usuario obtiene elementos pasivamente. Por el contrario, los iteradores de extracción (pulling iterator) son controlados por el usuario para la lógica de iteración, obteniendo activamente elementos de la secuencia. En general, los iteradores de extracción tendrán funciones específicas como next(), stop() para controlar el inicio o finalización de la iteración. Pueden ser una closure o una estructura.

go
scanner := bufio.NewScanner(file)
for scanner.Scan() {
    line, err := scanner.Text(), scanner.Err()
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(line)
}

Como se muestra arriba, Scanner obtiene la siguiente línea de texto del archivo mediante el método Text(), y usa el método Scan() para indicar si la iteración ha terminado. Este es también un modo de iterador de extracción. Scanner usa una estructura para registrar el estado, mientras que los iteradores de extracción definidos en la biblioteca iter usan closures para registrar el estado. Podemos usar las funciones iter.Pull o iter.Pull2 para convertir un iterador de empuje estándar en un iterador de extracción. La diferencia entre iter.Pull e iter.Pull2 es que el segundo tiene dos valores de retorno. Las firmas son las siguientes:

go
func Pull[V any](seq Seq[V]) (next func() (V, bool), stop func())

func Pull2[K, V any](seq Seq2[K, V]) (next func() (K, V, bool), stop func())

Ambos aceptan un iterador como parámetro y luego devuelven dos funciones next() y stop() para controlar la continuación y detención de la iteración:

go
func next() (V, bool)

func stop()

next devolverá el elemento iterado y un valor booleano que indica si el valor actual es válido. Cuando la iteración termina, la función next devolverá el valor cero del elemento y false. La función stop terminará el proceso de iteración. Cuando el llamador ya no use el iterador, debe usar la función stop para terminar la iteración. Por cierto, es un error que múltiples goroutines llamen a la función next del mismo iterador, ya que no es seguro para concurrencia.

A continuación, presentamos un ejemplo para demostrar. Su función es transformar el iterador de Fibonacci anterior en un iterador de extracción:

go
func main() {
  n := 10
  next, stop := iter.Pull(Fibonacci(n))
  defer stop()
  for {
    fibn, ok := next()
    if !ok {
      break
    }
    fmt.Println(fibn)
  }
}

Salida:

0
1
1
2
3
5
8
13
21
34

De esta manera, podemos controlar manualmente la lógica de iteración mediante las funciones next y stop. Quizás pienses que esto es innecesario. Si vas a hacer esto, ¿por qué no usar directamente la versión closure original? También podrías controlar la iteración tú mismo. El uso de closures es así:

go
func main() {
  fib := Fibonacci(10)
    for {
        n, ok := fib()
        if !ok {
            break
        }
        fmt.Println(n)
    }
}

Proceso de transformación: closure → iterador → iterador de extracción. El uso de closures e iteradores de extracción es casi el mismo, su filosofía es la misma. Este último también puede sufrir retrasos de rendimiento debido a varios procesamientos. Francamente, hacer esto es realmente innecesario. Sus escenarios de aplicación realmente no son muchos. Sin embargo, iter.Pull existe para iter.Seq, es decir, existe para convertir iteradores de empuje en iteradores de extracción. Si solo quieres un iterador de extracción, y además implementas un iterador de empuje específicamente para transformarlo, deberías considerar la complejidad y el rendimiento de tu propia implementación. Como en este ejemplo de la secuencia de Fibonacci, das una vuelta y vuelves al punto de partida. El único beneficio podría ser cumplir con la especificación oficial de iteradores.

Manejo de Errores

¿Qué hacer si ocurre un error durante la iteración? Podemos pasarlo a la función yield para que for range lo devuelva, permitiendo que el llamador lo maneje, como en el siguiente ejemplo de iterador de líneas:

go
func ScanLines(reader io.Reader) iter.Seq2[string, error] {
  scanner := bufio.NewScanner(reader)
  return func(yield func(string, error) bool) {
    for scanner.Scan() {
      if !yield(scanner.Text(), scanner.Err()) {
        return
      }
    }
  }
}

TIP

Vale la pena señalar que el iterador ScanLines es de un solo uso. No se puede usar nuevamente después de cerrar el archivo.

Se puede ver que su segundo valor de retorno es de tipo error. Su uso es el siguiente:

go
for line, err := range ScanLines(file) {
    if err != nil {
        fmt.Println(err)
        break
    }
    fmt.Println(line)
}

Este manejo es similar al manejo de errores ordinario. Los iteradores de extracción también son lo mismo:

go
next, stop := iter.Pull2(ScanLines(file))
defer stop()
for {
    line, err, ok := next()
    if err != nil {
        fmt.Println(err)
        break
    } else if !ok {
        break
    }
    fmt.Println(line)
}

Si ocurre un panic, simplemente usa recover como de costumbre:

go
defer func() {
    if err := recover(); err != nil {
        fmt.Println("panic:", err)
        os.Exit(1)
    }
}()

for line, err := range ScanLines(file) {
    if err != nil {
        fmt.Println(err)
        break
    }
    fmt.Println(line)
}

Los iteradores de extracción siguen siendo lo mismo, no se demostrará aquí.

Biblioteca Estándar

Muchas bibliotecas estándar también admiten iteradores. Las más utilizadas son las bibliotecas slices y maps. A continuación, presentamos algunas funciones bastante prácticas.

slices.All

go
func All[Slice ~[]E, E any](s Slice) iter.Seq2[int, E]

slices.All convertirá un slice en un iterador de slice:

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  for i, n := range slices.All(s) {
    fmt.Println(i, n)
  }
}

Salida:

0 1
1 2
2 3
3 4
4 5

slices.Values

go
func Values[Slice ~[]E, E any](s Slice) iter.Seq[E]

slices.Values convertirá un slice en un iterador de slice, pero sin índices:

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  for n := range slices.Values(s) {
    fmt.Println(n)
  }
}

Salida:

1
2
3
4
5

slices.Chunk

go
func Chunk[Slice ~[]E, E any](s Slice, n int) iter.Seq[Slice]

La función slices.Chunk devolverá un iterador que enviará slices de n elementos al llamador:

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  for chunk := range slices.Chunk(s, 2) {
    fmt.Println(chunk)
  }
}

Salida:

[1 2]
[3 4]
[5]

slices.Collect

go
func Collect[E any](seq iter.Seq[E]) []E

La función slices.Collect recopilará un iterador de slice en un slice:

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  s2 := slices.Collect(slices.Values(s))
  fmt.Println(s2)
}

Salida:

[1 2 3 4 5]

maps.Keys

go
func Keys[Map ~map[K]V, K comparable, V any](m Map) iter.Seq[K]

maps.Keys devolverá un iterador de todas las claves del map. Se puede usar junto con slices.Collect para recopilar directamente en un slice:

go
func main() {
  m := map[string]int{"one": 1, "two": 2, "three": 3}
  keys := slices.Collect(maps.Keys(m))
  fmt.Println(keys)
}

Salida:

[three one two]

Dado que map es desordenado, la salida tampoco es fija.

maps.Values

go
func Values[Map ~map[K]V, K comparable, V any](m Map) iter.Seq[V]

maps.Values devolverá un iterador de todos los valores del map. Se puede usar junto con slices.Collect para recopilar directamente en un slice:

go
func main() {
  m := map[string]int{"one": 1, "two": 2, "three": 3}
  keys := slices.Collect(maps.Values(m))
  fmt.Println(keys)
}

Salida:

[3 1 2]

Dado que map es desordenado, la salida tampoco es fija.

maps.All

go
func All[Map ~map[K]V, K comparable, V any](m Map) iter.Seq2[K, V]

maps.All puede convertir un map en un iterador de map:

go
func main() {
  m := map[string]int{"one": 1, "two": 2, "three": 3}
  for k, v := range maps.All(m) {
    fmt.Println(k, v)
  }
}

Generalmente no se usa directamente así, sino que se usa junto con otras funciones de procesamiento de flujo de datos.

maps.Collect

go
func Collect[K comparable, V any](seq iter.Seq2[K, V]) map[K]V

maps.Collect puede recopilar un iterador de map en un map:

go
func main() {
  m := map[string]int{"one": 1, "two": 2, "three": 3}
  m2 := maps.Collect(maps.All(m))
  fmt.Println(m2)
}

La función collect generalmente se usa como función terminal para el procesamiento de flujo de datos.

Llamadas Encadenadas

A través de las funciones proporcionadas por la biblioteca estándar mencionadas anteriormente, podemos combinarlas para procesar flujos de datos, como ordenar un flujo de datos:

go
sortedSlices := slices.Sorted(slices.Values(s))

Los iteradores de Go usan closures, solo se pueden anidar llamadas a funciones como esta. No se pueden hacer llamadas encadenadas directamente. Cuando la cadena de llamadas es larga, la legibilidad será muy pobre. Pero podemos usar una estructura para registrar el iterador, lo que nos permite implementar llamadas encadenadas.

Demo

Una demostración simple de llamadas encadenadas se muestra a continuación. Contiene funciones comúnmente usadas como Filter, Map, Find, Some, etc.

go
package iterx

import (
  "iter"
  "slices"
)

type SliceSeq[E any] struct {
  seq iter.Seq2[int, E]
}

func (s SliceSeq[E]) All() iter.Seq2[int, E] {
  return s.seq
}

func (s SliceSeq[E]) Filter(filter func(int, E) bool) SliceSeq[E] {
  return SliceSeq[E]{
    seq: func(yield func(int, E) bool) {
      // Reorganizar índices
      i := 0
      for k, v := range s.seq {
        if filter(k, v) {
          if !yield(i, v) {
            return
          }
          i++
        }
      }
    },
  }
}

func (s SliceSeq[E]) Map(mapFn func(E) E) SliceSeq[E] {
  return SliceSeq[E]{
    seq: func(yield func(int, E) bool) {
      for k, v := range s.seq {
        if !yield(k, mapFn(v)) {
          return
        }
      }
    },
  }
}

func (s SliceSeq[E]) Fill(fill E) SliceSeq[E] {
  return SliceSeq[E]{
    seq: func(yield func(int, E) bool) {
      for i, _ := range s.seq {
        if !yield(i, fill) {
          return
        }
      }
    },
  }
}

func (s SliceSeq[E]) Find(equal func(int, E) bool) (_ E) {
  for i, v := range s.seq {
    if equal(i, v) {
      return v
    }
  }
  return
}

func (s SliceSeq[E]) Some(match func(int, E) bool) bool {
  for i, v := range s.seq {
    if match(i, v) {
      return true
    }
  }
  return false
}

func (s SliceSeq[E]) Every(match func(int, E) bool) bool {
  for i, v := range s.seq {
    if !match(i, v) {
      return false
    }
  }
  return true
}

func (s SliceSeq[E]) Collect() []E {
  var res []E
  for _, v := range s.seq {
    res = append(res, v)
  }
  return res
}

func (s SliceSeq[E]) Sort(cmp func(x, y E) int) []E {
  collect := s.Collect()
  slices.SortFunc(collect, cmp)
  return collect
}

func (s SliceSeq[E]) SortStable(cmp func(x, y E) int) []E {
  collect := s.Collect()
  slices.SortStableFunc(collect, cmp)
  return collect
}

func Slice[S ~[]E, E any](s S) SliceSeq[E] {
  return SliceSeq[E]{seq: slices.All(s)}
}

Luego podemos procesar mediante llamadas encadenadas. Veamos algunos casos de uso.

Procesar valores de elementos

go
func main() {
  s := []string{"apple", "banana", "cherry"}
  all := iterx.Slice(s).Map(strings.ToUpper).All()
  for i, v := range all {
    fmt.Printf("index: %d, value: %s\n", i, v)
  }
}

Salida:

index: 0, value: APPLE
index: 1, value: BANANA
index: 2, value: CHERRY

Buscar un valor específico

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  result := iterx.Slice(s).Find(func(i int, e int) bool {
    return e == 3
  })
  fmt.Println(result)
}

Salida:

3

Rellenar slice

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  result := iterx.Slice(s).Fill(6).Collect()
  fmt.Println(result)
}

Salida:

[6 6 6 6 6]

Filtrar elementos

go
func main() {
  s := []int{1, 2, 3, 4, 5}
  filter := iterx.Slice(s).Filter(func(i int, e int) bool {
    return e%2 == 0
  }).All()
  for i, v := range filter {
    fmt.Printf("Index: %d, Value: %d\n", i, v)
  }
}

Salida:

Index: 0, Value: 2
Index: 1, Value: 4

Es lamentable que Go aún no admita funciones anónimas abreviadas, como las funciones flecha en JavaScript, Rust o Java. De lo contrario, las llamadas encadenadas podrían ser más concisas y elegantes.

Rendimiento

Dado que Go ha realizado muchos procesamientos para los iteradores, su rendimiento definitivamente no es tan bueno como el bucle for range nativo. Tomemos el ejemplo más simple de iterar sobre un slice para probar la diferencia de rendimiento entre ellos. Se dividen en los siguientes tipos:

  • Bucle for nativo
  • Iterador de empuje
  • Iterador de extracción

El código de prueba es el siguiente. La longitud del slice de prueba es 1000:

go
package main

import (
  "iter"
  "slices"
  "testing"
)

var s []int

const n = 10000

func init() {
  for i := range n {
    s = append(s, i)
  }
}

func testNaiveFor(s []int) {
  for i, n := range s {
    _ = i
    _ = n
  }
}

func testPushing(s []int) {
  for i, n := range slices.All(s) {
    _ = i
    _ = n
  }
}

func testPulling(s []int) {
  next, stop := iter.Pull2(slices.All(s))
  for {
    i, n, ok := next()
    if !ok {
      stop()
      return
    }
    _ = i
    _ = n
  }
}

func BenchmarkNaive_10000(b *testing.B) {
  for range b.N {
    testNaiveFor(s)
  }
}

func BenchmarkPushing_10000(b *testing.B) {
  for range b.N {
    testPushing(s)
  }
}

func BenchmarkPulling_10000(b *testing.B) {
  for range b.N {
    testPulling(s)
  }
}

Los resultados de la prueba son los siguientes:

goos: windows
goarch: amd64
pkg: golearn
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BenchmarkNaive_10000
BenchmarkNaive_10000-16           492658              2398 ns/op               0 B/op          0 allocs/op
BenchmarkPushing_10000
BenchmarkPushing_10000-16         315889              3707 ns/op               0 B/op          0 allocs/op
BenchmarkPulling_10000
BenchmarkPulling_10000-16           2016            574509 ns/op             440 B/op         14 allocs/op
PASS
ok      golearn 4.029s

Podemos ver por los resultados que la diferencia entre el iterador de empuje y el bucle for range nativo no es特别 grande, pero el iterador de extracción es casi dos órdenes de magnitud más lento que los dos anteriores. Al usarlos, cada uno debe considerar su situación real.

Resumen

Al igual que con los genéricos, los iteradores de Go también son controvertidos. Algunos opinan que los iteradores introducen demasiada complejidad, violando la filosofía de simplicidad de Go. Con este tipo de código de closures de iteradores, el depuración puede ser difícil, y la lectura puede ser aún más frustrante.

Puedes ver discusiones intensas sobre iteradores en muchos lugares:

Mirando racionalmente los iteradores de Go, ciertamente hacen que escribir código sea más conveniente, especialmente al manejar tipos de slice, pero al mismo tiempo introducen cierta complejidad. La legibilidad del código de la parte de iteradores disminuirá. Sin embargo, en general, creo que esta es realmente una característica práctica.

Golang editado por www.golangdev.cn