Skip to content
Snippets Groups Projects

Iterator

Merged Michaël El Kharroubi requested to merge iterator into main
Files
10
+ 238
1
# Pointeurs intelligents et mémoire
\ No newline at end of file
# Discussion du code `part10`
## Concepts
Les concepts abordés dans cet exemple sont:
- [Discussion du code `part10`](#discussion-du-code-part10)
- [Concepts](#concepts)
- [Documentation](#documentation)
- [Discussion](#discussion)
- [Le trait itérateur](#le-trait-itérateur)
- [Les fonctions `iter` et `collect`](#les-fonctions-iter-et-collect)
- [Quelques fonctions d'ordre supérieur sur les itérateurs](#quelques-fonctions-dordre-supérieur-sur-les-itérateurs)
- [Performances et lazy evaluation](#performances-et-lazy-evaluation)
- [Rustlings](#rustlings)
- [Les itérateurs](#les-itérateurs)
## Documentation
Afin de compléter ce cours, je vous recommande la lecture des ressources suivantes :
- [Traitements des collections avec de itérateurs en Rust](https://doc.rust-lang.org/book/ch13-02-iterators.html)
- [Le trait Iterator en Rust](https://doc.rust-lang.org/std/iter/trait.Iterator.html)
- [Les méthodes du trait Iterator](https://doc.rust-lang.org/std/iter/trait.Iterator.html#provided-methods)
## Discussion
### Le trait itérateur
L'itérateur est un patron de conception extrêment répandu en prorgrammation orientée objet. On le retrouve
dans plusieurs langages comme par exemple python.
Le trait `Iterator` est défini ainsi en version simplifiée dans la documentation :
```rust
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
```
Un itérateur possède donc un élément courant et une méthode `next()` qui permet de passer à l'élément suivant,
puis de retourner l'élément courant. Un itérateur peut-être fini ou infini. Il peut permettre par exemple de
parcourir une collection ou de créer un compteur.
La documentation Rust offre [un exemple d'implémentation d'itérateur assez accessible](https://doc.rust-lang.org/std/iter/index.html#implementing-iterator).
### Les fonctions `iter` et `collect`
Le Rust propose des méthodes permettant de passer d'une collection à un itérateur assez facilement.
```rust
# fn main(){
let v : Vec<i32> = vec![1,2,3,4];
let mut v_iter = v.iter();
println!("{}", v_iter.next().unwrap());
println!("{}", v_iter.next().unwrap());
println!("{}", v_iter.next().unwrap());
println!("{}", v_iter.next().unwrap());
assert!(v_iter.next().is_none());
# }
```
La méthode `iter()` des vecteurs permet de créer un itérateur à partir d'un vecteur. Il faut noter que `v_iter` est mutable, car son état interne est **modifié** par l'appel à la méthode `next()`.
En effet, pour pouvoir avancer dans notre itérateur, nous utilisons la fonction `next()` qui avance l'élément courant d'une position
et retourne une `Option` sur l'élément courant. Pour rappel, la signature de la méthode de `next()` est `fn next(&mut self) -> Option<Self::Item>;`.
Il est aussi possible de transformer un itérateur en collection à l'aide de la méthode `collect()`. La fonction `from_fn()` permet
de créer un itérateur à l'aide d'une fonction :
```rust
# fn main(){
let mut count = 0;
let even_counter = std::iter::from_fn(|| {
count += 2;
Some(count)
});
let v : Vec<i32> = even_counter.take(3).collect();
println!("Les 3 premiers nombres paires sont {}, {}, {}", v[0], v[1], v[2])
# }
```
Le code ci-dessus commence par créer un itérateur infini de nombres paires. Nous verrons plus-tard que les éléments de l'itérateur infini n'est pas généré immédiatement
[dans la section lazy-evaluation](#performances-et-lazy-evaluation).
Nous utilisons une variable mutable `count`, afin de générer les valeurs de notre itérateur.
Notre closure va capturer cette variable et l'incrémenter de 2 à chaque appel et retourner la valeur courante encapsulée dans une `Option`.
Notre itérateur étant infini, nous devons le limiter à un nombre fini d'éléments avant de pouvoir récupérer une collection.
Pour cela nous utilisons la méthode `take()` qui permet de limiter la taille de notre itérateur. Finalement, nous pouvons
récupérer un vecteur d'entier à l'aide de la méthode `collect()`.
### Quelques fonctions d'ordre supérieur sur les itérateurs
L'intérêt principal des itérateurs en Rust réside dans ses fonctions d'ordre supérieur. Elle permettent de traiter des collections
de manière efficaces en apportant des garanties notament en terme de concurrence. On retrouve notament le crate
[Rayon-rs](https://github.com/rayon-rs/rayon) qui permet d'effectuer des traitement en parallèle sans apporter de modifications
majeures au code.
Ici, nous nous concentrerons sur les méthodes suivantes qui sont très communes dans les langages fonctionnels (pas toujours avec les mêmes noms) :
- `map()`
- `filter()`
- `fold()`
- `zip()`
Reprenons notre code de recherche du minimum d'une liste :
```rust,ignore
{{#include ../../codes/rust_lang/part10/src/find.rs:find_minimum}}
```
Notre fonction prend en argument un vecteur `v` que nous transformons en itérateur avec la méthode `iter()`. Pour trouver le minimum
de notre itérateur, nous utilisons la méthode `fold()`. Cette méthode permet de réduire un itérateur en appliquant itérativement,
une fonction qui prend deux arguments et qui retourne un seul élément. On se retrouve donc avec une
seule valeur à la fin de la réduction.
La méthode `fold()` prend donc deux arguments. Le premier est la valeur d'initialisation de notre réduction. On parle parfois d'élément neutre,
mais ce terme est plus restrictif qu'une simple valeur initiale.
L'élément neutre d'une opération est l'élément `N` qui si on lui applique l'opération avec n'importe quel autre élément `x` retourne
cet élément `x`. Si nous prenons l'exemple de l'addition, l'élément neutre est 0, car :
$$ 0 + x = x + 0 = x $$
et ce peu importe la valeur de x. Il est préférable d'utiliser un élément neutre, plutôt qu'une valeur quelconque pour l'initialisation
de notre réduction. Sans rentrer dans les détails, qui dépassent le cadre de ce cours, les algoritmes parallèles de réduction reposent
sur l'usage d'un élément neutre. Utiliser un élément neutre, vous permettra donc de parallèliser votre code bien plus simplement.
En l'occurence puisqu'on veut retourner une option, notre élément neutre est `None`.
Le deuxième argument de notre fonction `fold()` est l'opération de réduction que nous utiliserons pour trouver le minimum. Pour cela nous
utilisons une closure. Cette dernière prend deux argument, un accumulateur (la valeur courante de la réduction) de type `Option<i32>`
et un l'élément courant de l'itérateur qui est de type `&i32`. Notre closure va simplement retourner une Option contenant l'élément le
plus petit entre la valeur actuelle de l'accumulateur et la valeur courante. Cette valeur devient la nouvelle valeur de l'accumulateur
pour l'appel suivant.
Le résultat de la méthode `fold()` est la dernière valeur de l'accumulateur. Si l'itérateur est vide, la méthode `fold()` retournera la valeur
d'initialisation. Dans notre cas, il s'agit d'une `Option` vide.
Pour illustrer l'usage de la méthode `filter()`, nous avons une fonction qui trouve le plus petit nombre pair dans un vecteur :
```rust,ignore
{{#include ../../codes/rust_lang/part10/src/find.rs:find_even_minimum}}
```
La méthode est similaire à la recherche du minimum, mais afin de garder uniquement les nombres pairs on ajoute la fonction `filter()`. Quand on ajoute ainsi
une série de traitements à la suite, on parle de pipelines.
La fonction `filter()` prend une fonction en paramètre appelée prédicat. Un prédicat et une fonction qui prend un élément et retourne un booléen.
Cette fonction va déterminer quels éléments conserver. Ici nous utilisons une closure qui retourne `true` si le nombre est pair. La méthode `filter()`
va retourner un nouvel itérateur composé uniquement des éléments séléctionnés par le prédicat.
Pour finir, on recherche la valeur minimum de ce nouvel itérateur, de la même façon que dans l'exemple
précédent, à l'aide de la méthode `fold()`.
Essayons maintenant de résoudre un problème un peu plus complexe en ajoutant les méthodes `zip()` et `map()`.
Nous aimerions trouver quel est l'élément le plus petit en valeur absolue d'un vecteur donné.
```rust,ignore
{{#include ../../codes/rust_lang/part10/src/find.rs:find_absolute_minimum}}
```
La première étape consiste à créer deux itérateurs. Le premier contient le signe de chaque élément
et le deuxième, la valeur absolue de chaque élément.
```rust,ignore
{{#include ../../codes/rust_lang/part10/src/find.rs:find_absolute_minimum_1}}
```
Pour obtenir ces itérateurs, nous allons transformer nos itérateurs obtenus avec `iter()` en utilsant
la fonction `map()`. Cette méthode permet d'appliquer une même transformation sur tous les éléments
d'un itérateur. Pour l'itérateur `signs`, on appelle la méthode `signum()` des `i32`, qui retourne 1 si
le nombre est positif, 0 si le nombre est 0 et -1 si le nombre est négatif. Pour `abs_values`,
nous utilisons la méthode `abs()` des `i32`, qui retourne la valeur absolue d'un nombre.
```rust,ignore
{{#include ../../codes/rust_lang/part10/src/find.rs:find_absolute_minimum_2}}
```
Maintenant que nous avons nos deux itérateurs, nous aimerions pouvoir itérer sur les deux simultanément.
Pour cela, nous pouvons utiliser la méthode `zip()`. Elle permet de transformer deux itérateurs, en un
unique itérateur de tuple. Ici nous avons deux itérateurs de `i32`, qui deviennent donc un seul itérateur
de type tuple `(i32, i32)`.
```rust,ignore
{{#include ../../codes/rust_lang/part10/src/find.rs:find_absolute_minimum_3}}
```
Ensuite avec `fold()`, il nous suffit de comparer les valeurs absolues et de retourner une option contenant
le signe et la valeur absolue.
```rust,ignore
{{#include ../../codes/rust_lang/part10/src/find.rs:find_absolute_minimum_4}}
```
Pour finir, on utilise la méthode `map()` de notre `Option<(i32,i32)>` pour multiplier
la valeur absolue par le signe. Ce qui nous donne au final une `Option<i32>` contenant notre résultat.
### Performances et lazy evaluation
Les transformations sur les itérateurs sont en général aussi perfomantes qu'une
simple boucle for. On peut voir par exemple [ce benchmark dans le livre Rust](https://doc.rust-lang.org/book/ch13-04-performance.html).
Si on ajoute la possibilité de parallèliser facilement un code basé sur les itérateurs,
leur intérêt paraît évident.
Un des éléments qui explique les perfomances des itérateurs réside dans la lazy evaluation.
Lorsqu'on appelle une opération de transformation sur un itérateur, la transformation n'est
pas réalisée directement. C'est uniquement lorsqu'on appelle une fonction dite terminale, comme
par exemple `fold()` ou `collect()` qui doivent produire un résulat. Les transformations intermédiaires
peuvent ainsi souvent être fusionnés.
Ce qui veut dire par exemple que dans le code suivant,
```rust
# fn main() {
let v = vec![0,1,2,3,4,5];
let it = v.iter().map(|x| x + 1).map(|x| x * 3).filter(|x| x % 2 == 1);
let res : Vec<i32> = it.collect();
# }
```
que tant que la fonction `collect()` n'a pas été appelée, alors aucune transformation n'est effectuée.
Si vous ne nous croyez pas, un moyen simple de vous en convaincre, consiste à appliquer
une série de transformation sur un itérateur infini et de mesurer la performance.
## Rustlings
Les rustlings à faire dans ce chapitre sont les suivants:
### Les itérateurs
```bash
$ rustlings run iterators1
$ rustlings run iterators2
$ rustlings run iterators3
$ rustlings run iterators4
$ rustlings run iterators5
```
Loading