Discussion du code part08
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 fonctionelle 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 prends en argument une ou plusieurs fonctions et/ou retourne une ou plusieurs fonctions.
L'intérêt principal de nos fonctions anonyme 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'additioner 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 prends un i32
et retourne un i32
.
Puisque la fonction plus_two
prends 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 additione 1
à un entier. A 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'apperçoit qu'il s'agit d'une fonction qui prends deux éléments de type T
et en retourne
un troisième du même. 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 is T is Copyable. Which means that 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 par la partie 07. 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 intialisé à 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 recontre durant la première itération,
on utilise un simple assignation conditionnelle comme nous l'avons vu dans les cours précédent.
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 un 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 l'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 entre deux nombres.
On voit ici tout l'intérêt de nos fonctions d'ordre supérieur. Il me suffit d'écrire une seul 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 recontre parfois la traduction fermetures. Les closures ne sont pas simplement des fonctions sans identifiant, en effet, une closure capture son environement. Une closure est composée d'une fonction anonyme et des variables capturée dans son environnement.
Voici un exemple de code qui illustre le concept de caputre :
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 siginifie 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, c'est l'un des trois traits
qu'implémentent toutes les closures. Nous ne verrons pas les deux autres dans le cadre de ce cours, mais nous avons
FnOnce
et FnMut
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-desssus, on peut voir que la fonction do_stuff
prends 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 autre 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 impaire.
Ici, nous avons 2 étapes :
- La fonction
find_with_hof
retourne une optionmax_val
- Sur
max_val
, nous appliquons un filtre, ce qui nous donneodd_max
. Nous avons donc 3 cas possibles- Si
odd_max
contient une valeur et que cette valeur est impaire, on affiche un message qui annonce que le maximum est impaire. - Sinon si l'
max_val
contient une valeur, on affiche un message qui annonce que le maximum est paire. - Sinon,
max_val
estNone
et donc il n'y a pas de maximum.
- Si