Go iterators (range-over-func) : le contrat de yield et les pièges

J'ai voulu transformer un helper de pagination en iterator « propre » avec la syntaxe range de Go 1.23. Trente secondes plus tard : panic: range function continued iteration after function call returned false. Le genre de message qui ne dit rien tant qu'on n'a pas compris le contrat sous-jacent. Les iterators range-over-func sont élégants, mais ils déplacent une partie de la responsabilité du compilateur vers toi : et c'est là que ça casse.

Voici comment ils marchent vraiment, et les trois pièges qui transforment un iterator « propre » en bug de production : le contrat de yield, le cleanup au break, et la fuite de iter.Pull.

Un iterator, c'est une fonction qui prend un yield

Depuis Go 1.23, tu peux faire un range sur une fonction. Le package iter standardise deux signatures : iter.Seq[V] (une valeur) et iter.Seq2[K, V] (deux, comme clé/valeur). Un iterator est une fonction qui reçoit un yield et l'appelle pour chaque élément :

// iter.Seq[int] = func(yield func(int) bool)
func Count(n int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := 0; i < n; i++ {
            if !yield(i) {
                return // le consommateur a arrêté → on s'arrête
            }
        }
    }
}

// côté appelant : la syntaxe range cache tout le mécanisme
for i := range Count(3) {
    fmt.Println(i) // 0, 1, 2
}

Quand tu écris for i := range Count(3), le compilateur transforme le corps de ta boucle en une closure yield qu'il passe à l'iterator. Chaque yield(i) exécute une itération de ta boucle. La valeur de retour de yield est la clé de tout : true = continue, false = le consommateur a fait un break ou un return.

Le contrat de yield : respecter le false, toujours

C'est le piège, celui de mon panic. Quand yield renvoie false, ton iterator doit arrêter immédiatement et ne plus jamais appeler yield. Si tu continues, le runtime panique pour t'empêcher de produire dans un consommateur qui n'écoute plus.

// ❌ ignore le retour de yield → panic au premier break du consommateur
func Bad(n int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := 0; i < n; i++ {
            yield(i) // on ne regarde JAMAIS le bool retourné
        }
    }
}

// ✅ respecte le contrat
func Good(n int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := 0; i < n; i++ {
            if !yield(i) {
                return
            }
        }
    }
}

La règle est mécanique : tout appel à yield doit être gardé par un if !yield(...) { return }. Si tu produis dans plusieurs branches, chacune doit respecter le contrat. C'est le prix de la syntaxe sucrée : le compilateur ne peut pas vérifier ça pour toi, donc il le vérifie à l'exécution, brutalement.

Le cleanup quand le consommateur fait break

Un iterator qui détient une ressource, un fichier, une connexion, un curseur DB, doit la libérer même si le consommateur s'arrête en plein milieu. Et comme un break côté appelant fait renvoyer false à yield puis sortir ta fonction, un simple defer à l'intérieur de l'iterator suffit, à condition de l'écrire :

func Lines(path string) iter.Seq[string] {
    return func(yield func(string) bool) {
        f, err := os.Open(path)
        if err != nil {
            return
        }
        defer f.Close() // ✅ s'exécute même si le consommateur break

        sc := bufio.NewScanner(f)
        for sc.Scan() {
            if !yield(sc.Text()) {
                return // defer f.Close() se déclenche ici aussi
            }
        }
    }
}

// le defer ferme le fichier, que la boucle aille au bout ou s'arrête à la 2e ligne
for line := range Lines("big.log") {
    if strings.Contains(line, "FATAL") {
        break
    }
}

L'erreur classique, c'est d'ouvrir la ressource en dehors de la fonction iterator (au moment de construire l'iter.Seq), où le defer ne couvre plus le cas du break. Ouvre la ressource dans la closure, ferme-la avec defer juste après. Même discipline d'annulation que pour ne pas fuiter une goroutine : prévoir la sortie anticipée dès la conception.

iter.Pull : tirer à la demande, et ne pas oublier stop()

Le range est un modèle « push » : l'iterator pousse les valeurs. Parfois tu veux « pull » : avancer deux séquences en parallèle, ou consommer une valeur à la fois sur décision. iter.Pull convertit un iterator push en deux fonctions : next() et stop() :

next, stop := iter.Pull(Count(1000))
defer stop() // ✅ OBLIGATOIRE, sinon fuite

for {
    v, ok := next()
    if !ok {
        break
    }
    if v == 42 {
        break // on s'arrête tôt : stop() (via defer) libère l'iterator
    }
}

iter.Pull fait tourner l'iterator dans une goroutine dédiée. Si tu ne consommes pas jusqu'au bout et que tu n'appelles pas stop(), cette goroutine reste bloquée pour toujours : c'est une fuite de goroutine pure. Le réflexe est invariable : next, stop := iter.Pull(...) suivi immédiatement de defer stop(). Pas d'exception.

Quand NE PAS faire un iterator

La hype pousse à tout transformer en iter.Seq. C'est une erreur. Un range-over-func a un coût : appels de fonction indirects, closures, des limites d'inlining que le compilateur ne franchit pas toujours. Pour une petite collection déjà en mémoire, un slice est plus simple et plus rapide.

// ❌ inutile : la donnée tient en mémoire, l'iterator n'apporte que de l'overhead
func Names() iter.Seq[string] { /* ... */ }

// ✅ rends un slice : plus simple à appeler, à tester, à composer
func Names() []string { return []string{"a", "b", "c"} }

Les iterators brillent sur ce qu'un slice fait mal : séquences lazy (calculées à la demande), infinies (un compteur, un flux), coûteuses (pagination d'API, lecture de gros fichier ligne à ligne), ou composées (filtres et maps chaînés sans matérialiser les intermédiaires). Si aucun de ces cas ne s'applique, rends un slice. C'est l'esprit Go de la simplicité par défaut : la feature la plus maline n'est pas toujours la bonne.

Conclusion

Les iterators range-over-func sont une vraie addition au langage, mais ils introduisent un contrat que le compilateur ne garde qu'à l'exécution : respecter le false de yield, fermer les ressources dans la closure, appeler stop() sur un iter.Pull. Trois règles, trois pièges. Une fois intégrées, elles deviennent automatiques.

La question à se poser avant d'en écrire un n'est pas « est-ce que je peux ? » mais « est-ce que la séquence est lazy, infinie, coûteuse ou composée ? ». Si oui, l'iterator est élégant et justifié. Sinon, tu ajoutes de la complexité et de l'overhead pour rendre un slice, et un slice, ça ne panique jamais.

Commentaires (0)