Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Traiter une série d’éléments avec des itérateurs

Les itérateurs vous permettent d’effectuer une tâche sur une séquence d’éléments à tour de rôle. Un itérateur est responsable de la logique d’itération sur chaque élément et de déterminer lorsque la séquence est terminée. Lorsque nous utilisons des itérateurs, nous n’avons pas besoin de ré-implémenter cette logique nous-mêmes.

En Rust, un itérateur est une évaluation paresseuse, ce qui signifie qu’il n’a aucun effet jusqu’à ce que nous appelions des méthodes qui consomment l’itérateur pour l’utiliser. Par exemple, le code dans l’encart 13-10 crée un itérateur sur les éléments du vecteur v1 en appelant la méthode iter définie sur Vec<T>. Ce code en lui-même ne fait rien d’utile.

Filename: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();
}
Listing 13-10: Creating an iterator

L’itérateur est stocké dans la variable v1_iter. Une fois que nous avons créé un itérateur, nous pouvons l’utiliser de diverses manières. Dans l’encart 3-5 du chapitre 3, nous avions utilisé des itérateurs avec des boucles for pour exécuter du code sur chaque élément. Sous le capot, cela crée implicitement puis consomme un itérateur, mais jusqu’à présent, nous avons escamoté la manière dont cela fonctionne exactement.

Dans l’exemple de l’encart 13-11, nous séparons la création de l’itérateur de son utilisation dans la boucle for. Quand la boucle for est appelée en utilisant l’itérateur dans v1_iter, chaque élément de l’itérateur est utilisé au cours d’une itération de la boucle, ce qui permet d’afficher chaque valeur.

Filename: src/main.rs
fn main() {
    let v1 = vec![1, 2, 3];

    let v1_iter = v1.iter();

    for val in v1_iter {
        println!("On a : {val}");
    }
}
Listing 13-11: Using an iterator in a for loop

Dans les langages qui n’ont pas d’itérateurs fournis par leur bibliothèque standard, nous écririons probablement cette même fonctionnalité en démarrant une variable à l’indice 0, en utilisant cette variable comme indice sur le vecteur afin d’obtenir une valeur puis en incrémentant la valeur de cette variable dans une boucle jusqu’à ce qu’elle atteigne le nombre total d’éléments dans le vecteur.

Les itérateurs s’occupent de toute cette logique pour nous, réduisant le code redondant dans lequel nous pourrions potentiellement faire des erreurs. Les itérateurs nous donnent plus de flexibilité pour utiliser la même logique avec de nombreux types de séquences différentes, et pas seulement avec des structures de données avec lesquelles nous pouvons utiliser des indices, telles que les vecteurs. Voyons comment les itérateurs font cela.

Le trait Iterator et la méthode next

Tous les itérateurs implémentent un trait appelé Iterator qui est défini dans la bibliothèque standard. La définition du trait ressemble à ceci :

#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;

    // les méthodes avec des implémentations par défaut ont été exclues
}
}

Remarquez que cette définition utilise une nouvelle syntaxe : type Item et Self::Item, qui définissent un type associé à ce trait. Nous verrons ce que sont les types associés au chapitre 20. Pour l’instant, tout ce que vous devez savoir est que ce code dit que l’implémentation du trait Iterator nécessite que vous définissiez aussi un type Item, et ce type Item est utilisé dans le type de retour de la méthode next. En d’autres termes, le type Item sera le type retourné par l’itérateur.

Le trait Iterator exige la définition d’une seule méthode par les développeurs : la méthode next, qui retourne un élément de l’itérateur à la fois intégré dans un Some, et lorsque l’itération est terminée, il retourne None.

On peut appeler la méthode next directement sur les itérateurs ; l’encart 13-12 montre quelles valeurs sont retournées par des appels répétés à next sur l’itérateur créé à partir du vecteur.

Filename: src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn demo_iterateur() {
        let v1 = vec![1, 2, 3];

        let mut v1_iter = v1.iter();

        assert_eq!(v1_iter.next(), Some(&1));
        assert_eq!(v1_iter.next(), Some(&2));
        assert_eq!(v1_iter.next(), Some(&3));
        assert_eq!(v1_iter.next(), None);
    }
}
Listing 13-12: Calling the next method on an iterator

Remarquez que nous avons eu besoin de rendre mutable v1_iter : appeler la méthode next sur un iterator change son état interne qui garde en mémoire l’endroit où il en est dans la séquence. En d’autres termes, ce code consomme, ou utilise, l’itérateur. Chaque appel à next consomme un élément de l’itérateur. Nous n’avions pas eu besoin de rendre mutable v1_iter lorsque nous avions utilisé une boucle for parce que la boucle avait pris possession de v1_iter et l’avait rendu mutable en coulisses.

Notez également que les valeurs que nous obtenons des appels à next sont des références immuables aux valeurs dans le vecteur. La méthode iter produit un itérateur pour des références immuables. Si nous voulons créer un itérateur qui prend possession de v1 et retourne les valeurs possédées, nous pouvons appeler into_iter au lieu de iter. De même, si nous voulons itérer sur des références mutables, nous pouvons appeler iter_mut au lieu de iter.

Les méthodes qui consomment un itérateur

Le trait Iterator a un certain nombre de méthodes différentes avec des implémentations par défaut que nous fournit la bibliothèque standard ; vous pouvez découvrir ces méthodes en regardant dans la documentation de l’API de la bibliothèque standard pour le trait Iterator. Certaines de ces méthodes appellent la méthode next dans leur définition, c’est pourquoi nous devons toujours implémenter la méthode next lors de l’implémentation du trait Iterator.

Les méthodes qui appellent next sont appelées des adaptateurs de consommation, parce que les appeler consomme l’itérateur. Un exemple est la méthode sum, qui prend possession de l’itérateur et itére sur ses éléments en appelant plusieurs fois next, consommant ainsi l’itérateur. A chaque étape de l’itération, il ajoute chaque élément à un total en cours et retourne le total une fois l’itération terminée. L’encart 13-13 a un test illustrant une utilisation de la méthode sum :

Filename: src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn iterator_sum() {
        let v1 = vec![1, 2, 3];

        let v1_iter = v1.iter();

        let total: i32 = v1_iter.sum();

        assert_eq!(total, 6);
    }
}
Listing 13-13: Calling the sum method to get the total of all items in the iterator

Nous ne sommes pas autorisés à utiliser v1_iter après l’appel à sum car sum a pris possession de l’itérateur avec lequel nous l’appelons.

Méthodes qui produisent d’autres itérateurs

Les adaptateurs d’itération sont des méthodes définies sur le trait Iterator qui ne consomment pas l’itérateur. À la place, ils produisent différents itérateurs en changeant certains aspects de l’itérateur d’origine.

L’encart 13-14 montre un exemple d’appel à la méthode d’adaptation d’itération map, qui prend en paramètre une fermeture qui va s’exécuter sur chaque élément au fur et à mesure que les éléments sont itérés. La fermeture crée ici un nouvel itérateur dans lequel chaque élément du vecteur a été incrémenté de 1.

Filename: src/main.rs
fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    v1.iter().map(|x| x + 1);
}
Listing 13-14: Calling the iterator adapter map to create a new iterator

Cependant, ce code déclenche un avertissement :

$ cargo run
   Compiling iterators v0.1.0 (file:///projects/iterators)
warning: unused `Map` that must be used
 --> src/main.rs:4:5
  |
4 |     v1.iter().map(|x| x + 1);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: iterators are lazy and do nothing unless consumed
  = note: `#[warn(unused_must_use)]` on by default
help: use `let _ = ...` to ignore the resulting value
  |
4 |     let _ = v1.iter().map(|x| x + 1);
  |     +++++++

warning: `iterators` (bin "iterators") generated 1 warning
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.47s
     Running `target/debug/iterators`

Le code dans l’encart 13-14 ne fait rien ; la fermeture que nous avons renseignée n’est jamais exécutée. L’avertissement nous rappelle pourquoi : les adaptateurs d’itération sont des évaluations paresseuses, c’est pourquoi nous devons consommer l’itérateur ici.

Pour corriger ceci et consommer l’itérateur, nous utiliserons la méthode collect, que vous avez utilisé avec env::args dans l’encart 12-1 du chapitre 12. Cette méthode consomme l’itérateur et collecte les valeurs résultantes dans un type de collection de données.

Dans l’encart 13-15, nous recueillons les résultats de l’itération sur l’itérateur qui sont retournés par l’appel à map sur un vecteur. Ce vecteur finira par contenir chaque élément du vecteur original incrémenté de 1.

Filename: src/main.rs
fn main() {
    let v1: Vec<i32> = vec![1, 2, 3];

    let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();

    assert_eq!(v2, vec![2, 3, 4]);
}
Listing 13-15: Calling the map method to create a new iterator, and then calling the collect method to consume the new iterator and create a vector

Comme map prend en paramètre une fermeture, nous pouvons renseigner n’importe quelle opération que nous souhaitons exécuter sur chaque élément. C’est un bon exemple de la façon dont les fermetures nous permettent de personnaliser certains comportements tout en réutilisant le comportement d’itération fourni par le trait Iterator.

Nous pouvons enchaîner plusieurs appels à des adaptateurs d’itération pour effectuer des actions complexes de manière compréhensible. Mais comme les itérateurs sont des évaluations paresseuses, nous devons faire appel à l’une des méthodes d’adaptation de consommation pour obtenir les résultats des appels aux adaptateurs d’itération.

Fermetures capturant leur environnement

De nombreux adaptateurs d’itération prenent des fermetures comme arguments, et ces fermetures que nous passons comme arguments aux adaptateurs d’itération seront des fermetures qui capturent leur environnement.

Pour cet exemple, nous utiliserons la méthode filter, qui prend une fermeture. La fermeture prend un élément de l’itérateur et renvoie un bool. Si la fermeture renvoie true, la valeur sera inclue dans l’itération produite par filter. Si la fermeture renvoie false, la valeur ne sera pas inclue.

Dans l’encart 13-16, nous utilisons filter avec une fermeture qui capture la variable pointure_chaussure de son environnement pour itérer sur une collection d’instances de la structure Chaussure. Il ne retournera que les chaussures avec la pointure demandée.

Filename: src/lib.rs
#[derive(PartialEq, Debug)]
struct Chaussure {
    pointure: u32,
    style: String,
}

fn chaussures_a_la_pointure(chaussures: Vec<Chaussure>, pointure_chaussure: u32) -> Vec<Chaussure> {
    chaussures.into_iter().filter(|s| s.pointure == pointure_chaussure).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn filtres_par_pointure() {
        let chaussures = vec![
            Chaussure {
                pointure: 10,
                style: String::from("basket"),
            },
            Chaussure {
                pointure: 13,
                style: String::from("sandale"),
            },
            Chaussure {
                pointure: 10,
                style: String::from("botte"),
            },
        ];

        let a_ma_pointure = chaussures_a_la_pointure(chaussures, 10);

        assert_eq!(
            a_ma_pointure,
            vec![
                Chaussure {
                    pointure: 10,
                    style: String::from("basket")
                },
                Chaussure {
                    pointure: 10,
                    style: String::from("botte")
                },
            ]
        );
    }
}
Listing 13-16: Using the filter method with a closure that captures shoe_size

La fonction chaussures_a_la_pointure prend possession d’un vecteur de chaussures et d’une pointure comme paramètres. Il retourne un vecteur contenant uniquement des chaussures de la pointure demandée.

Dans le corps de chaussures_a_la_pointure, nous appelons into_iter pour créer un itérateur qui prend possession du vecteur. Ensuite, nous appelons filter pour adapter cet itérateur dans un nouvel itérateur qui ne contient que les éléments pour lesquels la fermeture retourne true.

La fermeture capture le paramètre pointure_chaussure de l’environnement et compare la valeur avec la pointure de chaque chaussure, en ne gardant que les chaussures de la pointure spécifiée. Enfin, l’appel à collect retourne un vecteur qui regroupe les valeurs renvoyées par l’itérateur.

Le test confirme que lorsque nous appelons chaussures_a_la_pointure, nous n’obtenons que des chaussures qui ont la même pointure que la valeur que nous avons demandée.