Discussion du code closures
Concepts
Les concepts abordés dans cet exemple sont:
Documentation
Afin de compléter ce cours, je vous recommande la lecture des ressources suivantes :
- Les fonctions anonymes closures en Rust
- La transformation d'une option avec des closures
- Les fonctions d'ordre supérieur et les closures
Discussion
De plus en plus de langages proposent des fonctions anonymes (des fonctions sans identifiant). Ces fonctions sont un outil précieux de la programmation fonctionnelle qui gagne elle aussi en popularité. Nous présenterons dans ce cours ce qu'elles sont et les avantages qu'elles peuvent apporter.
Fonctions anonymes
Une fonction anonyme est une fonction sans identifiant, qui permet de transformer un comportement en variable.
Prenons un exemple simple :
|x: i32| -> i32 { x + 1 }
Nous avons entre | les arguments de notre fonction anonyme, ici x qui est de type i32. À la suite
du deuxième |, nous avons le type de retour et finalement entre {}, une expression qui est le corps
de la fonction. Il n'est pas obligatoire d'indiquer le type de l'argument, le type de retour ou de mettre
entre accolades le corps de la fonction, cela dépendra des déductions que le compilateur peut réaliser.
En une phrase, on peut lire l'expression ci-dessus comme étant : "Une fonction qui pour tout entier
signé de 32 bits x donne x + 1".
Cette expression en Rust est une fonction anonyme. Son équivalent en fonction standard serait :
fn add_one(x:i32) -> i32 {
x + 1
}
Il est possible de stocker nos fonctions anonymes dans une variable, par exemple :
let max_op: fn(i32, i32) -> i32 = |x, y| if x >= y { x } else { y };
Fonction d'ordre supérieur
Une fonction d'ordre supérieur est une fonction qui prend en argument une ou plusieurs fonctions et/ou retourne une ou plusieurs fonctions.
L'intérêt principal de nos fonctions anonymes est de pouvoir les passer et les retourner via des fonctions.
Prenons un petit exemple :
fn plus_two(x: i32, add_one: fn (i32) -> i32) -> i32 { add_one(add_one(x)) } fn main(){ let add_one = |x| x + 1; println!("{}", plus_two(5, add_one)); }
Ici nous avons une fonction qui additionne 2 à un entier x, mais pour cela notre fonction doit tout d'abord
savoir additionner 1. Elle prend donc en argument l'entier x auquel elle doit additionner 2, et une fonction
permettant d'additionner 1 à un nombre appelée add_one.
Si on se penche sur cet argument nous avons add_one: fn (i32) -> i32, le type de cet argument est une fonction,
qui prend un i32 et retourne un i32.
Puisque la fonction plus_two prend en argument une fonction, il s'agit d'une fonction d'ordre supérieur.
Dans la fonction main, on déclare une variable add_one qui va contenir une fonction anonyme qui additionne 1
à un entier. À titre d'illustration nous appliquons plus_two à 5 et notre fonction anonyme stockée
dans une variable nommée add_one.
Cet exemple est un peu trivial, mais il permet de saisir brièvement la syntaxe.
Nous pouvons modifier notre exemple du calcul du minimum d'une liste, pour y ajouter des fonctions d'ordre supérieur.
Dans un premier temps, nous pouvons généraliser le comportement de notre fonction de recherche du minimum. Pour cela,
on commence par créer un type BinaryOperator<T> :
pub type BinaryOperator<T> = fn(T, T) -> T;
Si on lit sa définition, on s'aperçoit qu'il s'agit d'une fonction qui prend deux éléments de type T et en retourne
un troisième du même type. Cette définition s'applique parfaitement à la fonction minimum, je prends deux éléments du même type,
je détermine lequel est le plus petit et je le retourne.
Prenons maintenant le code de notre fonction du calcul du minimum généralisée :
pub fn find_with_hof<T: Copy>(tab: &[T], op: BinaryOperator<T>) -> Option<T> {
let mut res = None;
// Here, if T is Copyable, t is not moved in the loop
for t in tab {
if let Some(val) = res {
res = Some(op(val, *t))
} else {
res = Some(*t)
}
}
res
}
Cette fonction est sensiblement la même que la fonction étudiée dans le chapitre La gestion des erreurs en Rust. Le premier changement qu'il faut remarquer, intervient au niveau des arguments.
On voit apparaître un nouvel argument op: BinaryOperator<T>. Il s'agit simplement de l'opération que
nous allons utiliser itérativement sur notre tableau, afin d'obtenir un résultat. On pourrait prendre par
exemple la fonction minimum.
Contrairement au trait Minimum que nous avions défini jusqu'à maintenant, nous allons gérer manuellement
le résultat qui est initialisé à None. La deuxième modification que nous devons remarquer se trouve dans
le corps de la boucle. Pour gérer le résultat vide que l'on rencontre durant la première itération,
on utilise une simple assignation conditionnelle comme nous l'avons vu dans les cours précédents.
Avec cette fonction d'ordre supérieur, nous pouvons désormais utiliser n'importe quelle opération qui réduit deux éléments en un seul.
En utilisant un autre type de fonction d'ordre supérieur, celles qui retournent une fonction, on peut générer plusieurs opérateurs de ce type.
pub fn minimum_operator<T: PartialOrd>() -> BinaryOperator<T> {
|x: T, y: T| if x <= y { x } else { y }
}
pub fn maximum_operator<T: PartialOrd>() -> BinaryOperator<T> {
|x: T, y: T| if x >= y { x } else { y }
}
pub fn sum_operator<T: std::ops::Add<Output = T>>() -> BinaryOperator<T> {
|x: T, y: T| x + y
}
pub fn mul_operator<T: std::ops::Mul<Output = T>>() -> BinaryOperator<T> {
|x: T, y: T| x * y
}
Pour utiliser ces fonctions, rien de plus simple :
let min = find_with_hof(&tab, minimum_operator());
match min {
Some(val) => println!("The minimum value is {}", val),
None => eprintln!("There is no minimum"),
}
Il suffit d'appeler la fonction minimum_operator qui va nous retourner notre fonction anonyme capable de
déterminer le minimum entre deux nombres.
On voit ici tout l'intérêt de nos fonctions d'ordre supérieur. Il me suffit d'écrire une seule fois le code qui réduit mon tableau et je peux choisir mon opération en lui passant simplement le comportement en argument, comme si c'était une variable quelconque.
Closures
En Rust, les fonctions anonymes se nomment closures. On rencontre parfois la traduction fermetures. Les closures ne sont pas simplement des fonctions sans identifiant, en effet, une closure capture son environnement. Une closure est composée d'une fonction anonyme et des variables capturées dans son environnement.
Voici un exemple de code qui illustre le concept de capture :
fn main(){ let a : i32 = 50; let divide_by_a = |x:i32| x / a; println!("{}", divide_by_a(100)); }
Ici nous avons une variable a qui contient un i32 et une variable divide_by_a qui contient une closure qui
prend un i32 en argument et retourne un i32. Ce qu'il faut remarquer, c'est que la variable a est capturée
par la closure. Ce qui signifie que si je passais la variable divide_by_a à une fonction, la variable a serait
elle aussi passée indirectement.
Jusqu'à maintenant, nous nous sommes contentés de passer en argument des closures qui ne capturaient aucune variable.
Pour passer par exemple notre closure divide_by_a qui capture une variable, il nous faudra utiliser par exemple le trait
Fn. Sans entrer dans les détails, il fait partie d'une famille de trois
traits (Fn, FnMut
et FnOnce), selon la façon dont la closure utilise son environnement.
Toute closure implémente au moins FnOnce ; nous ne verrons pas les deux autres en détail dans le cadre de ce cours.
Modifions donc notre code pour ajouter une fonction :
fn do_stuff<F: Fn(i32) -> i32>(op: F) -> i32 { op(100) } fn main(){ let a: i32 = 50; let divide_by_a = |x: i32| x / a; println!("{}", do_stuff(divide_by_a)); }
Dans le code ci-dessus, on peut voir que la fonction do_stuff prend un argument appelé op de type générique F.
Notre type générique F est un type implémentant Fn(i32) -> i32, c'est-à-dire une fonction qui prend
en argument un i32 et retourne un i32. Il ne faut surtout pas confondre fn qui est un mot-clé du langage et Fn,
qui est un trait décrivant entre autres une closure qui capture des éléments de son environnement.
Exemples d'utilisation avec les options
Les options proposent également des méthodes qui prennent en argument d'autres fonctions. Nous pouvons en voir deux dans notre code.
- map
- filter
La fonction map permet de transformer le contenu d'une option si celle-ci n'est pas None, ou de ne rien faire dans le cas contraire. Prenons l'exemple suivant :
let two: f32 = 2.0f32;
let sum: Option<i32> = find_with_hof(&tab, sum_operator());
let half: Option<f32> = sum.map(|x: i32| (x as f32) / two);
match half {
Some(val) => println!("The sum of the elements divided by two is {}", val),
None => eprintln!("There is no sum"),
}
Dans le code ci-dessus, nous pouvons voir un exemple d'utilisation de la méthode map. Nous récupérons tout d'abord
la somme des éléments du tableau, sous forme d'option. Ensuite, pour transformer cette option, on utilise une closure,
qui permet de diviser un i32 par deux et qui retourne un f32. On transforme donc une Option<i32> en Option<f32>.
Si la méthode find_with_hof retourne une option vide, alors l'option retournée par map reste None.
let max_val: Option<i32> = find_with_hof(&tab, max_op);
let odd_max: Option<i32> = max_val.filter(|x| x % 2 == 1);
match odd_max {
Some(_) => println!("The maximum value is an odd number"),
None => {
if max_val.is_some() {
println!("The maximum value is an even number")
} else {
eprintln!("There is no maximum")
}
}
}
Ici, nous pouvons voir un exemple d'utilisation de la méthode filter. Nous cherchons le plus grand élément du tableau.
Ensuite, nous essayons de déterminer sa parité à l'aide d'une closure qui retourne true si le nombre est impair.
Ici, nous avons 2 étapes :
- La fonction
find_with_hofretourne une optionmax_val - Sur
max_val, nous appliquons un filtre, ce qui nous donneodd_max. Nous avons donc 3 cas possibles- Si
odd_maxcontient une valeur et que cette valeur est impaire, on affiche un message qui annonce que le maximum est impair. - Sinon si
max_valcontient une valeur, on affiche un message qui annonce que le maximum est pair. - Sinon,
max_valestNoneet donc il n'y a pas de maximum.
- Si
Rustlings
Il n'y a pas de rustlings à faire dans ce chapitre.