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(); }
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); } }
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);
}
}
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);
}
}
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); }
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]); }
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")
},
]
);
}
}
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 }
}
}
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
}
}
}
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);
}
}
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);
}
}
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
.