Traiter une série d'éléments avec un itérateur
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-13 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.
fn main() { let v1 = vec![1, 2, 3]; let v1_iter = v1.iter(); }
Encart 13-13 : création d'un itérateur
Une fois que nous avons créé un itérateur, nous pouvons l'utiliser de diverses
manières. Dans l'encart 3-4 du chapitre 3, nous avions utilisé des itérateurs
avec des boucles for pour exécuter du code sur chaque élément, bien que nous
ayons laissé de côté ce que l'appel à iter faisait jusqu'à présent.
L'exemple dans l'encart 13-14 sépare la création de l'itérateur de son
utilisation dans la boucle for. L'itérateur est stocké dans la variable
v1_iter, et aucune itération n'a lieu à ce moment-là. Lorsque la boucle for
est appelée en utilisant l'itérateur v1_iter, chaque élément de l'itérateur
est utilisé à chaque itération de la boucle, qui affiche chaque valeur.
fn main() { let v1 = vec![1, 2, 3]; let v1_iter = v1.iter(); for val in v1_iter { println!("On a : {}", val); } }
Encart 13-14 : utilisation d'un itérateur dans une boucle
for
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 19. 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-15 montre quelles valeurs sont retournées par des appels répétés à next sur
l'itérateur créé à partir du vecteur.
Fichier : 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);
}
}
Encart 13-15 : appel de la méthode next sur un itérateur
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-16 a un test
illustrant une utilisation de la méthode sum :
Fichier : 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);
}
}
Encart 13-16 : appel de la méthode sum pour obtenir la
somme de tous les éléments présents dans l'itérateur
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
D'autres méthodes définies sur le trait Iterator, connues sous le nom
d'adaptateurs d'itération, nous permettent de transformer un itérateur en un
type d'itérateur différent. 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.
L'encart 13-17 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
pour produire un nouvel itérateur. La fermeture crée ici un nouvel itérateur
dans lequel chaque élément du vecteur a été incrémenté de 1. Cependant, ce code
déclenche un avertissement :
Fichier : src/main.rs
fn main() { let v1: Vec<i32> = vec![1, 2, 3]; v1.iter().map(|x| x + 1); }
Encart 13-17 : appel de l'adaptateur d'itération map
pour créer un nouvel itérateur
Voici l'avertissement que nous obtenons :
$ 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: `#[warn(unused_must_use)]` on by default
= note: iterators are lazy and do nothing unless consumed
warning: `iterators` (bin "iterators") generated 1 warning
Finished dev [unoptimized + debuginfo] target(s) in 0.47s
Running `target/debug/iterators`
Le code dans l'encart 13-17 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-18, 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.
Fichier : 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]); }
Encart 13-18 : appel de la méthode map pour créer un
nouvel itérateur, puis appel de la méthode collect pour consommer le nouvel
itérateur afin de créer un vecteur
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.
Utilisation de fermetures capturant leur environnement
Maintenant que nous avons présenté les itérateurs, nous pouvons illustrer une
utilisation commune des fermetures qui capturent leur environnement en utilisant
l'adaptateur d'itération filter. La méthode filter appelée sur un itérateur
prend en paramètre une fermeture qui s'exécute sur chaque élément de l'itérateur
et retourne un booléen pour chacun. Si la fermeture retourne true, la valeur
sera incluse dans l'itérateur produit par filter. Si la fermeture retourne
false, la valeur ne sera pas incluse dans l'itérateur résultant.
Dans l'encart 13-19, 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.
Fichier : 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("baskets"),
},
Chaussure {
pointure: 13,
style: String::from("sandale"),
},
Chaussure {
pointure: 10,
style: String::from("bottes"),
},
];
let a_ma_pointure = chaussures_a_la_pointure(chaussures, 10);
assert_eq!(
a_ma_pointure,
vec![
Chaussure {
pointure: 10,
style: String::from("baskets")
},
Chaussure {
pointure: 10,
style: String::from("bottes")
},
]
);
}
}
Encart 13-19 : utilisation de la méthode filter avec une
fermeture capturant pointure_chaussure
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.
Créer nos propres itérateurs avec le trait Iterator
Nous avons vu que nous pouvons créer un itérateur en appelant iter,
into_iter ou iter_mut sur un vecteur. Nous pouvons créer des itérateurs à
partir d'autres types de collections de la bibliothèque standard, comme les
tables de hachage. Nous pouvons aussi créer des itérateurs qui font tout ce que
nous voulons en implémentant le trait Iterator sur nos propres types. Comme
nous l'avons mentionné précédemment, la seule méthode pour laquelle nous devons
fournir une définition est la méthode next. Une fois que nous avons fait cela,
nous pouvons utiliser toutes les autres méthodes qui ont des implémentations par
défaut fournies par le trait Iterator !
Pour preuve, créons un itérateur qui ne comptera que de 1 à 5. D'abord, nous
allons créer une structure contenant quelques valeurs. Ensuite nous
transformerons cette structure en itérateur en implémentant le trait Iterator
et nous utiliserons les valeurs de cette implémentation.
L'encart 13-20 montre la définition de la structure Compteur et une fonction
associée new pour créer des instances de Compteur :
Fichier : src/lib.rs
struct Compteur {
compteur: u32,
}
impl Compteur {
fn new() -> Compteur {
Compteur { compteur: 0 }
}
}
Encart 13-20 : définition de la structure Compteur et
d'une fonction new qui crée des instances de Compteur avec une valeur
initiale de 0 pour le champ compteur.
La structure Compteur a un champ compteur. Ce champ contient une valeur
u32 qui gardera la trace de l'endroit où nous sommes dans le processus
d'itération de 1 à 5. Le champ compteur est privé car nous voulons que ce soit
l'implémentation de Compteur qui gère sa valeur. La fonction new impose de
toujours démarrer de nouvelles instances avec une valeur de 0 pour le champ
compteur.
Ensuite, nous allons implémenter le trait Iterator sur notre type Compteur
en définissant le corps de la méthode next pour préciser ce que nous voulons
qu'il se passe quand cet itérateur est utilisé, comme dans l'encart 13-21 :
Fichier : src/lib.rs
struct Compteur {
compteur: u32,
}
impl Compteur {
fn new() -> Compteur {
Compteur { compteur: 0 }
}
}
impl Iterator for Compteur {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
if self.compteur < 5 {
self.compteur += 1;
Some(self.compteur)
} else {
None
}
}
}
Encart 13-21 : implémentation du trait Iterator sur
notre structure Compteur
Nous avons défini le type associé Item pour notre itérateur à u32, ce qui
signifie que l'itérateur renverra des valeurs u32. Encore une fois, ne vous
préoccupez pas des types associés, nous les aborderons au chapitre 19.
Nous voulons que notre itérateur ajoute 1 à l'état courant, donc nous avons
initialisé compteur à 0 pour qu'il retourne 1 lors du premier appel à next.
Si la valeur de compteur est strictement inférieure à 5, next va incrémenter
compteur puis va retourner la valeur courante intégrée dans un Some. Une fois
que compteur vaudra 5, notre itérateur va arrêter d'incrémenter compteur et
retournera toujours None.
Utiliser la méthode next de notre Itérateur Compteur
Une fois que nous avons implémenté le trait Iterator, nous avons un
itérateur ! L'encart 13-22 montre un test démontrant que nous pouvons utiliser
la fonctionnalité d'itération de notre structure Compteur en appelant
directement la méthode next, comme nous l'avons fait avec l'itérateur créé à
partir d'un vecteur dans l'encart 13-15.
Fichier : src/lib.rs
struct Compteur {
compteur: u32,
}
impl Compteur {
fn new() -> Compteur {
Compteur { compteur: 0 }
}
}
impl Iterator for Compteur {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
if self.compteur < 5 {
self.compteur += 1;
Some(self.compteur)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn appel_direct_a_next() {
let mut compteur = Compteur::new();
assert_eq!(compteur.next(), Some(1));
assert_eq!(compteur.next(), Some(2));
assert_eq!(compteur.next(), Some(3));
assert_eq!(compteur.next(), Some(4));
assert_eq!(compteur.next(), Some(5));
assert_eq!(compteur.next(), None);
}
}
Encart 13-22 : test de l'implémentation de la méthode
next
Ce test crée une nouvelle instance de Compteur dans la variable compteur et
appelle ensuite next à plusieurs reprises, en vérifiant que nous avons
implémenté le comportement que nous voulions que cet itérateur suive : renvoyer
les valeurs de 1 à 5.
Utiliser d'autres méthodes du trait Iterator
Maintenant que nous avons implémenté le trait Iterator en définissant la
méthode next, nous pouvons maintenant utiliser les implémentations par défaut
de n'importe quelle méthode du trait Iterator telles que définies dans la
bibliothèque standard, car elles utilisent toutes la méthode next.
Par exemple, si pour une raison quelconque nous voulions prendre les valeurs
produites par une instance de Compteur, les coupler avec des valeurs produites
par une autre instance de Compteur après avoir sauté la première valeur,
multiplier chaque paire ensemble, ne garder que les résultats qui sont
divisibles par 3 et additionner toutes les valeurs résultantes ensemble, nous
pourrions le faire, comme le montre le test dans l'encart 13-23 :
Fichier : src/lib.rs
struct Compteur {
compteur: u32,
}
impl Compteur {
fn new() -> Compteur {
Compteur { compteur: 0 }
}
}
impl Iterator for Compteur {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
if self.compteur < 5 {
self.compteur += 1;
Some(self.compteur)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn appel_direct_a_next() {
let mut compteur = Compteur::new();
assert_eq!(compteur.next(), Some(1));
assert_eq!(compteur.next(), Some(2));
assert_eq!(compteur.next(), Some(3));
assert_eq!(compteur.next(), Some(4));
assert_eq!(compteur.next(), Some(5));
assert_eq!(compteur.next(), None);
}
#[test]
fn utilisation_des_autres_methodes_du_trait_iterator() {
let somme: u32 = Compteur::new()
.zip(Compteur::new().skip(1))
.map(|(a, b)| a * b)
.filter(|x| x % 3 == 0)
.sum();
assert_eq!(18, somme);
}
}
Encart 13-23 : utilisation d'une gamme de méthodes du
trait Iterator sur notre itérateur Compteur
Notez que zip ne produit que quatre paires ; la cinquième paire théorique
(5, None) n'est jamais produite car zip retourne None lorsque l'un de
ses itérateurs d'entrée retourne None.
Tous ces appels de méthode sont possibles car nous avons renseigné comment
la méthode next fonctionne et la bibliothèque standard fournit des
implémentations par défaut pour les autres méthodes qui appellent next.