Itération

Tout comme il existe une abstraction d’itérateur pour itérer sur des données en C++, il en existe aussi une en Rust. Cependant les itérateurs de Rust sont un peu différents de ceux de C++, car ils savent quand ils se terminent (à la manière des ranges de C++20). Cela leur permet de supporter beaucoup plus d’opérations de façon ergonomique.

De plus, en accord avec les objectifs de conception de Rust, les itérateurs Rust interdisent toutes sortes de comportements indéfinis permis par les itérateurs C++ (accès hors bornes, invalidation…).

Types d’itérateurs

On l’a vu, en Rust, il y a trois façons de base de transmettre une valeur à une fonction :

  • Par valeur
  • Par référence partagée
  • Par référence mutable

En toute logique, on a donc trois façons conventionnelles d’itérer sur une collection d’éléments. Ces façons de faire sont accessibles via des méthodes aux noms tout aussi conventionnels :

  • collection.into_iter() pour l’itération par valeur.
    • L’itérateur est construit en consommant la collection (sauf si elle implémente Copy)
    • Les valeurs contenues dans la collection sont déplacées vers le code utilisateur.
    • On peut utiliser la syntaxe raccourcie for elem in collection {}.
  • collection.iter() pour l’itération par référence partagée.
    • L’itérateur est construit à partir d’une référence partagée sur la collection.
    • Le code utilisateur reçoit des références partagées sur les éléments de la collection.
    • On peut utiliser la syntaxe raccourcie for elem in &collection {}.
  • collection.iter_mut() pour l’itération par référence mutable.
    • L’itérateur est construit à partir d’une référence mutable sur la collection.
    • Le code utilisateur reçoit des références mutables sur les éléments de la collection.
    • On peut utiliser la syntaxe raccourcie for elem in &mut collection {}.

Voilà pour le cas général. Le détail dépendra du type précis auquel on a affaire. Exemples :

  • Le type str fournit deux manières différentes d’itérer sur ses données, soit par point de code (chars()), soit par octet UTF-8 (bytes()). Le bon choix dépend de ce que l’utilisateur veut faire, donc il n’y a pas de méthode iter() conventionnelle.
  • Les collections associatives HashMap et BTreeMap que nous verrons plus tard permettent d’itérer par clé, par valeur, ou les deux.
  • Plusieurs types (str, BTreeMap, …) ne fournissent pas d’accès en écriture à tout ou partie de leurs données car un utilisateur ayant un tel accès pourrait facilement violer les invariants du type et potentiellement causer du comportement indéfini.
  • La plupart des collections permettent de retirer des éléments, et disposent donc d’un itérateur drain() qui est construit à partir d’une référence mutable vers la collection et permet de la vider de ses éléments sans la déplacer (et donc sans la détruire à terme).

Boucle for

Avec les itérateurs, la chose la plus simple qu’on peut faire, c’est de créer une boucle for dont le code sera appelé pour chaque élément d’une collection.

fn main() {
    // Itération par valeur (version explicite)
    for x in [1u8, 2, 3].into_iter() {
        println!("{x}");
    }
    println!("---");

    // Itération par valeur (raccourci)
    for x in [4u8, 5, 6] {
        println!("{x}");
    }
    println!("---");

    // Itération par référence partagée (raccourci)
    for r in &[7u8, 8, 9] {
        println!("{}", *r);
    }
}

C’est très similaire aux boucles for dans la plupart des langages modernes, donc je ne vais pas m’apesantir beaucoup dessus :

  • Ca marche bien dans les cas simples où on veut faire une chose une fois par élément.
  • On peut l’employer pour d’autres tâches comme les réductions (calculer la somme des éléments d’un tableau, etc.), mais il y a souvent d’autres outils plus adaptés.
  • Quand on commence à vouloir faire des choses plus sophistiquées comme itérer sur le voisinage d’une case dans un tableau (ce dont on a besoin pour résoudre le problème de la réaction de Gray-Scott), ça ne marche plus.

Itérateurs spécialisés

Les types itérables comme slice ont souvent des méthodes qui donnent accès à des itérateurs plus spécialisés. Dans le cas de slice, on notera notamment les itérateurs…

  • chunks() et chunks_mut(), qui permettent de tronçonner le tableau source en sous-tableaux d’une certaine taille, avec potentiellement un tronçon plus petit à la fin :
    #![allow(unused)]
    fn main() {
    for chunk in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].chunks(3) {
      println!("{chunk:?}");
    }
    }
  • chunks_exact() et chunks_exact_mut(), variantes de chunks() et chunks_mut() qui permettent de traiter le dernier tronçon à part pour que la boucle qui consomme l’itérateur traite toujours des tronçons de même taille. Cette régularité rend le code plus facile à optimiser pour le compilateur, on a donc souvent de meilleurs performances :
    #![allow(unused)]
    fn main() {
    let chunks_exact = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].chunks_exact(3);
    let remainder = chunks_exact.remainder();
    
    for chunk in chunks_exact {
      println!("Chunk: {chunk:?}");
    }
    println!("Remainder: {remainder:?}");
    }
  • windows() permet d’accéder au voisinage de taille N autour de chaque point du tableau :
    #![allow(unused)]
    fn main() {
    for window in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].windows(4) {
      println!("{window:?}");
    }
    }
    Saurez-vous deviner pourquoi il n’existe pas de méthode windows_mut() ? Un indice : que se passerait-il si on extrayait les sorties successives de cet itérateur et les mettait de côté ?

Dans l’ensemble, quand vous êtes coincés avec les trois itérateurs de base, pensez toujours à étudier la documentation de vos types itérables pour connaître leurs itérateurs spécialisés, il y en aura peut-être un qui sera plus adapté à votre cas d’utilisation.

Transformations d’itérateurs

La boucle for n’est pas la seule utilisation possible d’un itérateur en Rust. Tous les itérateurs partagent un certain nombres de méthodes (via le trait Iterator) qui permettent de transformer l’itérateur de différentes façons. Quelques exemples :

  • enumerate() associe à chaque élément de l’itérateur un indice croissant. On peut par exemple s’en servir pour connaître l’indice des éléments du tableau sur lequel on itère :
    #![allow(unused)]
    fn main() {
    for (idx, elem) in [98, 76, 54, 32].iter().enumerate() {
      println!("Indice {idx} : {elem}");
    }
    }
  • map() prend en paramètre une fonction de T -> U où T est le type d’éléments émis par l’itérateur. Il en résulte un itérateur de U où chaque élément est le résultat de l’application de la fonction aux éléments de l’itérateur d’origine :
    #![allow(unused)]
    fn main() {
    for elem in [1, 2, 3, 4].iter().map(|x| 2 * x) {
      println!("{elem}");
    }
    }
  • filter() prend en paramètre une fonction de &T -> bool où T est le type d’élément émis par l’itérateur. Il en résulte un itérateur de T qui n’émet que les éléments de l’itérateur d’origine pour lequel la fonction retourne true :
    #![allow(unused)]
    fn main() {
    for elem in [1, 2, 3, 4].iter().filter(|x| *x % 2 == 0) {
      println!("{elem}");
    }
    }
  • zip() prend en paramètre un deuxième itérateur. Il en résulte un itérateur de paires d’éléments issus des deux itérateurs, tronqué à la taille du plus court des deux itérateurs :
    #![allow(unused)]
    fn main() {
    for (x, y) in ([1, 2, 3, 4].into_iter())
                      .zip([5, 6, 7].into_iter())
    {
        println!("Reçu {x} et {y}");
    }
    }

Il y a beaucoup d’autres méthodes pour éliminer un certain nombre d’éléments, sélectionnner un certain nombre d’éléments, rechercher un élémént dans l’itérateur… Je vous recommande très fortement de faire au moins une fois le tour de la documentation du trait Iterator pour avoir une idée de tout ce qu’il est possible rien qu’avec les itérateurs de la bibliothèque standard.

Ces méthodes d’itérateurs comparables aux algorithmes de la STL en C++, sauf que contrairement aux algorithmes de la STL, c’est aussi assez facile à utiliser pour que vous ayez vraiment envie de les utiliser sans qu’un collègue ou un framework vous y force.

Avec toutes ces possibilités, on en finit par se demander si on a toujours besoin des boucles for. Et de fait, quand on ajoute des réductions comme reduce(), il est tout à fait possible d’effectuer un calcul itératif sans jamais utiliser de boucle explicite :

#![allow(unused)]
fn main() {
let somme = [1, 2, 3, 4].into_iter()
                        .reduce(|x, y| x + y)
                        .unwrap_or(0);
println!("{somme}");
}

Le choix entre ces deux styles de programmation (boucles explicites et méthodes d’itérateurs) dépendra de ce qu’on essaie de faire :

  • Les pipelines d’itérateurs comme celui qu’on a montré ci-dessus tendent à devenir plus complexes que les boucles quand le calcul est lui-même complexe, notamment parce qu’on perd la possibilité de sortir facilement de la boucle avec des outils comme break.
  • En contrepartie, ils tendent à être plus lisibles que les boucles pour les choses simples, et du fait de leur nature paresseuse, ils se prêtent à l’application automatique d’optimisations. Nous verrons ainsi dans le chapitre sur le parallélisme comment un pipeline d’itérateurs sur une collection standard peut être trivialement parallélisé avec rayon.