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 :

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 prend 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 option max_val
  • Sur max_val, nous appliquons un filtre, ce qui nous donne odd_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 est None et donc il n'y a pas de maximum.

Rustlings

Il n'y a pas de rustlings à faire dans ce chapitre.