Iteradores en Go 1.23: Diferencias entre Push y Pull

Los modelos de iteradores: push vs. pull en Go 1.23 representan dos paradigmas distintos de control de flujo para el procesamiento de secuencias, diferenciándose fundamentalmente por qué entidad ostenta la responsabilidad de avanzar la ejecución. Mientras que el modelo push es el estándar para la instrucción for-range, el modelo pull proporciona un mecanismo imperativo para escenarios donde el consumidor requiere un control granular sobre el momento exacto en que se recupera el siguiente elemento.

Este comportamiento dual existe para dotar a Go de una flexibilidad que otros lenguajes resuelven mediante generadores pesados o hilos. El modelo push es extremadamente eficiente y sencillo para la mayoría de los casos de uso, pero falla en situaciones complejas como el “zip” de dos secuencias (iterar dos colecciones simultáneamente) o la comparación de flujos. El paquete iter resuelve esto permitiendo transformar un iterador push en uno pull de forma transparente, eliminando la necesidad de implementar manualmente estados complejos o canales de sincronización.

El modelo Push y la inversión de control

El modelo push, tipificado por iter.Seq[V] e iter.Seq2[K, V], se basa en una función iteradora que “empuja” valores hacia un callback yield. En este esquema, el iterador tiene el control del bucle y el scope de la ejecución. Es la forma más idiomática de Go 1.23 porque permite al compilador realizar optimizaciones de inlining, tratando el cuerpo del bucle como una simple función de retorno. El estado de la iteración reside en el stack de la función iteradora, lo que minimiza las alocaciones en el heap.

Sin embargo, su limitación intrínseca es la falta de autonomía del consumidor: una vez que comienza el bucle, el consumidor solo puede decidir si continuar o detenerse (mediante el booleano devuelto por yield), pero no puede “pausar” la iteración para realizar otras tareas y reanudarla arbitrariamente fuera del contexto de ese bucle específico.

Transformación al modelo Pull con iter.Pull

Para escenarios de consumo no lineal, Go introduce iter.Pull. Esta función acepta un iterador de tipo push y devuelve dos funciones: next() (V, bool) y stop(). Este mecanismo cambia la propiedad del control de flujo. Al llamar a next, el consumidor solicita proactivamente el siguiente valor, emulando el comportamiento de un iterador tradicional en lenguajes como Python o Java.

Internamente, iter.Pull gestiona la ejecución del iterador push de forma suspendida. Aunque el underlying type sigue siendo una función, el runtime de Go coordina la transferencia de valores. Es indispensable invocar a stop() para liberar los recursos asociados, especialmente si la iteración no llega a su fin natural, garantizando que no existan fugas en el mecanismo de suspensión.

package main

import (
	"fmt"
	"iter"
)

func main() {
	// Secuencia push simple
	numbers := func(yield func(int) bool) {
		for i := 1; i <= 5; i++ {
			if !yield(i) {
				return
			}
		}
	}

	// Conversión a modelo pull
	next, stop := iter.Pull(numbers)
	defer stop() // Garantiza la limpieza de la corrutina interna

	// Consumo manual y selectivo
	val1, ok1 := next()
	fmt.Printf("Valor 1: %d, ok: %t\n", val1, ok1) // Valor 1: 1, ok: true

	val2, ok2 := next()
	fmt.Printf("Valor 2: %d, ok: %t\n", val2, ok2) // Valor 2: 2, ok: true

	// El pull permite lógica condicional entre pasos
	if val2 > 0 {
		v, _ := next()
		fmt.Println("Siguiente tras condición:", v) // Siguiente tras condición: 3
	}
}
Go

El comportamiento más contraintuitivo de este patrón es que next() devuelve el valor cero del tipo y false tanto cuando la secuencia termina normalmente como después de haber llamado a stop(). No hay una distinción en el valor de retorno para estos dos estados finales.

La gestión de corrutinas internas en el ciclo de vida de iter.Pull

Un aspecto no obvio del runtime es que iter.Pull no utiliza goroutines estándar del usuario, sino un mecanismo de corrutinas integradas en el scheduler de Go. Cuando se llama a iter.Pull, el runtime inicia la función push en un contexto de ejecución separado. Cada llamada a next() realiza un cambio de contexto (hand-off) hacia esa corrutina para que ejecute hasta el siguiente yield.

Un edge case crítico ocurre cuando se abandona un iterador pull sin llamar a stop(). Si la función push original contenía sentencias defer (como el cierre de un archivo o la liberación de un mutex), estas nunca se ejecutarán si la corrutina queda suspendida indefinidamente esperando un yield que el consumidor nunca procesará. El recolector de basura no puede limpiar la memoria de una corrutina que aún es alcanzable pero está bloqueada, lo que resulta en una fuga de recursos persistente. Por lo tanto, la assignability de las funciones devueltas por iter.Pull siempre debe ir ligada a un patrón de limpieza estricto mediante defer stop().


  • Módulo: Iteradores
  • Artículo número: #66

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio